Skip to content

Commit

Permalink
Cleanup custom JWT properties. Add heartbeat endpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
EarthlingDavey committed Jul 26, 2024
1 parent 586231b commit ede507b
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 134 deletions.
2 changes: 1 addition & 1 deletion deploy/config/local/nginx/server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions public/app/mu-plugins/moj-auth/401.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.');
Expand All @@ -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) {
Expand Down
180 changes: 78 additions & 102 deletions public/app/mu-plugins/moj-auth/moj-auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,8 +47,6 @@ class Auth
private $debug = false;
private $https = false;
private $sub = '';
private $login_attempts = null;
private $success_uri = null;

/**
* Constructor
Expand All @@ -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();

Expand All @@ -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();
}

/**
Expand All @@ -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();
23 changes: 8 additions & 15 deletions public/app/mu-plugins/moj-auth/traits/jwt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()');

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions public/app/mu-plugins/moj-auth/traits/oauth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
7 changes: 0 additions & 7 deletions public/app/mu-plugins/moj-auth/verify.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down

0 comments on commit ede507b

Please sign in to comment.