From 3ffd18189dc56d938d2758546d489f20452cefe0 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Tue, 26 Nov 2024 18:59:58 +0000 Subject: [PATCH 1/8] Allow access to the intranet-archive user agent. --- .env.example | 3 +++ .github/workflows/deploy.yml | 1 + .github/workflows/ip-ranges-configure.yml | 3 +++ deploy/config/local/nginx/server.conf | 2 ++ deploy/config/server.conf | 2 ++ deploy/development/secret.tpl.yml | 1 + .../app/mu-plugins/moj-auth/traits/utils.php | 22 +++++++++++++++++++ public/app/mu-plugins/moj-auth/verify.php | 3 +++ 8 files changed, 37 insertions(+) diff --git a/.env.example b/.env.example index 5129dbd2d..ba817ad54 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,9 @@ OAUTH_CLIENT_ID= OAUTH_TENANT_ID= OAUTH_CLIENT_SECRET="" +# A token to grant the intranet-archive user-agent access. +INTRANET_ARCHIVE_TOKEN="" + # IP ranges in nginx geo format. 1 IP range per line, each range is followed by it's value. # @see https://nginx.org/en/docs/http/ngx_http_geo_module.html # AUTH is off diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22fa15a2b..42021569b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,6 +57,7 @@ jobs: BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} IGNORE_IP_RANGES: ${{ vars.IGNORE_IP_RANGES }} ALERTS_SLACK_WEBHOOK: ${{ secrets.ALERTS_SLACK_WEBHOOK }} + INTRANET_ARCHIVE_TOKEN: ${{ secrets.INTRANET_ARCHIVE_TOKEN }} run: | ## - - - - - - - - - - ## CloudFront - - - - diff --git a/.github/workflows/ip-ranges-configure.yml b/.github/workflows/ip-ranges-configure.yml index 6c45e9d63..ee5d6485e 100644 --- a/.github/workflows/ip-ranges-configure.yml +++ b/.github/workflows/ip-ranges-configure.yml @@ -34,13 +34,16 @@ jobs: # @see https://nginx.org/en/docs/http/ngx_http_geo_module.html ALLOW_VALUE=1 DEPRI_VALUE=2 + CLOUD_EGRES=5 ALLOW_FORMATTED=$(yq 'explode(.) | .allow_access_to_moj_intranet | flatten | map(. + " '$ALLOW_VALUE';") | join("\n")' moj-cidr-addresses.yml) DEPRI_FORMATTED=$(yq 'explode(.) | .deprecating_access_to_moj_intranet | flatten | map(. + " '$DEPRI_VALUE';") | join("\n")' moj-cidr-addresses.yml) + CLOUD_FORMATTED=$(yq 'explode(.) | .cloud_platform | flatten | map(. + " '$CLOUD_EGRES';") | join("\n")' moj-cidr-addresses.yml) CONFIG=$( echo "$ALLOW_FORMATTED" echo "$DEPRI_FORMATTED" + echo "$CLOUD_FORMATTED" ) CONFIG_ENCRYPTED=$( diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index aef7686c6..60ce4cabe 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -38,6 +38,7 @@ map $ip_group $ip_skip_oauth { 2 1; # Allowed IPs (depricating) 3 0; # Cloud Platform IPs 4 1; # Local IP + 5 0; # Cloud Platform Egress IPs default 0; # Default value } @@ -202,6 +203,7 @@ server { # The subrequest handler, WordPress is not loaded in this file. set $script_name /app/mu-plugins/moj-auth/verify.php; + fastcgi_param HTTP_X_MOJ_IP_GROUP $ip_group; include /etc/nginx/php-fpm-auth.conf; } diff --git a/deploy/config/server.conf b/deploy/config/server.conf index 3d3c29d09..970266a3d 100644 --- a/deploy/config/server.conf +++ b/deploy/config/server.conf @@ -40,6 +40,7 @@ map $ip_group $ip_skip_oauth { 2 1; # Allowed IPs (depricating) 3 0; # Cloud Platform IPs 4 1; # Local IP (127.0.0.1) + 5 0; # Cloud Platform Egress IPs default 0; # Default value } @@ -217,6 +218,7 @@ server { # The subrequest handler, WordPress is not loaded in this file. set $script_name /app/mu-plugins/moj-auth/verify.php; + fastcgi_param HTTP_X_MOJ_IP_GROUP $ip_group; include /etc/nginx/php-fpm-auth.conf; } diff --git a/deploy/development/secret.tpl.yml b/deploy/development/secret.tpl.yml index 676c91777..895da2887 100644 --- a/deploy/development/secret.tpl.yml +++ b/deploy/development/secret.tpl.yml @@ -20,6 +20,7 @@ stringData: OAUTH_CLIENT_SECRET: "${OAUTH_CLIENT_SECRET}" BASIC_AUTH_USER: "${BASIC_AUTH_USER}" BASIC_AUTH_PASS: "${BASIC_AUTH_PASS}" + INTRANET_ARCHIVE_TOKEN: "${INTRANET_ARCHIVE_TOKEN}" --- apiVersion: v1 kind: Secret diff --git a/public/app/mu-plugins/moj-auth/traits/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php index 7b995b1df..db0ece823 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -94,4 +94,26 @@ public function deleteCookie(string $name): void $this->setCookie($name, '', $this->now - 1); } + + /** + * Check if the request is from the intranet archiver. + * + * Checks the following: + * - the user-agent is 'intranet-archive' + * - the X-MOJ-IP-GROUP header is 5 (the IPs are Cloud Platform egress) + * - the X-ACCESS-TOKEN header is the same as the INTRANET_ARCHIVE_TOKEN env var. + * + * @return bool True if the request satisfies the above conditions. + */ + + public function isIntranetArchiver(): bool + { + $this->log('isIntranetArchiver()'); + + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + $group = $_SERVER['HTTP_X_MOJ_IP_GROUP'] ?? ''; + $access_token = $_SERVER['HTTP_X_ACCESS_TOKEN'] ?? ''; + + return (int) $group === 5 && $user_agent === 'intranet-archive' && $access_token === $_ENV['INTRANET_ARCHIVE_TOKEN']; + } } diff --git a/public/app/mu-plugins/moj-auth/verify.php b/public/app/mu-plugins/moj-auth/verify.php index 165a4f18a..d627fe8df 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -40,6 +40,9 @@ public function handleAuthRequest(string $required_role = 'reader'): void { $this->log('handleAuthRequest()'); + // If the response is from the intranet-archiver, then return a 200 response. + $this->isIntranetArchiver() && http_response_code(200) && exit(); + // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. $jwt = $this->getJwt(); From bd1507de918a7558b5f4f680192d76bb2f9f3ea1 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Tue, 26 Nov 2024 19:01:29 +0000 Subject: [PATCH 2/8] Update utils.php --- public/app/mu-plugins/moj-auth/traits/utils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/mu-plugins/moj-auth/traits/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php index db0ece823..7f34f109d 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -110,8 +110,8 @@ public function isIntranetArchiver(): bool { $this->log('isIntranetArchiver()'); - $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $group = $_SERVER['HTTP_X_MOJ_IP_GROUP'] ?? ''; + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $access_token = $_SERVER['HTTP_X_ACCESS_TOKEN'] ?? ''; return (int) $group === 5 && $user_agent === 'intranet-archive' && $access_token === $_ENV['INTRANET_ARCHIVE_TOKEN']; From b11b62cf8ee0054cf859d48602a01135d543d4e1 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:25:46 +0000 Subject: [PATCH 3/8] Add `wp gen-jwt intranet-archive` command. --- composer.json | 3 +- composer.lock | 94 ++++++++++++++++++- public/app/mu-plugins/moj-auth/401.php | 2 +- public/app/mu-plugins/moj-auth/moj-auth.php | 13 ++- public/app/mu-plugins/moj-auth/traits/cli.php | 68 ++++++++++++++ public/app/mu-plugins/moj-auth/traits/jwt.php | 93 ++++++++++++++++-- .../app/mu-plugins/moj-auth/traits/utils.php | 42 ++++++--- public/app/mu-plugins/moj-auth/verify.php | 9 +- 8 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 public/app/mu-plugins/moj-auth/traits/cli.php diff --git a/composer.json b/composer.json index 66adc4b83..60012dbc5 100644 --- a/composer.json +++ b/composer.json @@ -89,7 +89,8 @@ "php-http/guzzle7-adapter": "^1.0" }, "require-dev": { - "squizlabs/php_codesniffer": "^3.0.2" + "squizlabs/php_codesniffer": "^3.0.2", + "php-stubs/wp-cli-stubs": "^2.11" }, "extra": { "installer-paths": { diff --git a/composer.lock b/composer.lock index 2b77665ae..be3187d27 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": "72e8d00584ac1127269d674a0f7a177c", + "content-hash": "1b853a70d61abe7d630a2068edebfd73", "packages": [ { "name": "alphagov/notifications-php-client", @@ -3260,6 +3260,98 @@ } ], "packages-dev": [ + { + "name": "php-stubs/wordpress-stubs", + "version": "v6.7.1", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/83448e918bf06d1ed3d67ceb6a985fc266a02fd1", + "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1", + "shasum": "" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^4.13", + "php": "^7.4 || ^8.0", + "php-stubs/generator": "^0.8.3", + "phpdocumentor/reflection-docblock": "^5.4.1", + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^9.5", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.1" + }, + "time": "2024-11-24T03:57:09+00:00" + }, + { + "name": "php-stubs/wp-cli-stubs", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wp-cli-stubs.git", + "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/f27ff9e8e29d7962cb070e58de70dfaf63183007", + "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007", + "shasum": "" + }, + "require": { + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0" + }, + "require-dev": { + "php": "~7.3 || ~8.0", + "php-stubs/generator": "^0.8.0" + }, + "suggest": { + "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wp-cli-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress", + "wp-cli" + ], + "support": { + "issues": "https://github.com/php-stubs/wp-cli-stubs/issues", + "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.11.0" + }, + "time": "2024-11-25T10:09:13+00:00" + }, { "name": "squizlabs/php_codesniffer", "version": "3.10.3", diff --git a/public/app/mu-plugins/moj-auth/401.php b/public/app/mu-plugins/moj-auth/401.php index 71e489e90..dcb7db6af 100644 --- a/public/app/mu-plugins/moj-auth/401.php +++ b/public/app/mu-plugins/moj-auth/401.php @@ -96,7 +96,7 @@ public function handle401Request(): void $jwt->failed_callbacks = $jwt->failed_callbacks ?? 0; // Set a JWT without a role, to persist the user's ID, login attempts and success_url. - $jwt = $this->setJwt($jwt); + [$jwt] = $this->setJwt($jwt); $this->log('handle401Request failed_callbacks: ' . $jwt->failed_callbacks); diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php index 1e20ca384..3efa34e8b 100644 --- a/public/app/mu-plugins/moj-auth/moj-auth.php +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -23,6 +23,7 @@ return; } +require_once 'traits/cli.php'; require_once 'traits/jwt.php'; require_once 'traits/oauth.php'; require_once 'traits/utils.php'; @@ -39,6 +40,7 @@ class Auth { + use AuthCli; use AuthJwt; use AuthOauth; use AuthUtils; @@ -61,6 +63,7 @@ public function __construct(array $args = []) $this->debug = $args['debug'] ?? false; $this->https = isset($_SERVER['HTTPS']) || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO']); + $this->initCli(); $this->initJwt(); $this->initOauth(); } @@ -69,6 +72,10 @@ public function handleRequest(): void { $this->log('handleRequest()'); + if (defined('WP_CLI') && WP_CLI) { + return; + } + if (!$this->oauth_action) { return; } @@ -78,7 +85,7 @@ public function handleRequest(): void // Set a JWT without a role, to persist the user's ID. if (!$jwt) { - $jwt = $this->setJwt(); + $this->setJwt(); } if ('login' === $this->oauth_action) { @@ -187,7 +194,7 @@ public function handleHeartbeatRequest(): void // 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); + [$jwt] = $this->setJwt($jwt); $mutated_jwt = false; } @@ -218,7 +225,7 @@ public function handleHeartbeatRequest(): void // Set the JWT, if it's been mutated. Either by clearing properties, or it's been refreshed. if ($mutated_jwt) { - $jwt = $this->setJwt($jwt); + $this->setJwt($jwt); } return; diff --git a/public/app/mu-plugins/moj-auth/traits/cli.php b/public/app/mu-plugins/moj-auth/traits/cli.php new file mode 100644 index 000000000..be646f877 --- /dev/null +++ b/public/app/mu-plugins/moj-auth/traits/cli.php @@ -0,0 +1,68 @@ +log('in generateJwtCommand'); + + WP_CLI::log('GenerateJwt starting'); + + if (empty($args[0]) || $args[0] !== 'intranet-archive') { + WP_CLI::log('GenerateJwt the command was missing the role argument.'); + return; + } + + // Generate a JWT with the intranet-archive role and a long expiry. + [$jwt, $jwt_string] = $this->setJwt((object) [ + 'roles' => [$args[0]], + 'expiry' => time() + self::GENERATED_JWT_DURATION + ]); + + // Log the JWT object, so the user can see the contents. + WP_CLI::log('JWT contents: ' . print_r($jwt, true)); + + // Log the JWT string, this should be copied by the user and can be set as a cookie with name `jwt`. + WP_CLI::log('JWT cookie: ' . $jwt_string); + + WP_CLI::log('GenerateJwt complete'); + } +} diff --git a/public/app/mu-plugins/moj-auth/traits/jwt.php b/public/app/mu-plugins/moj-auth/traits/jwt.php index f2b59302e..a41a3a0ae 100644 --- a/public/app/mu-plugins/moj-auth/traits/jwt.php +++ b/public/app/mu-plugins/moj-auth/traits/jwt.php @@ -28,10 +28,21 @@ trait AuthJwt const JWT_DURATION = 60 * 60; // 1 hour const JWT_REFRESH = 60 * 2; // 2 minutes + // JWT roles and their conditions. + const JWT_ROLES = [ + // The reader role has no conditions. + 'reader' => true, + // The intranet-archive role has a condition that the IP group must be 5. + 'intranet-archive' => [ + 'conditions' => [ + 'ipGroupIn' => [5] + ] + ] + ]; + /** * Init */ - public function initJwt(): void { $this->log('initJwt()'); @@ -47,7 +58,6 @@ 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(): false | object { $this->log('getJwt()'); @@ -83,10 +93,9 @@ public function getJwt(): false | object /** * Set a JWT cookie. * - * @return object Returns the JWT payload. + * @return [object, string] Returns the JWT payload. */ - - public function setJwt(object $args = new \stdClass()): object + public function setJwt(object $args = new \stdClass()): array { $this->log('setJwt()'); @@ -116,10 +125,78 @@ public function setJwt(object $args = new \stdClass()): object $payload['success_url'] = $args->success_url; } - $jwt = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); + $jwt_string = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); + + $this->setCookie($this::JWT_COOKIE_NAME, $jwt_string, $cookie_expiry); + + return [(object) $payload, $jwt_string]; + } + + + /** + * Verify the JWT roles. + * + * @param [string] $jwt_roles The roles from the JWT. + * @return bool + */ + public function verifyJwtRoles(array $jwt_roles): bool + { + + $this->log('verifyJwtRoles()'); + + if (!is_array($jwt_roles)) { + $this->log('verifyJwtRoles() $jwt_roles is not an array.', null, 'error'); + return false; + } + + return $this->arrayAny($jwt_roles, function ($role) { + $role_props = $this::JWT_ROLES[$role] ?? false; - $this->setCookie($this::JWT_COOKIE_NAME, $jwt, $cookie_expiry); + if (!$role_props) { + $this->log('verifyJwtRoles() jwt role is not defined in JWT_ROLES.', null, 'error'); + return false; + } + + // If the role has no conditions, then return true. + if ($role_props === true) { + return true; + } + + $conditions = $role_props['conditions'] ?? false; + + if (!$conditions) { + $this->log('verifyJwtRoles() $conditions is false.', null, 'error'); + return false; + } + + return $this->arrayAll($conditions, function ($condition, $key) { + $method = 'verifyJwtRole' . ucfirst($key); + + if (!method_exists($this, $method)) { + $this->log('verifyJwtRoles() $method does not exist.', null, 'error'); + return false; + } + + return $this->$method($condition); + }); + }); + } + + /** + * Verify the IPs group of the request is in array. + * + * @param [int] $valid_groups + * @return bool + */ + public function verifyJwtRoleIpGroupIn(array $valid_groups): bool + { + $request_ip_group = $_SERVER['HTTP_X_MOJ_IP_GROUP'] ?? ''; + + if (!in_array((int) $request_ip_group, $valid_groups)) { + $this->log('verifyJwtRoleIpGroupIn() $request_ip_group is not in $valid_groups.', null, 'error'); + return false; + } - return (object) $payload; + return true; } } diff --git a/public/app/mu-plugins/moj-auth/traits/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php index 7f34f109d..210b5273f 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -96,24 +96,42 @@ public function deleteCookie(string $name): void } /** - * Check if the request is from the intranet archiver. + * Find an item in an array. * - * Checks the following: - * - the user-agent is 'intranet-archive' - * - the X-MOJ-IP-GROUP header is 5 (the IPs are Cloud Platform egress) - * - the X-ACCESS-TOKEN header is the same as the INTRANET_ARCHIVE_TOKEN env var. + * When we upgrade to PHP 8.4, we can use array_any instead. * - * @return bool True if the request satisfies the above conditions. + * @param array $array + * @param callable $callback + * + * @return mixed */ - public function isIntranetArchiver(): bool + public function arrayAny($array, $callback) { - $this->log('isIntranetArchiver()'); + foreach ($array as $entry) { + if (call_user_func($callback, $entry) === true) + return true; + } + return false; + } - $group = $_SERVER['HTTP_X_MOJ_IP_GROUP'] ?? ''; - $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; - $access_token = $_SERVER['HTTP_X_ACCESS_TOKEN'] ?? ''; + /** + * Ensure all items in an array satisfy the callback function. + * + * When we upgrade to PHP 8.4, we can use array_all instead. + * + * @param array $array + * @param callable $callback + * + * @return mixed + */ - return (int) $group === 5 && $user_agent === 'intranet-archive' && $access_token === $_ENV['INTRANET_ARCHIVE_TOKEN']; + public function arrayAll($array, $callback) + { + foreach ($array as $entry) { + if (call_user_func($callback, $entry) === false) + return false; + } + return true; } } diff --git a/public/app/mu-plugins/moj-auth/verify.php b/public/app/mu-plugins/moj-auth/verify.php index d627fe8df..efde4429b 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -36,20 +36,17 @@ public function __construct(array $args = []) $this->initJwt(); } - public function handleAuthRequest(string $required_role = 'reader'): void + public function handleAuthRequest(): void { $this->log('handleAuthRequest()'); - // If the response is from the intranet-archiver, then return a 200 response. - $this->isIntranetArchiver() && http_response_code(200) && exit(); - // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. $jwt = $this->getJwt(); // 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; + $jwt_verified_role = $this->verifyJwtRoles($jwt?->roles ?? []); - $status_code = $jwt_correct_role ? 200 : 401; + $status_code = $jwt_verified_role ? 200 : 401; // $status_code= 401; From ae55af9551e0d4df9c6a4997889737e7d9ff6a9a Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:27:47 +0000 Subject: [PATCH 4/8] Remove `INTRANET_ARCHIVE_TOKEN` --- .env.example | 3 --- .github/workflows/deploy.yml | 1 - deploy/development/secret.tpl.yml | 1 - 3 files changed, 5 deletions(-) diff --git a/.env.example b/.env.example index ba817ad54..5129dbd2d 100644 --- a/.env.example +++ b/.env.example @@ -84,9 +84,6 @@ OAUTH_CLIENT_ID= OAUTH_TENANT_ID= OAUTH_CLIENT_SECRET="" -# A token to grant the intranet-archive user-agent access. -INTRANET_ARCHIVE_TOKEN="" - # IP ranges in nginx geo format. 1 IP range per line, each range is followed by it's value. # @see https://nginx.org/en/docs/http/ngx_http_geo_module.html # AUTH is off diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42021569b..22fa15a2b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,7 +57,6 @@ jobs: BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} IGNORE_IP_RANGES: ${{ vars.IGNORE_IP_RANGES }} ALERTS_SLACK_WEBHOOK: ${{ secrets.ALERTS_SLACK_WEBHOOK }} - INTRANET_ARCHIVE_TOKEN: ${{ secrets.INTRANET_ARCHIVE_TOKEN }} run: | ## - - - - - - - - - - ## CloudFront - - - - diff --git a/deploy/development/secret.tpl.yml b/deploy/development/secret.tpl.yml index 895da2887..676c91777 100644 --- a/deploy/development/secret.tpl.yml +++ b/deploy/development/secret.tpl.yml @@ -20,7 +20,6 @@ stringData: OAUTH_CLIENT_SECRET: "${OAUTH_CLIENT_SECRET}" BASIC_AUTH_USER: "${BASIC_AUTH_USER}" BASIC_AUTH_PASS: "${BASIC_AUTH_PASS}" - INTRANET_ARCHIVE_TOKEN: "${INTRANET_ARCHIVE_TOKEN}" --- apiVersion: v1 kind: Secret From 9fea7038a019d36785e8403b9bf9647f7eb48cb1 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:53:28 +0000 Subject: [PATCH 5/8] Add key and values to `arrayAll` function. --- public/app/mu-plugins/moj-auth/traits/jwt.php | 7 +++---- public/app/mu-plugins/moj-auth/traits/utils.php | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/public/app/mu-plugins/moj-auth/traits/jwt.php b/public/app/mu-plugins/moj-auth/traits/jwt.php index a41a3a0ae..e2dae5094 100644 --- a/public/app/mu-plugins/moj-auth/traits/jwt.php +++ b/public/app/mu-plugins/moj-auth/traits/jwt.php @@ -141,7 +141,6 @@ public function setJwt(object $args = new \stdClass()): array */ public function verifyJwtRoles(array $jwt_roles): bool { - $this->log('verifyJwtRoles()'); if (!is_array($jwt_roles)) { @@ -169,15 +168,15 @@ public function verifyJwtRoles(array $jwt_roles): bool return false; } - return $this->arrayAll($conditions, function ($condition, $key) { - $method = 'verifyJwtRole' . ucfirst($key); + return $this->arrayAll($conditions, function ($condition_name, $condition_value) { + $method = 'verifyJwtRole' . ucfirst($condition_name); if (!method_exists($this, $method)) { $this->log('verifyJwtRoles() $method does not exist.', null, 'error'); return false; } - return $this->$method($condition); + return $this->$method($condition_value); }); }); } diff --git a/public/app/mu-plugins/moj-auth/traits/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php index 210b5273f..1f93af498 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -128,8 +128,8 @@ public function arrayAny($array, $callback) public function arrayAll($array, $callback) { - foreach ($array as $entry) { - if (call_user_func($callback, $entry) === false) + foreach ($array as $key => $value) { + if (call_user_func($callback, $key, $value) === false) return false; } return true; From 70f1c05e55e1a027fb5d3972f9b6bf5f7450984c Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:01:42 +0000 Subject: [PATCH 6/8] Update README.md --- .github/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/README.md b/.github/README.md index afe14132d..e67a41ae1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -363,6 +363,7 @@ To view the intranet content, visitors must meet one of the following criteria. - Be in an Allow List of IP ranges. - Or, have a Microsoft Azure account, within the organisation. +- Or, in the case of the intranet-archive scraper, have a valid JWT token. The visitor's IP is checked first, then if that check fails, they are redirected to the project's Entra application. @@ -546,6 +547,14 @@ This is for 2 reasons: - It will keep the OAuth session fresh, the endpoint handler will refresh OAuth tokens, and update JWTs before they expire. - If a visitor's state has changed, e.g. they have moved from an office with an allowed IP, then their browser content is blurred and they are prompted to refresh the page. +### Access for the Intranet Archive service. + +The intranet-archive service is a scraper that collects content from the intranet for archiving purposes. + +It is granted access via a JWT token, which is generated manually by running the `wp gen-jwt intranet-archive` command from an fpm container. + +The cookie has a role of `intranet-archive`. For this roll to be granted access to the intranet, the request IP must be one of Cloud Platform's egress IPs. + [License Link]: https://github.com/ministryofjustice/intranet/blob/main/LICENSE 'License.' From 6cb9eeca40bb35c72c81777bfd51bac5f0b919c4 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:04:19 +0000 Subject: [PATCH 7/8] Update cli.php --- public/app/mu-plugins/moj-auth/traits/cli.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/public/app/mu-plugins/moj-auth/traits/cli.php b/public/app/mu-plugins/moj-auth/traits/cli.php index be646f877..70768d20e 100644 --- a/public/app/mu-plugins/moj-auth/traits/cli.php +++ b/public/app/mu-plugins/moj-auth/traits/cli.php @@ -16,10 +16,8 @@ trait AuthCli { - const GENERATED_JWT_DURATION = 60 * 60 * 24 * 365 * 5; // 5 years - /** * Init the WP CLI command. * From cd240ef4c5adb12cfaeee1128479b3bc35799c0a Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:06:33 +0000 Subject: [PATCH 8/8] Change duration to 3 years, and add to readme --- .github/README.md | 2 ++ public/app/mu-plugins/moj-auth/traits/cli.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index e67a41ae1..293f3f858 100644 --- a/.github/README.md +++ b/.github/README.md @@ -555,6 +555,8 @@ It is granted access via a JWT token, which is generated manually by running the The cookie has a role of `intranet-archive`. For this roll to be granted access to the intranet, the request IP must be one of Cloud Platform's egress IPs. +When the JWT_SECRET is rotated, a new JWT token will need to be generated, and the Intranet Archive service will need to be updated with the new JWT. + [License Link]: https://github.com/ministryofjustice/intranet/blob/main/LICENSE 'License.' diff --git a/public/app/mu-plugins/moj-auth/traits/cli.php b/public/app/mu-plugins/moj-auth/traits/cli.php index 70768d20e..b72fc1ba1 100644 --- a/public/app/mu-plugins/moj-auth/traits/cli.php +++ b/public/app/mu-plugins/moj-auth/traits/cli.php @@ -16,7 +16,7 @@ trait AuthCli { - const GENERATED_JWT_DURATION = 60 * 60 * 24 * 365 * 5; // 5 years + const GENERATED_JWT_DURATION = 60 * 60 * 24 * 365 * 3; // 3 years /** * Init the WP CLI command.