From ede507bdb83d0cc835e9aea49901a34b5cbc0fd0 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:49:23 +0100 Subject: [PATCH] Cleanup custom JWT properties. Add heartbeat endpoint. --- deploy/config/local/nginx/server.conf | 2 +- public/app/mu-plugins/moj-auth/401.php | 12 +- public/app/mu-plugins/moj-auth/moj-auth.php | 180 ++++++++---------- public/app/mu-plugins/moj-auth/traits/jwt.php | 23 +-- .../app/mu-plugins/moj-auth/traits/oauth.php | 4 +- public/app/mu-plugins/moj-auth/verify.php | 7 - 6 files changed, 94 insertions(+), 134 deletions(-) diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index a9f8e19f9..ecb4a0fa0 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -173,7 +173,7 @@ server { } # Rewrite auth endpoints to fpm (WordPress's index.php) - location ~ ^/auth/(login|callback|logout) { + location ~ ^/auth/(login|callback|heartbeat|logout) { auth_request off; fastcgi_param HTTP_X_IP_GROUP $geo; rewrite /auth/* /index.php?$args; diff --git a/public/app/mu-plugins/moj-auth/401.php b/public/app/mu-plugins/moj-auth/401.php index f3b8b885a..4fba2b8e8 100644 --- a/public/app/mu-plugins/moj-auth/401.php +++ b/public/app/mu-plugins/moj-auth/401.php @@ -37,8 +37,6 @@ class Standalone401 private $debug = false; private $https = false; private $sub = ''; - private $login_attempts = null; - private $success_uri = null; const OAUTH_LOGIN_URI = '/auth/login'; const MAX_AUTO_LOGIN_ATTEMPTS = 5; @@ -50,7 +48,6 @@ public function __construct(array $args = []) $this->now = time(); $this->debug = $args['debug'] ?? false; $this->https = isset($_SERVER['HTTPS']) || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO']); - $this->success_uri = $_SERVER['REQUEST_URI']; if (!file_exists($this::STATIC_401)) { error_log('moj-auth/401.php 401.html was not found.'); @@ -70,13 +67,16 @@ public function handle401Request(): void $this->log('handle401Request()'); // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. - $jwt = $this->getJwt(); + $jwt = $this->getJwt() ?: (object)[]; // Set loginAttempts with a default of 1, or add one to the existing value. - $this->login_attempts = empty($jwt->login_attempts) ? 1 : ((int) $jwt->login_attempts) + 1; + $jwt->login_attempts = empty($jwt->login_attempts) ? 1 : ((int) $jwt->login_attempts) + 1; + + // Where to redirect the user after successful login. + $jwt->success_uri = $_SERVER['REQUEST_URI']; // Set a JWT without a role, to persist the user's ID, login attempts and success_uri. - $jwt = $this->setJwt(); + $jwt = $this->setJwt($jwt); // Is this the first few times a visitor has hit the 401 page? if ($jwt->login_attempts <= $this::MAX_AUTO_LOGIN_ATTEMPTS) { diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php index 28997e5e0..2606a3dc0 100644 --- a/public/app/mu-plugins/moj-auth/moj-auth.php +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -18,8 +18,6 @@ // Do not allow access outside WP defined('ABSPATH') || exit; - - // If the plugin isn't enabled, return early. if (Config::get('MOJ_AUTH_ENABLED') === false) { return; @@ -49,8 +47,6 @@ class Auth private $debug = false; private $https = false; private $sub = ''; - private $login_attempts = null; - private $success_uri = null; /** * Constructor @@ -69,8 +65,14 @@ public function __construct(array $args = []) $this->initOauth(); } - public function handleLoginRequest(): void + public function handleRequest(): void { + $this->log('handleRequest()'); + + if(!$this->oauth_action) { + return; + } + // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. $jwt = $this->getJwt(); @@ -79,136 +81,116 @@ public function handleLoginRequest(): void $jwt = $this->setJwt(); } - if ($this->oauth_action === 'login') { - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - $this->oauthLogin(); + if ('login' === $this->oauth_action) { + $this->handleLoginRequest(); + exit(); + } + + if ('callback' === $this->oauth_action) { + $this->handleCallbackRequest(); + exit(); + } + + if ('heartbeat' === $this->oauth_action) { + $this->handleHeartbeatRequest(); + exit(); + } + + if (!empty($this->oauth_action)) { + $this->log('Unknown oauth action'); + exit(); } } + public function handleLoginRequest(): void + { + $this->log('handleLoginRequest()'); + + // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. + $this->oauthLogin(); + } + public function handleCallbackRequest(): void { $this->log('handleCallbackRequest()'); // If we've hit the callback endpoint, then handle it here. On fail it exits with 401 & php code execution stops here. - $oauth_access_token = 'callback' === $this->oauth_action ? $this->oauthCallback() : null; + $oauth_access_token = $this->oauthCallback(); // The callback has returned an access token. - if (is_object($oauth_access_token) && !$oauth_access_token->hasExpired()) { - $this->log('Access token is valid. Will set JWT and store refresh token.'); - // Set a JWT cookie. - $this->setJwt((object)[ - 'expiry' => $oauth_access_token->getExpires(), - 'roles' => ['reader'], - ]); - // Store the tokens. - $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - // Redirect the user to the page they were trying to access. - header('Location: ' . \home_url($this->success_uri ?? '/')); - exit(); + if (!is_object($oauth_access_token) || $oauth_access_token->hasExpired()) { + $this->log('Access token is not valid, or expired.'); + return; } - } - /** - * Handle the page request - * - * This method is called on every page request. - * It checks the JWT cookie and the IP address to determine if the user should be allowed access. - * - * @param string $required_role The necessary role required to access the page. - * @return void - */ + $this->log('Access token is valid. Will set JWT and store refresh token.'); - public function handlePageRequest(string $required_role = 'reader'): void - { - $this->log('handlePageRequest()'); + $jwt = $this->getJwt() ?: (object)[]; - // TODO - is \headers_sent() a good idea? Could the user exploit a bug? - // If headers are already sent or we're doing a cron job, return early. - if (\headers_sent() || defined('DOING_CRON')) { - return; - } + $jwt->expiry = $oauth_access_token->getExpires(); + $jwt->roles = ['reader']; - // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. - $jwt = $this->getJwt(); + // Set a JWT cookie. + $this->setJwt($jwt); - // Set a JWT without a role, to persist the user's ID. - if (!$jwt) { - $jwt = $this->setJwt(); - } + // Store the tokens. + $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - $this->log('$this->oauth_action: ' . $this->oauth_action); + // Redirect the user to the page they were trying to access. + header('Location: ' . \home_url($jwt->success_uri ?? '/')) && exit(); + } - if ('login' === $this->oauth_action) { - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - $this->oauthLogin(); - } + public function handleHeartbeatRequest(): void + { + $this->log('handleHeartbeatRequest()'); - // If we've hit the callback endpoint, then handle it here. On fail it exits with 401 & php code execution stops here. - $oauth_access_token = 'callback' === $this->oauth_action ? $this->oauthCallback() : null; + // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. + $jwt = $this->getJwt(); - // The callback has returned an access token. - if (is_object($oauth_access_token) && !$oauth_access_token->hasExpired()) { - $this->log('Access token is valid. Will set JWT and store refresh token.'); - // Set a JWT cookie. - $this->setJwt((object)[ - 'expiry' => $oauth_access_token->getExpires(), - 'roles' => ['reader'] - ]); - // Store the tokens. - $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - // Get the origin request from the cookie. - $user_redirect = get_transient('oauth_user_url_' . $this->sub); - // Remove the transient. - delete_transient('oauth_user_url_' . $this->sub); - // Redirect the user to the page they were trying to access. - header('Location: ' . \home_url($user_redirect ?? '/')); - exit(); + if(!$jwt) { + return; } - // Get the roles from the JWT and check that they're sufficient. - $jwt_correct_role = $jwt && $jwt->roles ? in_array($required_role, $jwt->roles) : false; + // Keep track of JWT mutations. + $mutated_jwt = false; + + // Clear success_uri & login_attempts here? + if (!empty($jwt->login_attempts) || !empty($jwt->success_uri)) { + $mutated_jwt = true; + $jwt->login_attempts = null; + $jwt->success_uri = null; + } // Calculate the remaining time on the JWT token. $jwt_remaining_time = $jwt && $jwt->exp ? $jwt->exp - $this->now : 0; - // JWT is valid and it's not time to refresh it. - if ($jwt_correct_role && $jwt_remaining_time > $this::JWT_REFRESH) { + // It's not time to refresh the JWT, and we need to update the JWT. + if ($jwt_remaining_time > $this::JWT_REFRESH && $mutated_jwt) { + $jwt = $this->setJwt($jwt); + } + + // It's not time to refresh the JWT, return early. + if ($jwt_remaining_time > $this::JWT_REFRESH) { return; } /* - * There is no valid JWT, or it's about to expire. + * The JWT is about to expire. */ - // If the IP address is allowed, set a JWT and return. - if ($this->ipAddressIsAllowed()) { - $this->setJwt((object)['roles' => ['reader']]); - return; - } - // Refresh OAuth token if it's about to expire. $oauth_refresh_token = $this->sub ? $this->getStoredTokens($this->sub, 'refresh') : null; $oauth_refreshed_access_token = $oauth_refresh_token ? $this->oauthRefreshToken($oauth_refresh_token) : null; - + if (is_object($oauth_refreshed_access_token) && !$oauth_refreshed_access_token->hasExpired()) { $this->log('Refreshed access token is valid. Will set JWT and store refresh token.'); // Set a JWT cookie. - $jwt = $this->setJwt((object)[ - 'expiry' => $oauth_refreshed_access_token->getExpires(), - 'roles' => ['reader'] - ]); + $jwt->expiry = $oauth_refreshed_access_token->getExpires(); + $jwt->roles = ['reader']; + $jwt = $this->setJwt($jwt); // Store the tokens. $this->storeTokens($this->sub, $oauth_refreshed_access_token, 'refresh'); - return; - } - - // If there's any time left on the JWT then return. - if ($jwt_correct_role && $jwt_remaining_time > 0) { - return; } - - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - // $this->oauthLogin(); } /** @@ -227,11 +209,5 @@ public function logout(): void } -$auth = new Auth(['debug' => true]); // Config::get('MOJ_AUTH_DEBUG')]); -$auth->handleLoginRequest(); -$auth->handleCallbackRequest(); - -// Handle a normal page view -// - refresh token if necessary -// - clean up the success_uri -// - clean up the login_attempts +$auth = new Auth(['debug' => Config::get('MOJ_AUTH_DEBUG')]); +$auth->handleRequest(); diff --git a/public/app/mu-plugins/moj-auth/traits/jwt.php b/public/app/mu-plugins/moj-auth/traits/jwt.php index 7b50464c7..18785409a 100644 --- a/public/app/mu-plugins/moj-auth/traits/jwt.php +++ b/public/app/mu-plugins/moj-auth/traits/jwt.php @@ -41,7 +41,7 @@ public function initJwt(): void * @return bool|object Returns false if the JWT is not found or an object if it is found. */ - public function getJwt(): bool | object + public function getJwt(): false | object { $this->log('getJwt()'); @@ -70,14 +70,6 @@ public function getJwt(): bool | object $this->sub = $decoded->sub; } - if (!empty($decoded->login_attempts)) { - $this->login_attempts = $decoded->login_attempts; - } - - if (!empty($decoded->success_uri)) { - $this->success_uri = $decoded->success_uri; - } - return $decoded; } @@ -105,13 +97,14 @@ public function setJwt(object $args = new \stdClass()): object 'roles' => isset($args->roles) ? $args->roles : [], ]; - // Custom claims - conditionally add login_attempts & success_uri. - if(!empty($this->login_attempts)) { - $payload['login_attempts'] = $this->login_attempts; + // Custom claims - conditionally add login_attempts from $args or class property. + if(!empty($args->login_attempts)) { + $payload['login_attempts'] = $args->login_attempts; } - - if(!empty($this->success_uri)) { - $payload['success_uri'] = $this->success_uri; + + // Custom claims - conditionally add success_uri from $args or class property. + if(!empty($args->success_uri)) { + $payload['success_uri'] = $args->success_uri; } $jwt = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); diff --git a/public/app/mu-plugins/moj-auth/traits/oauth.php b/public/app/mu-plugins/moj-auth/traits/oauth.php index d3f5ef475..2c0bfdb80 100644 --- a/public/app/mu-plugins/moj-auth/traits/oauth.php +++ b/public/app/mu-plugins/moj-auth/traits/oauth.php @@ -111,9 +111,7 @@ public function oauthLogin(): void // Storing pkce prevents an attacker from potentially intercepting the auth code and using it. set_transient('oauth_pkce_' . $state_hashed, $oauth_client->getPkceCode(), 60 * 5); // 5 minutes - header('Location: ' . $authUrl); - // echo 'will redirect to: ' . $authUrl; - exit(); + header('Location: ' . $authUrl) && exit(); } /** diff --git a/public/app/mu-plugins/moj-auth/verify.php b/public/app/mu-plugins/moj-auth/verify.php index ae3e04a54..fc5daed7b 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -43,18 +43,11 @@ public function handleAuthRequest(string $required_role = 'reader'): void // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. $jwt = $this->getJwt(); - $this->log('jwt in auth request', $jwt); - // Get the roles from the JWT and check that they're sufficient. $jwt_correct_role = $jwt && $jwt->roles ? in_array($required_role, $jwt->roles) : false; $status_code = $jwt_correct_role ? 200 : 401; - $status_code = 200; - // $status_code = 401; - - $this->log('handleAuthRequest status: ' . $status_code); - http_response_code($status_code) && exit(); } }