diff --git a/.github/README.md b/.github/README.md index afe14132d..293f3f858 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,16 @@ 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. + +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/.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/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/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/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..b72fc1ba1 --- /dev/null +++ b/public/app/mu-plugins/moj-auth/traits/cli.php @@ -0,0 +1,66 @@ +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..e2dae5094 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,77 @@ 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_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_value); + }); + }); + } + + /** + * 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 7b995b1df..1f93af498 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -94,4 +94,44 @@ public function deleteCookie(string $name): void $this->setCookie($name, '', $this->now - 1); } + + /** + * Find an item in an array. + * + * When we upgrade to PHP 8.4, we can use array_any instead. + * + * @param array $array + * @param callable $callback + * + * @return mixed + */ + + public function arrayAny($array, $callback) + { + foreach ($array as $entry) { + if (call_user_func($callback, $entry) === true) + return true; + } + return false; + } + + /** + * 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 + */ + + public function arrayAll($array, $callback) + { + foreach ($array as $key => $value) { + if (call_user_func($callback, $key, $value) === 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 165a4f18a..efde4429b 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -36,7 +36,7 @@ public function __construct(array $args = []) $this->initJwt(); } - public function handleAuthRequest(string $required_role = 'reader'): void + public function handleAuthRequest(): void { $this->log('handleAuthRequest()'); @@ -44,9 +44,9 @@ public function handleAuthRequest(string $required_role = 'reader'): void $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;