Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CDPT-887 auth with nginx cache #612

Merged
merged 9 commits into from
Jul 29, 2024
Prev Previous commit
Next Next commit
Cleanup custom JWT properties. Add heartbeat endpoint.
  • Loading branch information
EarthlingDavey committed Jul 26, 2024
commit ede507bdb83d0cc835e9aea49901a34b5cbc0fd0
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) {
EarthlingDavey marked this conversation as resolved.
Show resolved Hide resolved
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;
EarthlingDavey marked this conversation as resolved.
Show resolved Hide resolved
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();
Dismissed Show dismissed Hide dismissed
}

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