From 97f5ba8f72c0a4ef1d8490387f06e5e22512490c Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:09:11 +0100 Subject: [PATCH] CDPT-1511 Intranet migration add microsoft login azure ad (#508) * WIP checkin * Auth class complete. * Make CDN a node application, for testing CloudFront functions. * Cookie signing iis working * Update moj-auth.php * Complete auth for allowed IPs, cdn validates the CloudFront signed cookies. * Remove oauth2 * Allow for key rotation. * Improve env var generation. * Update amazon-s3-and-cloudfront.php * Update moj-auth.php * Update local-key-gen.sh * Add CloudFront env vars. * Document format * Add allowed ips to ConfigMap. * Fix error: Each class must be in a file by itself * Azure auth draft working. - add oauth2 composer package - add env vars to example - add readme - how to make an azure entra app - add all error pages for nginx to statically serve * CDPT-1511 Move moj-auth to own folder to improve readability. * CDPT-1511 copy expiry from oauth to jwt. Improve docs add debugging. * Add Azure config values for dev deployment. * Allow for disabling OAuth. * Fix untrusted redirect vulnrability. * Move composer.json comment. * Update composer.lock * Improve composer.json comment * Update composer.lock * Update composer.json comments * Delete moj-auth.php duplicate --- .env.example | 5 + .github/README.md | 43 +++ .gitignore | 1 + composer.json | 23 +- composer.lock | 72 ++++- deploy/config/local/nginx/server.conf | 2 + deploy/config/server.conf | 2 + deploy/development/config.yml | 3 + deploy/development/secret.tpl | 1 + public/app/mu-plugins/moj-auth.php | 213 --------------- public/app/mu-plugins/moj-auth/jwt.php | 100 +++++++ public/app/mu-plugins/moj-auth/moj-auth.php | 169 ++++++++++++ public/app/mu-plugins/moj-auth/oauth.php | 256 ++++++++++++++++++ public/app/mu-plugins/moj-auth/utils.php | 142 ++++++++++ .../inc/amazon-s3-and-cloudfront-signing.php | 34 ++- public/info.php | 4 + 16 files changed, 851 insertions(+), 219 deletions(-) delete mode 100644 public/app/mu-plugins/moj-auth.php create mode 100644 public/app/mu-plugins/moj-auth/jwt.php create mode 100644 public/app/mu-plugins/moj-auth/moj-auth.php create mode 100644 public/app/mu-plugins/moj-auth/oauth.php create mode 100644 public/app/mu-plugins/moj-auth/utils.php diff --git a/.env.example b/.env.example index 2bcbf9d49..736556bf2 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,11 @@ SECURE_AUTH_SALT='generate-key' LOGGED_IN_SALT='generate-key' NONCE_SALT='generate-key' +# Entra API - see readme for more info. +OAUTH_CLIENT_ID= +OAUTH_TENNANT_ID= +OAUTH_CLIENT_SECRET="" + # IP addresses, with optional CIDR notation. Separated by newlines & # comments. ALLOWED_IPS=" # Home netowrk IP range - at http://intranet.docker/info.php > HTTP_X_REAL_IP diff --git a/.github/README.md b/.github/README.md index 35624e8f1..f0e39f927 100644 --- a/.github/README.md +++ b/.github/README.md @@ -265,6 +265,49 @@ To verify that S3 & CloudFront are working correctly. - The img source domain should be CloudFront. - Directly trying to access an image via the S3 bucket url should return an access denied message. +## Azure Setup + +### Useful links + +- [Ministry of Justice | Overview](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview) +- App [MOJ-Local-Intranet-v2](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/73ed65a5-e879-4027-beab-f5e64de803b7/isMSAApp~/false) +- App [MOJ-Dev-Intranet-V2](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/quickStartType~/null/sourceType/Microsoft_AAD_IAM/appId/1dac3cbf-91d2-4c0e-9c80-0bf3f8fabd75) + +### Register an application + +1. Go to the Azure portal and sign in with your account. +2. Click on the `Microsoft Entra ID` service. +3. Click on `App registrations`. +4. Click on `New registration`. +5. Fill in the form (adjust to the environment): + - Name: `MOJ-Local-Intranet-v2` + - Supported account types: `Accounts in this organizational directory only` + - Redirect URI: `Web` and `http://localhost/oauth2?action=callback` + or `https://dev-intranet.apps.live.cloud-platform.service.justice.gov.uk/oauth2?action=callback` etc. +6. Copy the `Application (client) ID` and `Directory (tenant) ID` values, + make them available as environment variables `OAUTH_CLIENT_ID`, `OAUTH_TENNANT_ID`. +7. Click on `Certificates & secrets` > `New client secret`. +8. Fill in the form: + - Description: `Local-Intranet-v2` + - Expires: `6 months` +9. Set a reminder to update the client secret before it expires. +10. Copy the `Value` value, make it available as environment variable `OAUTH_CLIENT_SECRET`. +11. Click on `Expose an API` > `Add a scope`. +12. Use the default Application ID URI, which is `api://`. +13. Fill in the form: + - Scope name: `user_impersonation` + - Who can consent: `Admins and users` + - Admin consent display name: `Access Intranet` + - Admin consent description: `Access Intranet on behalf of the signed-in user` + - User consent display name: `Access Intranet` + - User consent description: `Access Intranet on your behalf` +14. Click on `Add a client application`. +15. Enter the Client ID of the application you created. +16. Check the box next to the application you created. +17. Click on `Add application`. + +The oauth2 flow should now work with the Azure AD/Entra ID application. +You can get an Access Token, Refresh Token and an expiry of the token. diff --git a/.gitignore b/.gitignore index f52c0fb60..8ab671660 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ public/app/debug.log public/app/plugins/* public/app/db.php public/app/mu-plugins/*/ +!public/app/mu-plugins/moj-auth/ public/app/upgrade public/app/languages/* public/app/uploads/* diff --git a/composer.json b/composer.json index fdecb9ab2..9a6bd40eb 100644 --- a/composer.json +++ b/composer.json @@ -75,7 +75,9 @@ "stayallive/wp-sentry": "^7.11", "ext-posix": "*", "ext-mysqli": "*", - "ext-zlib": "*" + "ext-zlib": "*", + "league/oauth2-client": "^2.7", + "firebase/php-jwt": "^6.10" }, "require-dev": { "squizlabs/php_codesniffer": "^3.0.2" @@ -105,7 +107,21 @@ "vendor:koodimonni-theme-language" ] }, - "wordpress-install-dir": "public/wp" + "wordpress-install-dir": "public/wp", + "meta-comments": { + "description": [ + "As comments are not allowed in composer.json, use this section for comments.", + "After adding comments here, run a benign composer command like ", + "`composer update paragonie/random_compat` to update composer.lock's hash." + ], + "replace.paragonie/random_compat": [ + "The `paragonie/random_compat` package is a dependency of `league/oauth2-client`.", + "As our php version is > 7 we do not need this package.", + "The package authors have an empty version of the package for this case: 9.99.99", + "Defining it inside `replace`, will prevent it from being installed.", + "@see: https://github.com/paragonie/random_compat?tab=readme-ov-file#version-99999" + ] + } }, "scripts": { "post-root-package-install": [ @@ -118,6 +134,9 @@ "vendor/bin/phpcbf -d memory_limit=256M" ] }, + "replace": { + "paragonie/random_compat": "9.99.99" + }, "authors": [ { "name": "Ministry of Justice", diff --git a/composer.lock b/composer.lock index f682ab567..27d77bf1d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "71df532a39d5d93bdcebda577933f677", + "content-hash": "d08066b7cb591092462dcf090d0e47ae", "packages": [ { "name": "acf/advanced-custom-fields-pro", @@ -1014,6 +1014,76 @@ }, "time": "2022-02-02T11:42:57+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", + "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + }, + "time": "2023-04-16T18:19:15+00:00" + }, { "name": "ministryofjustice/like-button-for-wordpress", "version": "dev-main", diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index de2505804..1c7adde8c 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -30,6 +30,8 @@ server { # CUSTOM ERROR PAGES ### error_page 400 /app/themes/clarity/error-pages/400.html; + error_page 401 /app/themes/clarity/error-pages/401.html; + error_page 403 /app/themes/clarity/error-pages/403.html; error_page 404 /app/themes/clarity/error-pages/404.html; error_page 500 /app/themes/clarity/error-pages/500.html; error_page 503 /app/themes/clarity/error-pages/maintenance.html; diff --git a/deploy/config/server.conf b/deploy/config/server.conf index 224f3b077..0d31207c0 100644 --- a/deploy/config/server.conf +++ b/deploy/config/server.conf @@ -31,6 +31,8 @@ server { client_max_body_size 250m; error_page 400 /app/themes/clarity/error-pages/400.html; + error_page 401 /app/themes/clarity/error-pages/401.html; + error_page 403 /app/themes/clarity/error-pages/403.html; error_page 404 /app/themes/clarity/error-pages/404.html; error_page 500 /app/themes/clarity/error-pages/500.html; error_page 503 /app/themes/clarity/error-pages/maintenance.html; diff --git a/deploy/development/config.yml b/deploy/development/config.yml index a1bbdfb33..cc5947d0a 100644 --- a/deploy/development/config.yml +++ b/deploy/development/config.yml @@ -7,6 +7,9 @@ data: WP_ENV: "development" WP_HOME: 'https://dev.intranet.justice.gov.uk' WP_SITEURL: 'https://dev.intranet.justice.gov.uk/wp' + # See Azure Setup for more information on how to get these values. + OAUTH_CLIENT_ID: 1dac3cbf-91d2-4c0e-9c80-0bf3f8fabd75 + OAUTH_TENNANT_ID: c6874728-71e6-41fe-a9e1-2e8c36776ad8 # IP addresses, with optional CIDR notation. Separated by newlines and using # for comments. ALLOWED_IPS: | # Global Protect diff --git a/deploy/development/secret.tpl b/deploy/development/secret.tpl index 03738c530..d81f5a3d5 100644 --- a/deploy/development/secret.tpl +++ b/deploy/development/secret.tpl @@ -16,6 +16,7 @@ stringData: SECURE_AUTH_KEY: "${SECURE_AUTH_KEY}" SECURE_AUTH_SALT: "${SECURE_AUTH_SALT}" JWT_SECRET: "${JWT_SECRET}" + OAUTH_CLIENT_SECRET: "${OAUTH_CLIENT_SECRET}" --- apiVersion: v1 kind: Secret diff --git a/public/app/mu-plugins/moj-auth.php b/public/app/mu-plugins/moj-auth.php deleted file mode 100644 index f4cf5bb20..000000000 --- a/public/app/mu-plugins/moj-auth.php +++ /dev/null @@ -1,213 +0,0 @@ -now = time(); - $this->is_dev = $_ENV['WP_ENV'] === 'development'; - $this->jwt_secret = $_ENV['JWT_SECRET']; - - // Clear JWT_SECRET from $_ENV global. It's not required elsewhere in the app. - unset($_ENV['JWT_SECRET']); - } - - /** - * Checks if a given IP address matches the specified CIDR subnet/s - * - * @see https://gist.github.com/tott/7684443?permalink_comment_id=2108696#gistcomment-2108696 - * - * @param string $ip The IP address to check - * @param mixed $cidrs The IP subnet (string) or subnets (array) in CIDR notation - * @param string $match optional If provided, will contain the first matched IP subnet - * @return boolean TRUE if the IP matches a given subnet or FALSE if it does not - */ - - public function ipMatch($ip, $cidrs, &$match = null): bool - { - foreach ((array) $cidrs as $cidr) { - if (empty($cidr)) { - continue; - } - $parts = explode('/', $cidr); - $subnet = $parts[0]; - $mask = $parts[1] ?? 32; - if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) { - $match = $cidr; - return true; - } - } - return false; - } - - /** - * Check if the IP address is allowed. - * - * Checks that we have the environment variable ALLOWED_IPS and server property HTTP_X_REAL_IP set. - * Runs the ipMatch method to check if the HTTP_X_REAL_IP is in the ALLOWED_IPS. - * - * @return bool Returns true if the IP address is allowed, otherwise false. - */ - - public function ipAddressIsAllowed(): bool - { - - if (empty($_ENV['ALLOWED_IPS']) || empty($_SERVER['HTTP_X_REAL_IP'])) { - return false; - } - - $newline_pattern = '/\r\n|\n|\r/'; // Match newlines. - $comments_pattern = '/\s*#.*/'; // Match comments. - - $allowedIps = array_map( - 'trim', - preg_split($newline_pattern, preg_replace($comments_pattern, '', $_ENV['ALLOWED_IPS'])) - ); - - return $this->ipMatch($_SERVER['HTTP_X_REAL_IP'], $allowedIps); - } - - /** - * Get the JWT from the request. - * - * @return bool|object Returns false if the JWT is not found or an object if it is found. - */ - - public function getJwt(): bool | object - { - // Get the JWT cookie from the request. - $jwt = $_COOKIE[$this::JWT_COOKIE_NAME] ?? null; - - if (!is_string($jwt)) { - return false; - } - - try { - $decoded = JWT::decode($jwt, new Key($this->jwt_secret, $this::JWT_ALGORITHM)); - } catch (\Exception $e) { - \Sentry\captureException($e); - // TODO: remove this error_log once we confirm that this way of capturing to Sentry is working. - error_log($e->getMessage()); - return false; - } - - return $decoded; - } - - /** - * Set a JWT cookie. - * - * @return void - */ - - public function setJwt(): void - { - - $expiry = $this->now + $this::JWT_DURATION; - - $payload = [ - // Registered claims - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 - 'exp' => $expiry, - // Public claims - https://www.iana.org/assignments/jwt/jwt.xhtml - 'roles' => ['reader'] - ]; - - $jwt = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); - - // Build the cookie value - the the JWT cookie doesn't need to be accessed by the subdomains. - $cookie_parts = [ - $this::JWT_COOKIE_NAME . '=' . $jwt, - 'path=/', - 'HttpOnly', - 'Expires=' . gmdate('D, d M Y H:i:s T', $expiry), - 'SameSite=Strict', - ...($this->is_dev ? [] : ['Secure']), - ]; - - header('Set-Cookie: ' . implode('; ', $cookie_parts)); - } - - /** - * 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 - */ - - public function handlePageRequest(string $required_role = 'reader'): void - { - // Get the JWT token from the request. - $jwt = $this->getJwt(); - - // If headers are already sent or we're doing a cron job, return early. - if (\headers_sent() || defined('DOING_CRON')) { - 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; - - // 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) { - return; - } - - // There is no valid JWT, or it's about to expire. - if ($this->ipAddressIsAllowed()) { - // Set a JWT cookie. - $this->setJwt(); - return; - } - - // Here is a good place to handle Azure AD/Entra ID authentication. - - // If there's any time left on the JWT then return. - if ($jwt_remaining_time > 0) { - return; - } - - // If the IP address is not allowed and the JWT has expired, then deny access. - http_response_code(401); - include(get_template_directory() . '/error-pages/401.html'); - exit(); - } -} - -$auth = new Auth(); -$auth->handlePageRequest('reader'); diff --git a/public/app/mu-plugins/moj-auth/jwt.php b/public/app/mu-plugins/moj-auth/jwt.php new file mode 100644 index 000000000..14f3ff399 --- /dev/null +++ b/public/app/mu-plugins/moj-auth/jwt.php @@ -0,0 +1,100 @@ +log('initJwt()'); + + $this->jwt_secret = $_ENV['JWT_SECRET']; + + // Clear JWT_SECRET from $_ENV global. It's not required elsewhere in the app. + unset($_ENV['JWT_SECRET']); + } + + /** + * Get the JWT from the request. + * + * @return bool|object Returns false if the JWT is not found or an object if it is found. + */ + + public function getJwt(): bool | object + { + $this->log('getJwt()'); + + // Get the JWT cookie from the request. + $jwt = $_COOKIE[$this::JWT_COOKIE_NAME] ?? null; + + if (!is_string($jwt)) { + return false; + } + + try { + $decoded = JWT::decode($jwt, new Key($this->jwt_secret, $this::JWT_ALGORITHM)); + } catch (\Exception $e) { + \Sentry\captureException($e); + $this->error($e->getMessage()); + return false; + } + + if ($decoded && $decoded->sub) { + $this->sub = $decoded->sub; + } + + return $decoded; + } + + /** + * Set a JWT cookie. + * + * @return object Returns the JWT payload. + */ + + public function setJwt(array $args = []): object + { + $this->log('setJwt()'); + + $expiry = isset($args['expiry']) ? $args['expiry'] : $this->now + $this::JWT_DURATION; + + if (!$this->sub) { + $this->sub = bin2hex(random_bytes(16)); + } + + $payload = [ + // Registered claims - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + 'sub' => $this->sub, + 'exp' => $expiry, + // Public claims - https://www.iana.org/assignments/jwt/jwt.xhtml + 'roles' => isset($args['roles']) ? $args['roles'] : [], + ]; + + $jwt = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); + + $this->setCookie($this::JWT_COOKIE_NAME, $jwt, $expiry); + + return (object) $payload; + } +} diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php new file mode 100644 index 000000000..ae7d1e72f --- /dev/null +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -0,0 +1,169 @@ +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->initJwt(); + $this->initOauth(); + } + + /** + * 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 + */ + + public function handlePageRequest(string $required_role = 'reader'): void + { + $this->log('handlePageRequest()'); + + // If headers are already sent or we're doing a cron job, return early. + if (\headers_sent() || defined('DOING_CRON')) { + return; + } + + // 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 without a role, to persist the user's ID. + if (!$jwt) { + $jwt = $this->setJwt(); + } + + // 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; + + // 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([ + '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(); + } + + // 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; + + // 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) { + return; + } + + /* + * There is no valid JWT, or it's about to expire. + */ + + // If the IP address is allowed, set a JWT and return. + if ($this->ipAddressIsAllowed()) { + $this->setJwt(['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([ + 'expiry' => $oauth_access_token->getExpires(), + 'roles' => ['reader'] + ]); + // 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(); + } + + /** + * Log a user out. + * + * There is currently no UI machanism for logging out. This is here for completeness. + * If it's used in the future it should used proceded with revoking CloudFront cookies. + * + * @return void + */ + public function logout(): void + { + $this->deleteCookie($this::JWT_COOKIE_NAME); + http_response_code(401) && exit(); + } +} + +$auth = new Auth(['debug' => false]); +$auth->handlePageRequest('reader'); diff --git a/public/app/mu-plugins/moj-auth/oauth.php b/public/app/mu-plugins/moj-auth/oauth.php new file mode 100644 index 000000000..d6dcda6fd --- /dev/null +++ b/public/app/mu-plugins/moj-auth/oauth.php @@ -0,0 +1,256 @@ +log('initOauth()'); + + // Check for required environment variables. OAuth can be disable by not setting these. + $this->oauth_enabled = !empty($_ENV['OAUTH_TENNANT_ID']) && !empty($_ENV['OAUTH_CLIENT_ID']) && !empty($_ENV['OAUTH_CLIENT_SECRET']); + + if (!$this->oauth_enabled) { + $this->log('Missing OAuth environment variables'); + return; + } + + $this->oauth_tennant_id = $_ENV['OAUTH_TENNANT_ID']; + $this->oauth_authority = 'https://login.microsoftonline.com/' . $this->oauth_tennant_id; + $this->oauth_app_id = $_ENV['OAUTH_CLIENT_ID']; + $this->oauth_app_secret = $_ENV['OAUTH_CLIENT_SECRET']; + $this->oauth_scopes = [ + 'api://' . $this->oauth_app_id . '/user_impersonation', + 'offline_access' // To get a refresh token + ]; + if ( + isset($_SERVER['REQUEST_URI']) + && str_starts_with($_SERVER['REQUEST_URI'], '/oauth2') + && isset($_GET['action']) + && in_array($_GET['action'], ['callback', 'login', 'logout']) + ) { + $this->oauth_action = $_GET['action']; + } + + // Clear OAUTH_CLIENT_SECRET from $_ENV global. It's not required elsewhere in the app. + unset($_ENV['OAUTH_CLIENT_SECRET']); + } + + /** + * Get OAuth client. + * + * @return GenericProvider + */ + + public function getOAuthClient(): GenericProvider + { + $this->log('getOAuthClient()'); + + return new GenericProvider([ + 'clientId' => $this->oauth_app_id, + 'clientSecret' => $this->oauth_app_secret, + 'redirectUri' => \home_url($this::OAUTH_CALLBACK_URI), + 'urlAuthorize' => $this->oauth_authority . $this::OAUTH_AUTHORIZE_ENDPOINT, + 'urlAccessToken' => $this->oauth_authority . $this::OAUTH_TOKEN_ENDPOINT, + 'urlResourceOwnerDetails' => '', + 'scopes' => implode(' ', $this->oauth_scopes), + 'pkceMethod' => GenericProvider::PKCE_METHOD_S256 + ]); + } + + /** + * Handle the OAuth login. + * + * @return void + */ + + public function oauthLogin(): void + { + $this->log('oauthLogin()'); + + if (!$this->oauth_enabled) { + $this->log('OAuth is not enabled'); + http_response_code(401) && exit(); + } + + $oauth_client = $this->getOAuthClient(); + + $authUrl = $oauth_client->getAuthorizationUrl(); + + // Hash state (with a salt), else the user could read the state and make their cookie match the callback's state. + $state_hashed = $this->hash($oauth_client->getState()); + + // Use a cookie to store oauth state. + $this->setCookie($this::OAUTH_STATE_COOKIE_NAME, $state_hashed, -1); + + // Store the user's origin URL in a transient. + set_transient('oauth_user_url_' . $this->sub, $_SERVER['REQUEST_URI'] ?? '', 60 * 5); // 5 minutes + + // 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); + exit(); + } + + /** + * Handle the OAuth callback. + * + * This function will handle the OAuth callback and return the access token. + * If the callback is invalid, it will return a 401 response. + * + * @return AccessTokenInterface + */ + + public function oauthCallback(): AccessTokenInterface + { + $this->log('oauthCallback()'); + + if (!$this->oauth_enabled) { + $this->log('OAuth is not enabled'); + http_response_code(401) && exit(); + } + + if (!isset($_SERVER['REQUEST_URI']) || !str_starts_with($_SERVER['REQUEST_URI'], $this::OAUTH_CALLBACK_URI)) { + $this->log('in oauthCallback(), request uri does not match'); + http_response_code(401) && exit(); + } + + // Get the hashed expected state from the cookie. + $expected_state_hashed = $_COOKIE[$this::OAUTH_STATE_COOKIE_NAME] ?? null; + // Delete the cookie. + $this->deleteCookie($this::OAUTH_STATE_COOKIE_NAME); + + if (empty($expected_state_hashed)) { + $this->log('No hashed expected state in the cookie.'); + http_response_code(401) && exit(); + } + + // Get the pkce code from the transient. + $pkce = get_transient('oauth_pkce_' . $expected_state_hashed); + // Delete the transient. + delete_transient('oauth_pkce_' . $expected_state_hashed); + + // Check for state and code in the query params. + if (!isset($_GET['state']) || !isset($_GET['code'])) { + $this->log('No state or code in the query params'); + http_response_code(401) && exit(); + } + + if (empty($expected_state_hashed) || $expected_state_hashed !== $this->hash($_GET['state'])) { + $this->log('Hashed states do not match'); + http_response_code(401) && exit(); + } + + // Initialize the OAuth client. + $access_token = null; + $oauth_client = $this->getOAuthClient(); + + try { + // Set the pkce code. + $oauth_client->setPkceCode($pkce); + + // Make the token request + $access_token = $oauth_client->getAccessToken('authorization_code', ['code' => $_GET['code']]); + } catch (IdentityProviderException $e) { + $this->log('Error: ' . $e->getMessage()); + http_response_code(401) && exit(); + } + + return $access_token; + } + + /** + * Store the access and refresh tokens. + * + * @param string $sub The subject of the tokens, i.e. a generated user ID. + * @param AccessTokenInterface $access_token The access token object. + * @param string|null $type The type of token to store. If not set, both access and refresh tokens will be stored. + * @return void + */ + + public function storeTokens(string $sub, AccessTokenInterface $access_token, string|null $type = null): void + { + $this->log('storeTokens()'); + + if (!$type || $type === 'access') { + set_transient('access_token_' . $sub, $access_token->getToken(), $access_token->getExpires()); + } + if (!$type || $type === 'refresh') { + set_transient('refresh_token_' . $sub, $access_token->getRefreshToken(), $access_token->getExpires()); + } + } + + /** + * Get the stored tokens. + * + * @param string $sub The subject of the tokens, i.e. a generated user ID. + * @param string|null $type The type of token to get. If not set, both access and refresh tokens will be returned. + * @return array|string|null + */ + + public function getStoredTokens(string $sub, string|null $type = null): array|string|null + { + $this->log('getStoredTokens()'); + + if ($type === 'access') { + return get_transient('access_token_' . $sub); + } + + if ($type === 'refresh') { + return get_transient('refresh_token_' . $sub); + } + + return [ + 'access' => get_transient('access_token_' . $sub), + 'refresh' => get_transient('refresh_token_' . $sub) + ]; + } + + /** + * Refresh the OAuth access token. + * + * @param string $refresh_token The refresh token. + * @return AccessTokenInterface + */ + + public function oauthRefreshToken(string $refresh_token): AccessTokenInterface + { + $this->log('oauthRefreshToken()'); + + $oauth_client = $this->getOAuthClient(); + + $access_token = $oauth_client->getAccessToken('refresh_token', [ + 'refresh_token' => $refresh_token + ]); + + return $access_token; + } +} diff --git a/public/app/mu-plugins/moj-auth/utils.php b/public/app/mu-plugins/moj-auth/utils.php new file mode 100644 index 000000000..af134df1f --- /dev/null +++ b/public/app/mu-plugins/moj-auth/utils.php @@ -0,0 +1,142 @@ +debug) { + return; + } + + error_log($message . ' ' . print_r($data, true)); + } + + /** + * Checks if a given IP address matches the specified CIDR subnet/s + * + * @see https://gist.github.com/tott/7684443?permalink_comment_id=2108696#gistcomment-2108696 + * + * @param string $ip The IP address to check + * @param mixed $cidrs The IP subnet (string) or subnets (array) in CIDR notation + * @param string $match optional If provided, will contain the first matched IP subnet + * @return boolean TRUE if the IP matches a given subnet or FALSE if it does not + */ + + public function ipMatch($ip, $cidrs, &$match = null): bool + { + $this->log('ipMatch()'); + + foreach ((array) $cidrs as $cidr) { + if (empty($cidr)) { + continue; + } + $parts = explode('/', $cidr); + $subnet = $parts[0]; + $mask = $parts[1] ?? 32; + if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) { + $match = $cidr; + return true; + } + } + + return false; + } + + + /** + * Check if the IP address is allowed. + * + * Checks that we have the environment variables ALLOWED_IPS and REMOTE_ADDR set. + * Runs the ipMatch method to check if the REMOTE_ADDR is in the ALLOWED_IPS. + * + * @return bool Returns true if the IP address is allowed, otherwise false. + */ + + public function ipAddressIsAllowed(): bool + { + $this->log('ipAddressIsAllowed()'); + + if (empty($_ENV['ALLOWED_IPS']) || empty($_SERVER['REMOTE_ADDR'])) { + return false; + } + + $newline_pattern = '/\r\n|\n|\r/'; // Match newlines. + $comments_pattern = '/\s*#.*/'; // Match comments. + + $allowedIps = array_map( + 'trim', + preg_split($newline_pattern, preg_replace($comments_pattern, '', $_ENV['ALLOWED_IPS'])) + ); + + return $this->ipMatch($_SERVER['REMOTE_ADDR'], $allowedIps); + } + + /** + * Hash a value using SHA256 and a salt. + * + * @param string $value The value to hash. + * @return string The hashed value. + */ + + public function hash(string $value): string + { + $this->log('hash()'); + + return hash('sha256', $value . $_ENV['AUTH_SALT']); + } + + /** + * A generic function to set a cookie. + * + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + * @param int $expiry The expiry time of the cookie. If not set, the cookie will expire at the end of the session. + * @return void + */ + + public function setCookie(string $name, string $value, int $expiry = 0): void + { + $this->log('setCookie()'); + + $cookie_parts = [ + $name . '=' . $value, + 'path=/', + 'HttpOnly', + 'SameSite=Strict', + ...($this->https ? ['Secure'] : []), + ...($expiry > 0 ? ['Expires=' . gmdate('D, d M Y H:i:s T', $expiry)] : []), + ]; + + header('Set-Cookie: ' . implode('; ', $cookie_parts), false); + } + + /** + * Delete a cookie by setting it to expire in the past. + * + * @param string $name The name of the cookie to delete. + */ + + public function deleteCookie(string $name): void + { + $this->log('deleteCookie()'); + + $this->setCookie($name, '', $this->now - 1); + } +} diff --git a/public/app/themes/clarity/inc/amazon-s3-and-cloudfront-signing.php b/public/app/themes/clarity/inc/amazon-s3-and-cloudfront-signing.php index 786aa8604..72d71e83b 100644 --- a/public/app/themes/clarity/inc/amazon-s3-and-cloudfront-signing.php +++ b/public/app/themes/clarity/inc/amazon-s3-and-cloudfront-signing.php @@ -113,10 +113,10 @@ public function getCloudfrontPublicKeyId(): string // Derive public key from private key. It should be in the standard format (with a single newline at the end). $public_key_formatted = openssl_pkey_get_details($private_key)['key']; - + // Decode the JSON string to an array. $cloudfront_public_key_object = json_decode($_ENV['AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT'], true); - + // If the public key is not found, throw an exception. if (empty($cloudfront_public_key_object)) { throw new \Exception('AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT was not found'); @@ -129,7 +129,7 @@ public function getCloudfrontPublicKeyId(): string $public_key_short = substr($public_key_sha256, 0, 8); // Find the matching array entry for the public key. - $public_key_id_and_comment = array_filter($cloudfront_public_key_object, fn ($key) => $key['comment'] === $public_key_short && !empty($key['id']) ); + $public_key_id_and_comment = array_filter($cloudfront_public_key_object, fn ($key) => $key['comment'] === $public_key_short && !empty($key['id'])); // If the public key is not found, throw an exception. if (empty($public_key_id_and_comment)) { @@ -151,6 +151,7 @@ public function getCloudfrontPublicKeyId(): string public function createSignedCookie(string $url) { + // Expire Time - this is for the policy. It's not the cookie expiry, i.e. when it's removed from the browser. $expiry = $this->now + $this::CLOUDFRONT_DURATION; @@ -245,6 +246,33 @@ public function handlePageRequest(): void header(sprintf('Set-Cookie: %s=%s; %s', $name, $value, $cloudfront_cookie_params_string), false); } } + + /** + * Revoke the CloudFront cookies. + * + * Delete the cookies from the user's browser. + * + * @return void + */ + + public function revoke(): void + { + // Properties for the cookies. + $cloudfront_cookie_params = [ + 'path=/', + 'HttpOnly', + 'Domain=' . $this->cloudfront_cookie_domain, + 'SameSite=Strict', + 'Expires=' . gmdate('D, d M Y H:i:s T', 0), + ...($this->is_dev ? [] : ['Secure']), + ]; + $cloudfront_cookie_params_string = implode('; ', $cloudfront_cookie_params); + + // Delete the cookies. + foreach (['CloudFront-Key-Pair-Id', 'CloudFront-Policy', 'CloudFront-Signature'] as $name) { + header(sprintf('Set-Cookie: %s=; %s', $name, $cloudfront_cookie_params_string), false); + } + } } new AmazonS3AndCloudFrontSigning(); diff --git a/public/info.php b/public/info.php index 7cd8702ea..8175c11f7 100644 --- a/public/info.php +++ b/public/info.php @@ -8,6 +8,10 @@ ## ------------------------------------------------------------------------- ## ------------------------------------------------------------------------- +# Output the IP address of the client. To make sure ingress is passing it correctly. +if(!empty($_SERVER['REMOTE_ADDR'])) { + echo 'Your IP address is: ' . $_SERVER['REMOTE_ADDR']; +} # output all settings concerning the PHP installation phpinfo();