lucidAuth/include/lucidAuth.functions.php

227 lines
9.0 KiB
PHP

<?php
$configurationFile = '../lucidAuth.config.php';
if (!file_exists($configurationFile)) {
throw new Exception(sprintf('Missing config file. Please rename \'%1$s.example\' to \'%1$s\' and edit it to reflect your setup.', explode('../', $configurationFile)[1]));
}
$settings = include_once($configurationFile);
try {
# switch ($settings->Database['Driver']) {
# case 'sqlite':
# $database = new PDO('sqlite:' . $settings->Database['Path']);
if (is_writable($settings->Sqlite['Path'])) {
$pdoDB = new PDO('sqlite:' . $settings->Sqlite['Path']);
} else {
throw new Exception(sprintf('Database file \'%1$s\' is not writable', $settings->Sqlite['Path']));
}
# }
}
catch (Exception $e) {
throw new Exception(sprintf('Unable to connect to database \'%1$s\'', $settings->Sqlite['Path']));
}
function authenticateLDAP (string $username, string $password) {
global $settings;
if (!empty($username) && !empty($password)) {
// Handle login requests
$ds = ldap_connect($settings->LDAP['Server'], $settings->LDAP['Port']);
// Strict namingconvention: only allow alphabetic characters
$sanitizedUsername = preg_replace('([^a-zA-Z]*)', '', $_POST['username']);
$qualifiedUsername = $settings->LDAP['Domain'] . '\\' . $sanitizedUsername;
if (@ldap_bind($ds, $qualifiedUsername, utf8_encode($_POST['password']))) {
// Successful authentication; get additional userdetails from authenticationsource
$ldapSearchResults = ldap_search($ds, $settings->LDAP['BaseDN'], "sAMAccountName=$sanitizedUsername");
$commonName = ldap_get_entries($ds, $ldapSearchResults)[0]['cn'][0];
$browserDetails = get_browser(null, True);
$geoLocation = json_decode(file_get_contents("http://ip-api.com/json/{$_SERVER['HTTP_X_REAL_IP']}"));
if ($geoLocation->status === 'fail') {
switch ($geoLocation->message) {
case 'private range':
case 'reserved range':
$geoLocation = json_decode(file_get_contents("http://ip-api.com/json/" . trim(file_get_contents('https://api.ipify.org')) ));
break;
case 'invalid query':
default:
$geoLocation->city = null;
$geoLocation->countryCode = null;
break;
}
}
// Create JWT-payload
$jwtPayload = [
'iat' => time(), // Issued at: time when the token was generated
'iss' => $_SERVER['SERVER_NAME'], // Issuer
'sub' => $qualifiedUsername, // Subject (ie. username)
'name' => $commonName, // Common name (as retrieved from AD)
'fp' => base64_encode(json_encode((object) [ // Fingerprint
'browser' => $browserDetails['browser'],
'platform' => $browserDetails['platform'],
'city' => $geoLocation->city,
'countrycode' => $geoLocation->countryCode
]))
];
$secureToken = JWT::encode($jwtPayload, base64_decode($settings->JWT['PrivateKey_base64']));
return ['status' => 'Success', 'token' => $secureToken];
} else {
// LDAP authentication failed!
return ['status' => 'Fail', 'reason' => '1'];
}
} else {
// Empty username or passwords not allowed!
return ['status' => 'Fail', 'reason' => '1'];
}
}
function storeToken (string $secureToken, string $qualifiedUsername, string $httpHost) {
global $settings, $pdoDB;
// Save authentication token in database serverside
try {
$pdoQuery = $pdoDB->prepare('
INSERT INTO SecureToken (UserId, Value)
SELECT User.Id, :securetoken
FROM User
WHERE LOWER(User.Username) = :qualifiedusername
');
$pdoQuery->execute([
':securetoken' => $secureToken,
':qualifiedusername' => strtolower($qualifiedUsername)
]);
}
catch (Exception $e) {
return ['status' => 'Fail', 'reason' => $e];
}
// Save authentication token in cookie clientside
$cookieDomain = array_values(array_filter($settings->Session['CookieDomains'], function ($value) use ($httpHost) {
// Check if $_SERVER['HTTP_HOST'] matches any of the configured domains (either explicitly or as a subdomain)
// This might seem backwards, but relying on $_SERVER directly allows spoofed values with potential security risks
return (strlen($value) > strlen($httpHost)) ? false : (0 === substr_compare($httpHost, $value, -strlen($value)));
}))[0];
if ($cookieDomain && setcookie('JWT', $secureToken, (time() + $settings->Session['Duration']), '/', '.' . $cookieDomain)) {
return ['status' => 'Success'];
} else {
return ['status' => 'Fail', 'reason' => 'Unable to store cookie(s)'];
}
}
function validateToken (string $secureToken) {
global $settings, $pdoDB;
// Decode provided authentication token
try {
$jwtPayload = JWT::decode($secureToken, base64_decode($settings->JWT['PrivateKey_base64']), $settings->JWT['Algorithm']);
} catch (Exception $e) {
// Invalid token
if ($settings->Debug['LogToFile']) {
file_put_contents('../validateToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Provided token could not be decoded' . PHP_EOL, FILE_APPEND);
}
return ['status' => 'Fail', 'reason' => '1'];
}
if ((int)$jwtPayload->iat < (time() - (int)$settings->Session['Duration'])) {
// Expired token
if ($settings->Debug['LogToFile']) {
file_put_contents('../validateToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Provided token has expired' . PHP_EOL, FILE_APPEND);
}
return ['status' => 'Fail', 'reason' => '3'];
}
// Retrieve all authentication tokens from database matching username
$pdoQuery = $pdoDB->prepare('
SELECT User.Id, SecureToken.Value
FROM SecureToken
LEFT JOIN User
ON (User.Id=SecureToken.UserId)
WHERE LOWER(User.Username) = :username
');
$pdoQuery->execute([
':username' => (string) strtolower($jwtPayload->sub)
]);
foreach($pdoQuery->fetchAll(PDO::FETCH_ASSOC) as $row) {
try {
$storedTokens[] = JWT::decode($row['Value'], base64_decode($settings->JWT['PrivateKey_base64']), $settings->JWT['Algorithm']);
$currentUserId = $row['Id'];
} catch (Exception $e) {
continue;
}
}
// Compare provided authentication token to all stored tokens in database
if (!empty($storedTokens) && sizeof(array_filter($storedTokens, function ($value) use ($jwtPayload) {
return $value->iat === $jwtPayload->iat;
})) === 1) {
purgeTokens($currentUserId, $settings->Session['Duration']);
return [
'status' => 'Success',
'name' => $jwtPayload->name,
'uid' => $currentUserId
];
} else {
if ($settings->Debug['LogToFile']) {
file_put_contents('../validateToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Either no matching token or multiple matching tokens found in database' . PHP_EOL, FILE_APPEND);
}
return ['status' => 'Fail', 'reason' => '2'];
}
}
function purgeTokens(int $userID, int $maximumTokenAge) {
global $settings, $pdoDB;
$defunctTokens = []; $expiredTokens = [];
$pdoQuery = $pdoDB->prepare('
SELECT SecureToken.Id, SecureToken.Value
FROM SecureToken
WHERE SecureToken.UserId = :userid
');
$pdoQuery->execute([
':userid' => (int) $userID
]);
foreach($pdoQuery->fetchAll(PDO::FETCH_ASSOC) as $row) {
try {
$token = JWT::decode($row['Value'], base64_decode($settings->JWT['PrivateKey_base64']), $settings->JWT['Algorithm']);
if ($token->iat < (time() - $maximumTokenAge)) {
$expiredTokens[] = $row['Id'];
}
} catch (Exception $e) {
$defunctTokens[] = $row['Id'];
}
}
try {
// Sadly, PDO does not support named parameters in constructions like 'IN ( :array )'
// instead, the supported syntax is unnamed placeholders like 'IN (?, ?, ?, ...)'
$pdoQuery = $pdoDB->prepare('
DELETE FROM SecureToken
WHERE SecureToken.Id IN (' . implode( ',', array_fill(0, count(array_merge($defunctTokens, $expiredTokens)), '?')) . ')
');
$pdoQuery->execute(array_merge($defunctTokens, $expiredTokens));
if ($settings->Debug['LogToFile']) {
file_put_contents('../purgeToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Garbage collection succeeded (' . $userID . ' => ' . $pdoQuery->rowCount() . ')' . PHP_EOL, FILE_APPEND);
}
return [
'status' => 'Success',
'amount' => $pdoQuery->rowCount()
];
} catch (Exception $e) {
if ($settings->Debug['LogToFile']) {
file_put_contents('../purgeToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Garbage collection failed (' . $userID . ' => ' . $e . ')' . PHP_EOL, FILE_APPEND);
}
return ['status' => 'Fail', 'reason' => $e];
}
}
?>