diff --git a/README.md b/README.md index f6735f4..00dbc43 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,64 @@ -# lucidAuth -[![](https://img.shields.io/badge/status-in%20production-%23003399.svg)](#) [![](https://img.shields.io/badge/contributors-1-green.svg) ](#) - -Forward Authentication for use with proxies (caddy, nginx, traefik, etc) - -## Usage -- Create a new folder, navigate to it in a commandprompt and run the following command: - `git clone https://code.spamasaurus.com/djpbessems/lucidAuth.git` -- Edit `include/lucidAuth.config.php.example` to reflect your configuration and save as `include/lucidAuth.config.php` -- Create a new website (within any php-capable webserver) and make sure that the documentroot points to the `public` folder -- Check if you are able to browse to `https:///lucidAuth.login.php` (where `` is the actual domain -or IP address- your webserver is listening on) -- Edit your proxy's configuration to use the new website as forward proxy: - - #### ~~in Caddy/nginx~~ (planned for a later stage) - - - #### in Traefik - Add the following lines (change to reflect your existing configuration): - ``` - [frontends.server1] - entrypoints = ["https"] - backend = "server1" - [frontends.server1.auth.forward] - address = "https:///lucidAuth.validateRequest.php" - [frontends.server1.routes] - [frontends.server1.routes.ext] - rule = "Host:" - ``` - -- #### Important! - The domainname of the website made in step 3, needs to match the domainname (*ignoring subdomains, if any*) of the resource utilizing this authentication proxy. - -## Questions or bugs +# lucidAuth [![](https://img.shields.io/badge/status-in%20production-%23003399.svg)](#) [![](https://img.shields.io/badge/contributors-1-green.svg) ](#) +> *Respect* the unexpected, mitigate your risks + +Forward Authentication for use with proxies (caddy, nginx, traefik, etc) + +## Usage +- Create a new folder, navigate to it in a commandprompt and run the following command: + `git clone https://code.spamasaurus.com/djpbessems/lucidAuth.git` +- Edit `include/lucidAuth.config.php.example` to reflect your configuration and save as `include/lucidAuth.config.php` +- Create a new website (within any php-capable webserver) and make sure that the documentroot points to the `public` folder +- Check if you are able to browse to `https:///lucidAuth.login.php` (where `` is the actual domain -or IP address- your webserver is listening on) +- Edit your proxy's configuration to use the new website as forward proxy: + - #### ~~in Caddy/nginx~~ (planned for a later stage) + + - #### in Traefik + Add the following lines (change to reflect your existing configuration): +##### 1.7 + ``` + [frontends.server1] + entrypoints = ["https"] + backend = "server1" + [frontends.server1.auth.forward] + address = "https:///lucidAuth.validateRequest.php" + [frontends.server1.routes] + [frontends.server1.routes.ext] + rule = "Host:" + ``` +##### 2.0 + Either whitelist IP's which should be trusted to send `HTTP_X-Forwarded-*` headers, ór enable insecure-mode in your static configuration: + ``` + entryPoints: + https: + address: :443 + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" + - "192.168.1.0/24" + # insecure: true + ``` + Define a middleware that tells Traefik to forward requests for authentication in your dynamic file provider: + ``` + https: + middlewares: + ldap-authentication: + forwardAuth: + address: "https:///lucidAuth.validateRequest.php" + trustForwardHeader: true + ``` + And finally add the new middleware to your service (different methods; this depends on your configuration): + ``` + # as a label (when using Docker provider) + traefik.http.routers.router1.middlewares: "ldap-authentication@file" + # as yaml (when using file provider) + routers: + router1: + middlewares: + - "ldap-authentication" + ``` + +- #### Important! + The domainname of the website made in step 3, needs to match the domainname (*ignoring subdomains, if any*) of the resource utilizing this authentication proxy. + +## Questions or bugs Feel free to open issues in this repository. \ No newline at end of file diff --git a/include/lucidAuth.functions.php b/include/lucidAuth.functions.php index df2aab2..bf022e8 100644 --- a/include/lucidAuth.functions.php +++ b/include/lucidAuth.functions.php @@ -35,13 +35,36 @@ function authenticateLDAP (string $username, string $password) { 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]; - // Create JWT-payload + $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) + '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'])); @@ -121,8 +144,8 @@ function validateToken (string $secureToken) { WHERE LOWER(User.Username) = :username '); $pdoQuery->execute([ - ':username' => (string) strtolower($jwtPayload->sub) - ]); + ':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']); @@ -136,7 +159,9 @@ function validateToken (string $secureToken) { if (!empty($storedTokens) && sizeof(array_filter($storedTokens, function ($value) use ($jwtPayload) { return $value->iat === $jwtPayload->iat; })) === 1) { - return [ + purgeTokens($currentUserId, $settings->Session['Duration']); + + return [ 'status' => 'Success', 'name' => $jwtPayload->name, 'uid' => $currentUserId @@ -149,4 +174,85 @@ function validateToken (string $secureToken) { } } +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]; + } +} + +function deleteToken(array $tokenIDs, int $userID) { + 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($tokenIDs), '?')) . ') + AND SecureToken.UserId = :userid + '); + $pdoQuery->execute($tokenIDs,[ + ':userid' => (int) $userID + ]); + + if ($settings->Debug['LogToFile']) { + file_put_contents('../deleteToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Successfully deleted specific token(s) (' . $userID . ' => #' . $pdoQuery->rowCount() . ')' . PHP_EOL, FILE_APPEND); + } + + return [ + 'status' => 'Success', + 'amount' => $pdoQuery->rowCount() + ]; + } catch (Exception $e) { + if ($settings->Debug['LogToFile']) { + file_put_contents('../deleteToken.log', (new DateTime())->format('Y-m-d\TH:i:s.u') . ' --- Failed deleting specific token(s) (' . $userID . ' => ' . $e . ')' . PHP_EOL, FILE_APPEND); + } + + return ['status' => 'Fail', 'reason' => $e]; + } + +} + ?> \ No newline at end of file diff --git a/include/lucidAuth.template.php b/include/lucidAuth.template.php index ebc8c29..197c737 100644 --- a/include/lucidAuth.template.php +++ b/include/lucidAuth.template.php @@ -130,6 +130,22 @@ $contentLayout['manage']['section'] = <<<'MANAGE_SECTION' %1$s +
+ Sessions +
+ + + + + + + + + + + +
Timestamp<origin>DescriptionManage
+
MANAGE_SECTION; diff --git a/lucidAuth.config.php.example b/lucidAuth.config.php.example index 167797b..9653f67 100644 --- a/lucidAuth.config.php.example +++ b/lucidAuth.config.php.example @@ -18,6 +18,16 @@ return (object) array( // Specify the NetBios name of the domain; to allow users to log on with just their usernames. ], + '2FA' => [ + 'Protocol' => 'TOTP', // Possible options are HOTP (sequential codes) and TOTP (timebased codes) + 'TOTP' => [ + 'Secret' => 'NULL', // By default, a 512 bits secret is generated. If you need, you can provide your own secret here. + 'Age' => '30', // The duration that each OTP code is valid for. + 'Length' => '6', // Number of digits the OTP code will consist of. + 'Algorithm' => 'SHA256' // The hashing algorithm used. + ], + ], + 'Sqlite' => [ 'Path' => '../data/lucidAuth.sqlite.db' // Relative path to the location where the database should be stored @@ -37,6 +47,9 @@ return (object) array( 'CrossDomainLogin' => False, // Set this to True if SingleSignOn (albeit rudementary) is desired // (cookies are inheritently unaware of each other; clearing cookies for one domain does not affect other domains) + // Important! + // If you leave this set to False, the domainname where lucidAuth will be running on, + // needs to match the domainname (*ignoring subdomains, if any*) of the resource utilizing the authentication proxy. 'CookieDomains' => [ 'domain1.tld' #, 'domain2.tld', 'subdomain.domain3.tld' ] diff --git a/public/images/README.md b/public/images/README.md new file mode 100644 index 0000000..365a994 --- /dev/null +++ b/public/images/README.md @@ -0,0 +1 @@ +Browser logo's obtained from [alrra/browser-logos](https://github.com/alrra/browser-logos). \ No newline at end of file diff --git a/public/images/chrome_256x256.png b/public/images/chrome_256x256.png new file mode 100644 index 0000000..a8ae85e Binary files /dev/null and b/public/images/chrome_256x256.png differ diff --git a/public/images/edge_256x256.png b/public/images/edge_256x256.png new file mode 100644 index 0000000..f80a904 Binary files /dev/null and b/public/images/edge_256x256.png differ diff --git a/public/images/firefox_256x256.png b/public/images/firefox_256x256.png new file mode 100644 index 0000000..dc974fb Binary files /dev/null and b/public/images/firefox_256x256.png differ diff --git a/public/images/opera_256x256.png b/public/images/opera_256x256.png new file mode 100644 index 0000000..f90cc72 Binary files /dev/null and b/public/images/opera_256x256.png differ diff --git a/public/images/safari_256x256.png b/public/images/safari_256x256.png new file mode 100644 index 0000000..c09ac65 Binary files /dev/null and b/public/images/safari_256x256.png differ diff --git a/public/images/tor_256x256.png b/public/images/tor_256x256.png new file mode 100644 index 0000000..b1f54dc Binary files /dev/null and b/public/images/tor_256x256.png differ diff --git a/public/lucidAuth.manage.php b/public/lucidAuth.manage.php index c6ea335..263dc02 100644 --- a/public/lucidAuth.manage.php +++ b/public/lucidAuth.manage.php @@ -8,37 +8,73 @@ } if ($validateTokenResult['status'] === "Success") { - include_once('../include/lucidAuth.template.php'); + if ($_REQUEST['do'] === 'retrievesessions') { + $storedTokens = []; - try { - $allUsers = $pdoDB->query(' - SELECT User.Id, User.Username, Role.Rolename - FROM User - LEFT JOIN Role - ON (Role.Id = User.RoleId) - ')->fetchAll(PDO::FETCH_ASSOC); - } catch (Exception $e) { + $pdoQuery = $pdoDB->prepare(' + SELECT SecureToken.Id, SecureToken.UserId, SecureToken.Value + FROM SecureToken + WHERE SecureToken.UserId = :userid + '); + $pdoQuery->execute([ + ':userid' => (int) $_REQUEST['userid'] + ]); + foreach($pdoQuery->fetchAll(PDO::FETCH_ASSOC) as $row) { + try { + $JWTPayload = JWT::decode($row['Value'], base64_decode($settings->JWT['PrivateKey_base64']), $settings->JWT['Algorithm']); + $storedTokens[] = [ + 'tid' => $row['Id'], + 'iat' => $JWTPayload->iat, + 'iss' => $JWTPayload->iss, + 'fp' => $JWTPayload->fp + ]; + } catch (Exception $e) { + // Invalid token + continue; + } + } + + // Return JSON object + header('Content-Type: application/json'); + echo json_encode([ + "Result" => "Success", + "SessionCount" => sizeof($storedTokens), + "UserSessions" => json_encode($storedTokens) + ]); + } else { + // No action requested, default action + include_once('../include/lucidAuth.template.php'); + + try { + $allUsers = $pdoDB->query(' + SELECT User.Id, User.Username, Role.Rolename + FROM User + LEFT JOIN Role + ON (Role.Id = User.RoleId) + ')->fetchAll(PDO::FETCH_ASSOC); + } catch (Exception $e) { // Should really do some actual errorhandling here - throw new Exception($e); - } - foreach($allUsers as $row) { - $tableRows[] = sprintf('%3$s%4$s%5$s', - $validateTokenResult['uid'] === $row['Id'] ? ' class="currentuser"': null, - $row['Id'], - explode('\\', $row['Username'])[1], - $row['Rolename'], - '' . ($validateTokenResult['uid'] === $row['Id'] ? null : ' ') - ); - } + throw new Exception($e); + } + foreach($allUsers as $row) { + $tableRows[] = sprintf('%3$s%4$s%5$s', + $validateTokenResult['uid'] === $row['Id'] ? ' class="currentuser"': null, + $row['Id'], + explode('\\', $row['Username'])[1], + $row['Rolename'], + '' . ($validateTokenResult['uid'] === $row['Id'] ? null : ' ') + ); + } - echo sprintf($pageLayout['full_alt'], - sprintf($contentLayout['manage']['header'], - $validateTokenResult['name'] - ), - sprintf($contentLayout['manage']['section'], - implode($tableRows) - ) - ); + echo sprintf($pageLayout['full_alt'], + sprintf($contentLayout['manage']['header'], + $validateTokenResult['name'] + ), + sprintf($contentLayout['manage']['section'], + implode($tableRows) + ) + ); + } } else { // No cookie containing valid authentication token found; // explicitly deleting any remaining cookie, then redirecting to loginpage diff --git a/public/misc/script.index.js b/public/misc/script.index.js index 514079c..972e144 100644 --- a/public/misc/script.index.js +++ b/public/misc/script.index.js @@ -49,14 +49,19 @@ $(document).ready(function(){ token: data.SecureToken })) } - }).done(function() { + }).done(function(_data, _textStatus, jqXHR) { NProgress.inc(1 / XHR.length); + console.log('CrossDomain login succeeded for domain `' + domain + '` [' + JSON.stringify(jqXHR) + ']'); + }).fail(function(jqXHR) { + console.log('CrossDomain login failed for domain `' + domain + '` [' + JSON.stringify(jqXHR) + ')]'); + // Should check why this failed (timeout or bad request?), and if this is the origin domain, take action })); }); $.when.apply($, XHR).then(function(){ $.each(arguments, function(_index, _arg) { - // Finished cross-domain logins (either succesfully or through timeout) + // Finished cross-domain logins (either successfully or through timeout) NProgress.done(); + console.log('CrossDomain login completed; forwarding to `' + data.Location + '`'); window.location.replace(data.Location); }); }); diff --git a/public/misc/script.manage.js b/public/misc/script.manage.js index 2048dda..8c77255 100644 --- a/public/misc/script.manage.js +++ b/public/misc/script.manage.js @@ -1,7 +1,80 @@ +jQuery.fn.inlineConfirm = function() { + return this.on('click', function(event) { + sessionID = $(this).data('sessionid'); +// event.preventDefault(); + $(this).off('click').parent().empty().append( + $('