From d772a7045e9824889765a8e8f864f981446f4586 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Wed, 24 Jul 2024 11:03:09 +0100 Subject: [PATCH 1/9] WIP --- config/application.php | 2 +- deploy/config/local/nginx/server.conf | 33 +++++++++++++++++++++ public/app/mu-plugins/moj-auth/moj-auth.php | 33 ++++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/config/application.php b/config/application.php index e48301304..ae56f20dc 100644 --- a/config/application.php +++ b/config/application.php @@ -138,7 +138,7 @@ Config::define('COMPRESS_SCRIPTS', false); // Enable the authentication mu-plugin. -Config::define('MOJ_AUTH_ENABLED', false); +Config::define('MOJ_AUTH_ENABLED', true); /** * Debugging Settings diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index 04f1d258a..3cfc14203 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -26,6 +26,20 @@ fastcgi_cache_use_stale updating error timeout invalid_header http_500; fastcgi_cache_key "$request_method$host$request_uri:$cookie_dw_agency"; fastcgi_ignore_headers Cache-Control Expires Set-Cookie; +# geo $geo { +# default 0; + +# include ip-ranges.conf + + +# read from include a .conf file + +# ?? +# load an env variable + +# # Maybe use init script to substitude env vars +# } + server { listen 8080 default_server; # For default requests. server_name localhost; @@ -72,6 +86,12 @@ server { rewrite ^/wp-content/uploads/(.*)$ /app/uploads/$1 permanent; } + location = /index.php?auth=subrequest { + # internal; + include /etc/nginx/php-fpm.conf; + fastcgi_pass fpm; + } + ## # CACHING ## @@ -102,7 +122,20 @@ server { fastcgi_cache_purge mojiCache "$request_method$host$1:*"; } + # if X-IP-Status set on request then deny all + + # add_header X-IP-Status $geo; + + # ?? + # if IP is allowed AND no JWT from Azure { + # redirect to Azure + # } + location / { + + auth_request /index.php?auth=subrequest; + auth_request_set $auth_status $upstream_status; + # First attempt to serve request as file, then # as a directory, then pass the request to # WordPress's front controller. diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php index 1d3dcdf5c..aa19921b4 100644 --- a/public/app/mu-plugins/moj-auth/moj-auth.php +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -12,13 +12,14 @@ */ namespace MOJ\Intranet; + use Roots\WPConfig\Config; // Do not allow access outside WP defined('ABSPATH') || exit; // If the plugin isn't enabled, return early. -if(Config::get('MOJ_AUTH_ENABLED') === false) { +if (Config::get('MOJ_AUTH_ENABLED') === false) { return; } @@ -78,6 +79,11 @@ public function handlePageRequest(string $required_role = 'reader'): void { $this->log('handlePageRequest()'); + if (isset($_GET['auth']) && $_GET['auth'] !== 'subrequest'){ + return; + } + + // 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; @@ -159,6 +165,30 @@ public function handlePageRequest(string $required_role = 'reader'): void $this->oauthLogin(); } + public function handleAuthRequest(string $required_role = 'reader'): void + { + $this->log('handleAuthRequest()'); + + // var_dump($_GET['auth']); + // exit(); + + if (empty($_GET['auth']) || $_GET['auth'] !== 'subrequest') { + return; + } + + // 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; + + $response_code = $jwt_correct_role ? 200 : 401; + + $this->log('handleAuthRequest returning: ' . $response_code); + + http_response_code($response_code) && exit(); + } + /** * Log a user out. * @@ -176,3 +206,4 @@ public function logout(): void $auth = new Auth(['debug' => Config::get('MOJ_AUTH_DEBUG')]); $auth->handlePageRequest('reader'); +$auth->handleAuthRequest('reader'); From 3d7962f9270f56465003950789ca69cb24698a3c Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:20:34 +0100 Subject: [PATCH 2/9] non-authd users are not served cache --- deploy/config/local/nginx/server.conf | 45 ++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index 3cfc14203..bc0246b78 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -86,11 +86,9 @@ server { rewrite ^/wp-content/uploads/(.*)$ /app/uploads/$1 permanent; } - location = /index.php?auth=subrequest { - # internal; - include /etc/nginx/php-fpm.conf; - fastcgi_pass fpm; - } + # Every request must go through... + auth_request /auth/verify; + auth_request_set $auth_status $upstream_status; ## # CACHING @@ -113,6 +111,11 @@ server { set $skip_cache 1; } + # Don't cache any auth paths. + if ($request_uri ~* "/auth/") { + set $skip_cache 1; + } + # ...it's from a logged in user, the cookie 'wordpress_no_cache' exists, or we're mid moj-auth flow. if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|OAUTH_.*") { set $skip_cache 1; @@ -122,6 +125,11 @@ server { fastcgi_cache_purge mojiCache "$request_method$host$1:*"; } + # Skip cache if if the user is not auth'd + if ($auth_status != "200") { + set $skip_cache 1; + } + # if X-IP-Status set on request then deny all # add_header X-IP-Status $geo; @@ -131,10 +139,13 @@ server { # redirect to Azure # } + + + location / { - auth_request /index.php?auth=subrequest; - auth_request_set $auth_status $upstream_status; + # auth_request_set variables are not accessible inside location blocks + # satisfy all; # First attempt to serve request as file, then # as a directory, then pass the request to @@ -142,6 +153,26 @@ server { try_files $uri $uri/ /index.php?$args; } + # @see https://gock.net/blog/2020/nginx-subrequest-authentication-server + location = /auth/verify { + + # internaly only, /auth can not be accessed from outside + # internal; + + fastcgi_param SCRIPT_FILENAME $document_root/app/mu-plugins/moj-auth/standalone.php; + include fastcgi_params; + # override SCRIPT_NAME which was set in fastcgi_params + fastcgi_param SCRIPT_NAME /app/mu-plugins/moj-auth/standalone.php; + fastcgi_pass fpm; + } + + + + # Rewrite auth endpoints to fpm (WordPress's index.php) + location = /auth/(?:login|callback|logout) { + rewrite "/auth/$1" /index.php?$args; + } + # deny access to dotfiles accept .well-known # this will deny access to .git, .htaccess, .env, and other sensitive files location ~ /\.(?!well-known).* { From 5ef58f99534201663a47f314c8e4dae5ef8d289b Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:13:54 +0100 Subject: [PATCH 3/9] auth subrequest working and auth endpoints always allowed --- deploy/config/local/nginx/server.conf | 34 ++++++----- public/app/mu-plugins/moj-auth/jwt.php | 4 +- public/app/mu-plugins/moj-auth/standalone.php | 58 +++++++++++++++++++ public/app/mu-plugins/moj-auth/utils.php | 4 +- 4 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 public/app/mu-plugins/moj-auth/standalone.php diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index bc0246b78..d15d679a6 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -86,9 +86,7 @@ server { rewrite ^/wp-content/uploads/(.*)$ /app/uploads/$1 permanent; } - # Every request must go through... - auth_request /auth/verify; - auth_request_set $auth_status $upstream_status; + ## # CACHING @@ -125,6 +123,10 @@ server { fastcgi_cache_purge mojiCache "$request_method$host$1:*"; } + location = /purge-all-cache { + fastcgi_cache_purge mojiCache "$request_method$host*"; + } + # Skip cache if if the user is not auth'd if ($auth_status != "200") { set $skip_cache 1; @@ -139,11 +141,12 @@ server { # redirect to Azure # } - - - location / { + # Every request must go through... + auth_request /auth/verify; + auth_request_set $auth_status $upstream_status; + # auth_request_set variables are not accessible inside location blocks # satisfy all; @@ -155,21 +158,26 @@ server { # @see https://gock.net/blog/2020/nginx-subrequest-authentication-server location = /auth/verify { + # Internal only, so /auth/verify can not be accessed from outside. + internal; + + # This is needed to be set explicitly, as this endpoint is hit as a subrequest. + fastcgi_no_cache 1; - # internaly only, /auth can not be accessed from outside - # internal; + # The subrequest handler, WordPress is not loaded in this file. + set $script_name /app/mu-plugins/moj-auth/standalone.php; - fastcgi_param SCRIPT_FILENAME $document_root/app/mu-plugins/moj-auth/standalone.php; + # Prepare and pass the request to fpm. + fastcgi_param SCRIPT_FILENAME $document_root$script_name; include fastcgi_params; # override SCRIPT_NAME which was set in fastcgi_params - fastcgi_param SCRIPT_NAME /app/mu-plugins/moj-auth/standalone.php; + fastcgi_param SCRIPT_NAME $script_name; fastcgi_pass fpm; } - - # Rewrite auth endpoints to fpm (WordPress's index.php) - location = /auth/(?:login|callback|logout) { + location ~* ^/auth/(?:login|callback|logout)$ { + auth_request off; rewrite "/auth/$1" /index.php?$args; } diff --git a/public/app/mu-plugins/moj-auth/jwt.php b/public/app/mu-plugins/moj-auth/jwt.php index a4b28b127..9c844d80b 100644 --- a/public/app/mu-plugins/moj-auth/jwt.php +++ b/public/app/mu-plugins/moj-auth/jwt.php @@ -2,8 +2,8 @@ namespace MOJ\Intranet; -// Do not allow access outside WP -defined('ABSPATH') || exit; +// Do not allow access outside WP or standalone.php +defined('ABSPATH') || defined('DOING_STANDALONE_AUTH') || exit; /** * JWT functions for MOJ\Intranet\Auth. diff --git a/public/app/mu-plugins/moj-auth/standalone.php b/public/app/mu-plugins/moj-auth/standalone.php new file mode 100644 index 000000000..079b9d411 --- /dev/null +++ b/public/app/mu-plugins/moj-auth/standalone.php @@ -0,0 +1,58 @@ +debug = $args['debug'] ?? false; + + $this->initJwt(); + } + + public function handleAuthRequest(string $required_role = 'reader'): void + { + $this->log('handleAuthRequest()'); + + // 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; + + $status_code = $jwt_correct_role ? 200 : 401; + // $status_code = 401; + $status_code = 200; + + http_response_code($status_code) && exit(); + } +} + +$standalone_auth = new StandaloneAuth(['debug' => true]); +$standalone_auth->handleAuthRequest(); diff --git a/public/app/mu-plugins/moj-auth/utils.php b/public/app/mu-plugins/moj-auth/utils.php index 90f2e5670..c5b6dd88d 100644 --- a/public/app/mu-plugins/moj-auth/utils.php +++ b/public/app/mu-plugins/moj-auth/utils.php @@ -2,8 +2,8 @@ namespace MOJ\Intranet; -// Do not allow access outside WP -defined('ABSPATH') || exit; +// Do not allow access outside WP or standalone.php +defined('ABSPATH') || defined('DOING_STANDALONE_AUTH') || exit; /** * Util functions for MOJ\Intranet\Auth. From ff3c68067694e20483dfd257506be256520f0763 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Thu, 25 Jul 2024 13:58:35 +0100 Subject: [PATCH 4/9] Geo IP working with hardcoded IP ranges --- deploy/config/local/nginx/server.conf | 65 +++++++++---------- public/app/mu-plugins/moj-auth/standalone.php | 2 - 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index d15d679a6..3b4b7fd51 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -26,19 +26,19 @@ fastcgi_cache_use_stale updating error timeout invalid_header http_500; fastcgi_cache_key "$request_method$host$request_uri:$cookie_dw_agency"; fastcgi_ignore_headers Cache-Control Expires Set-Cookie; -# geo $geo { -# default 0; -# include ip-ranges.conf - - -# read from include a .conf file - -# ?? -# load an env variable +geo $geo { + # Trusted IPs where 'X-Forwarded-For' is used. + proxy 172.17.0.0/16; + proxy 172.25.0.0/16; + + # TODO + # read from include a .conf file + # Maybe use init script to substitude env vars + default 0; -# # Maybe use init script to substitude env vars -# } + 192.168.65.1 1; +} server { listen 8080 default_server; # For default requests. @@ -119,6 +119,11 @@ server { set $skip_cache 1; } + # Skip cache if the concatenated value doesn't contain 200 - the user is not allowed. + if ($auth_status !~ 200) { + set $skip_cache 1; + } + location ~ /purge-cache(/.*) { fastcgi_cache_purge mojiCache "$request_method$host$1:*"; } @@ -127,28 +132,12 @@ server { fastcgi_cache_purge mojiCache "$request_method$host*"; } - # Skip cache if if the user is not auth'd - if ($auth_status != "200") { - set $skip_cache 1; - } - - # if X-IP-Status set on request then deny all - - # add_header X-IP-Status $geo; - - # ?? - # if IP is allowed AND no JWT from Azure { - # redirect to Azure - # } - location / { - # Every request must go through... + # Every request must go through this subrequest. auth_request /auth/verify; - auth_request_set $auth_status $upstream_status; - - # auth_request_set variables are not accessible inside location blocks - # satisfy all; + # Concatenate the status returned by $geo and fpm query. + auth_request_set $auth_status $status$upstream_status; # First attempt to serve request as file, then # as a directory, then pass the request to @@ -159,10 +148,13 @@ server { # @see https://gock.net/blog/2020/nginx-subrequest-authentication-server location = /auth/verify { # Internal only, so /auth/verify can not be accessed from outside. - internal; + # internal; - # This is needed to be set explicitly, as this endpoint is hit as a subrequest. - fastcgi_no_cache 1; + if ( $geo != 0 ) { + add_header X-Geo $geo; + add_header Content-Type text/plain; + return 200; + } # The subrequest handler, WordPress is not loaded in this file. set $script_name /app/mu-plugins/moj-auth/standalone.php; @@ -173,10 +165,13 @@ server { # override SCRIPT_NAME which was set in fastcgi_params fastcgi_param SCRIPT_NAME $script_name; fastcgi_pass fpm; + + # This is needed to be set explicitly, as this endpoint is hit as a subrequest. + fastcgi_no_cache 1; } # Rewrite auth endpoints to fpm (WordPress's index.php) - location ~* ^/auth/(?:login|callback|logout)$ { + location ~ ^/auth/(?:login|callback|logout)$ { auth_request off; rewrite "/auth/$1" /index.php?$args; } @@ -209,6 +204,8 @@ server { } location ~ \.php$ { + # Send the IP status along, to use in in the application. + fastcgi_param HTTP_X_IP_GROUP $geo; include /etc/nginx/php-fpm.conf; fastcgi_pass fpm; } diff --git a/public/app/mu-plugins/moj-auth/standalone.php b/public/app/mu-plugins/moj-auth/standalone.php index 079b9d411..f4ad95860 100644 --- a/public/app/mu-plugins/moj-auth/standalone.php +++ b/public/app/mu-plugins/moj-auth/standalone.php @@ -47,8 +47,6 @@ public function handleAuthRequest(string $required_role = 'reader'): void $jwt_correct_role = $jwt && $jwt->roles ? in_array($required_role, $jwt->roles) : false; $status_code = $jwt_correct_role ? 200 : 401; - // $status_code = 401; - $status_code = 200; http_response_code($status_code) && exit(); } From 4d245765d9a8f5098be09aded374481c888d358e Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:58:25 +0100 Subject: [PATCH 5/9] Move traits to folder, add dynamic 401 page --- deploy/config/local/nginx/php-fpm-auth.conf | 12 ++ deploy/config/local/nginx/server.conf | 33 +++--- public/app/mu-plugins/moj-auth/401.php | 98 +++++++++++++++ public/app/mu-plugins/moj-auth/moj-auth.php | 112 +++++++++++------- .../moj-auth/templates/401-redirect.php | 32 +++++ .../mu-plugins/moj-auth/{ => traits}/jwt.php | 35 ++++-- .../moj-auth/{ => traits}/oauth.php | 16 +-- .../moj-auth/{ => traits}/utils.php | 8 +- .../moj-auth/{standalone.php => verify.php} | 17 ++- 9 files changed, 282 insertions(+), 81 deletions(-) create mode 100644 deploy/config/local/nginx/php-fpm-auth.conf create mode 100644 public/app/mu-plugins/moj-auth/401.php create mode 100644 public/app/mu-plugins/moj-auth/templates/401-redirect.php rename public/app/mu-plugins/moj-auth/{ => traits}/jwt.php (65%) rename public/app/mu-plugins/moj-auth/{ => traits}/oauth.php (93%) rename public/app/mu-plugins/moj-auth/{ => traits}/utils.php (94%) rename public/app/mu-plugins/moj-auth/{standalone.php => verify.php} (73%) diff --git a/deploy/config/local/nginx/php-fpm-auth.conf b/deploy/config/local/nginx/php-fpm-auth.conf new file mode 100644 index 000000000..2389f0d4c --- /dev/null +++ b/deploy/config/local/nginx/php-fpm-auth.conf @@ -0,0 +1,12 @@ +# Config file to pass requests to specific scripts in the fpm container. +# Used for *some* auth requests where we don't want to load WordPress. + +# Prepare and pass the request to fpm. +fastcgi_param SCRIPT_FILENAME $document_root$script_name; +include fastcgi_params; +# override SCRIPT_NAME which was set in fastcgi_params +fastcgi_param SCRIPT_NAME $script_name; +fastcgi_pass fpm; + +# This is needed to be set explicitly, as this endpoint is hit as a subrequest. +fastcgi_no_cache 1; diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index 3b4b7fd51..a9f8e19f9 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -62,7 +62,7 @@ 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 401 /auth/401; # Use a dynamic 401 page, to conditionally rediect to login. 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; @@ -86,7 +86,10 @@ server { rewrite ^/wp-content/uploads/(.*)$ /app/uploads/$1 permanent; } - + # We use the header X-Ip-Group internally, if the client sends this header return Bad Request. + if ($http_x_ip_group) { + return 400; + } ## # CACHING @@ -133,7 +136,6 @@ server { } location / { - # Every request must go through this subrequest. auth_request /auth/verify; # Concatenate the status returned by $geo and fpm query. @@ -148,7 +150,7 @@ server { # @see https://gock.net/blog/2020/nginx-subrequest-authentication-server location = /auth/verify { # Internal only, so /auth/verify can not be accessed from outside. - # internal; + internal; if ( $geo != 0 ) { add_header X-Geo $geo; @@ -157,23 +159,24 @@ server { } # The subrequest handler, WordPress is not loaded in this file. - set $script_name /app/mu-plugins/moj-auth/standalone.php; + set $script_name /app/mu-plugins/moj-auth/verify.php; + include /etc/nginx/php-fpm-auth.conf; + } - # Prepare and pass the request to fpm. - fastcgi_param SCRIPT_FILENAME $document_root$script_name; - include fastcgi_params; - # override SCRIPT_NAME which was set in fastcgi_params - fastcgi_param SCRIPT_NAME $script_name; - fastcgi_pass fpm; + location ~ ^/auth/(401) { + # Internal only, so /auth/verify can not be accessed from outside. + internal; - # This is needed to be set explicitly, as this endpoint is hit as a subrequest. - fastcgi_no_cache 1; + # The 401 handler, WordPress is not loaded in this file. + set $script_name /app/mu-plugins/moj-auth/$1.php; + include /etc/nginx/php-fpm-auth.conf; } # Rewrite auth endpoints to fpm (WordPress's index.php) - location ~ ^/auth/(?:login|callback|logout)$ { + location ~ ^/auth/(login|callback|logout) { auth_request off; - rewrite "/auth/$1" /index.php?$args; + fastcgi_param HTTP_X_IP_GROUP $geo; + rewrite /auth/* /index.php?$args; } # deny access to dotfiles accept .well-known diff --git a/public/app/mu-plugins/moj-auth/401.php b/public/app/mu-plugins/moj-auth/401.php new file mode 100644 index 000000000..f3b8b885a --- /dev/null +++ b/public/app/mu-plugins/moj-auth/401.php @@ -0,0 +1,98 @@ +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.'); + http_response_code(401) && exit(); + } + + if (!file_exists($this::REDIRECT_TEMPLATE)) { + error_log('moj-auth/401.php template was not found.'); + http_response_code(401) && exit(); + } + + $this->initJwt(); + } + + 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(); + + // 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; + + // Set a JWT without a role, to persist the user's ID, login attempts and success_uri. + $jwt = $this->setJwt(); + + // Is this the first few times a visitor has hit the 401 page? + if ($jwt->login_attempts <= $this::MAX_AUTO_LOGIN_ATTEMPTS) { + + // This template will redirect them to login. + require_once $this::REDIRECT_TEMPLATE; + + // Return early. + return; + } + + // The user has hit a 401 too many times, we won't redirect. + require_once $this::STATIC_401; + } +} + +$standalone_401 = new Standalone401(['debug' => true]); +$standalone_401->handle401Request(); +exit(); diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php index aa19921b4..28997e5e0 100644 --- a/public/app/mu-plugins/moj-auth/moj-auth.php +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -18,14 +18,16 @@ // 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; } -require_once 'jwt.php'; -require_once 'oauth.php'; -require_once 'utils.php'; +require_once 'traits/jwt.php'; +require_once 'traits/oauth.php'; +require_once 'traits/utils.php'; /** * Class Auth @@ -43,10 +45,12 @@ class Auth use AuthOauth; use AuthUtils; - private $now = null; - private $debug = false; - private $https = false; - private $sub = ''; + private $now = null; + private $debug = false; + private $https = false; + private $sub = ''; + private $login_attempts = null; + private $success_uri = null; /** * Constructor @@ -65,6 +69,45 @@ public function __construct(array $args = []) $this->initOauth(); } + public function handleLoginRequest(): void + { + // 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 ($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(); + } + } + + 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; + + // 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(); + } + } + /** * Handle the page request * @@ -79,10 +122,6 @@ public function handlePageRequest(string $required_role = 'reader'): void { $this->log('handlePageRequest()'); - if (isset($_GET['auth']) && $_GET['auth'] !== 'subrequest'){ - return; - } - // 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')) { @@ -97,6 +136,13 @@ public function handlePageRequest(string $required_role = 'reader'): void $jwt = $this->setJwt(); } + $this->log('$this->oauth_action: ' . $this->oauth_action); + + 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(); + } + // 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; @@ -104,7 +150,7 @@ public function handlePageRequest(string $required_role = 'reader'): void 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([ + $this->setJwt((object)[ 'expiry' => $oauth_access_token->getExpires(), 'roles' => ['reader'] ]); @@ -136,7 +182,7 @@ public function handlePageRequest(string $required_role = 'reader'): void // If the IP address is allowed, set a JWT and return. if ($this->ipAddressIsAllowed()) { - $this->setJwt(['roles' => ['reader']]); + $this->setJwt((object)['roles' => ['reader']]); return; } @@ -147,7 +193,7 @@ public function handlePageRequest(string $required_role = 'reader'): void 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([ + $jwt = $this->setJwt((object)[ 'expiry' => $oauth_refreshed_access_token->getExpires(), 'roles' => ['reader'] ]); @@ -162,31 +208,7 @@ public function handlePageRequest(string $required_role = 'reader'): void } // 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 handleAuthRequest(string $required_role = 'reader'): void - { - $this->log('handleAuthRequest()'); - - // var_dump($_GET['auth']); - // exit(); - - if (empty($_GET['auth']) || $_GET['auth'] !== 'subrequest') { - return; - } - - // 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; - - $response_code = $jwt_correct_role ? 200 : 401; - - $this->log('handleAuthRequest returning: ' . $response_code); - - http_response_code($response_code) && exit(); + // $this->oauthLogin(); } /** @@ -204,6 +226,12 @@ public function logout(): void } } -$auth = new Auth(['debug' => Config::get('MOJ_AUTH_DEBUG')]); -$auth->handlePageRequest('reader'); -$auth->handleAuthRequest('reader'); + +$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 diff --git a/public/app/mu-plugins/moj-auth/templates/401-redirect.php b/public/app/mu-plugins/moj-auth/templates/401-redirect.php new file mode 100644 index 000000000..3ec0f2c89 --- /dev/null +++ b/public/app/mu-plugins/moj-auth/templates/401-redirect.php @@ -0,0 +1,32 @@ + + + + + + + + + Page Redirection + + + + + If you are not redirected automatically, follow this link to login. + + + diff --git a/public/app/mu-plugins/moj-auth/jwt.php b/public/app/mu-plugins/moj-auth/traits/jwt.php similarity index 65% rename from public/app/mu-plugins/moj-auth/jwt.php rename to public/app/mu-plugins/moj-auth/traits/jwt.php index 9c844d80b..7b50464c7 100644 --- a/public/app/mu-plugins/moj-auth/jwt.php +++ b/public/app/mu-plugins/moj-auth/traits/jwt.php @@ -2,8 +2,8 @@ namespace MOJ\Intranet; -// Do not allow access outside WP or standalone.php -defined('ABSPATH') || defined('DOING_STANDALONE_AUTH') || exit; +// Do not allow access outside WP, 401.php or verify.php +defined('ABSPATH') || defined('DOING_STANDALONE_401') || defined('DOING_STANDALONE_VERIFY') || exit; /** * JWT functions for MOJ\Intranet\Auth. @@ -55,17 +55,29 @@ public function getJwt(): bool | object try { $decoded = JWT::decode($jwt, new Key($this->jwt_secret, $this::JWT_ALGORITHM)); } catch (\Exception $e) { - if($e->getMessage() !== 'Expired token') { + if ($e->getMessage() !== 'Expired token') { \Sentry\captureException($e); } $this->log($e->getMessage()); return false; } - if ($decoded && $decoded->sub) { + if(!$decoded) { + return $decoded; + } + + if ($decoded->sub) { $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; } @@ -75,11 +87,11 @@ public function getJwt(): bool | object * @return object Returns the JWT payload. */ - public function setJwt(array $args = []): object + public function setJwt(object $args = new \stdClass()): object { $this->log('setJwt()'); - $expiry = isset($args['expiry']) ? $args['expiry'] : $this->now + $this::JWT_DURATION; + $expiry = isset($args->expiry) ? $args->expiry : $this->now + $this::JWT_DURATION; if (!$this->sub) { $this->sub = bin2hex(random_bytes(16)); @@ -90,8 +102,17 @@ public function setJwt(array $args = []): object 'sub' => $this->sub, 'exp' => $expiry, // Public claims - https://www.iana.org/assignments/jwt/jwt.xhtml - 'roles' => isset($args['roles']) ? $args['roles'] : [], + '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; + } + + if(!empty($this->success_uri)) { + $payload['success_uri'] = $this->success_uri; + } $jwt = JWT::encode($payload, $this->jwt_secret, $this::JWT_ALGORITHM); diff --git a/public/app/mu-plugins/moj-auth/oauth.php b/public/app/mu-plugins/moj-auth/traits/oauth.php similarity index 93% rename from public/app/mu-plugins/moj-auth/oauth.php rename to public/app/mu-plugins/moj-auth/traits/oauth.php index e59c36184..d3f5ef475 100644 --- a/public/app/mu-plugins/moj-auth/oauth.php +++ b/public/app/mu-plugins/moj-auth/traits/oauth.php @@ -24,7 +24,7 @@ trait AuthOauth private $oauth_scopes = []; private $oauth_action = ''; - const OAUTH_CALLBACK_URI = '/oauth2?action=callback'; + const OAUTH_CALLBACK_URI = '/auth/callback'; const OAUTH_AUTHORIZE_ENDPOINT = '/oauth2/v2.0/authorize'; const OAUTH_TOKEN_ENDPOINT = '/oauth2/v2.0/token'; const OAUTH_STATE_COOKIE_NAME = 'OAUTH_STATE'; @@ -47,16 +47,14 @@ public function initOauth() $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', + 'User.Read', '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']) + isset($_SERVER['REQUEST_URI']) && str_starts_with ($_SERVER['REQUEST_URI'], '/auth/' ) ) { - $this->oauth_action = $_GET['action']; + $path = explode('?', $_SERVER['REQUEST_URI'])[0]; + $this->oauth_action = explode('/', $path )[2]; } // Clear OAUTH_CLIENT_SECRET from $_ENV global. It's not required elsewhere in the app. @@ -110,13 +108,11 @@ public function oauthLogin(): void // 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); + // echo 'will redirect to: ' . $authUrl; exit(); } diff --git a/public/app/mu-plugins/moj-auth/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php similarity index 94% rename from public/app/mu-plugins/moj-auth/utils.php rename to public/app/mu-plugins/moj-auth/traits/utils.php index c5b6dd88d..ff56604bf 100644 --- a/public/app/mu-plugins/moj-auth/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -2,8 +2,8 @@ namespace MOJ\Intranet; -// Do not allow access outside WP or standalone.php -defined('ABSPATH') || defined('DOING_STANDALONE_AUTH') || exit; +// Do not allow access outside WP, 401.php or verify.php +defined('ABSPATH') || defined('DOING_STANDALONE_401') || defined('DOING_STANDALONE_VERIFY') || exit; /** * Util functions for MOJ\Intranet\Auth. @@ -73,6 +73,8 @@ public function ipAddressIsAllowed(): bool { $this->log('ipAddressIsAllowed()'); + return false; + if (empty($_ENV['ALLOWED_IPS']) || empty($_SERVER['HTTP_X_REAL_IP'])) { return false; } @@ -129,6 +131,8 @@ public function setCookie(string $name, string $value, int $expiry = 0): void ...($expiry > 0 ? ['Expires=' . gmdate('D, d M Y H:i:s T', $expiry)] : []), ]; + // $this->log('setCookie()', $cookie_parts); + header('Set-Cookie: ' . implode('; ', $cookie_parts), false); } diff --git a/public/app/mu-plugins/moj-auth/standalone.php b/public/app/mu-plugins/moj-auth/verify.php similarity index 73% rename from public/app/mu-plugins/moj-auth/standalone.php rename to public/app/mu-plugins/moj-auth/verify.php index f4ad95860..ae3e04a54 100644 --- a/public/app/mu-plugins/moj-auth/standalone.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -4,22 +4,22 @@ // Exit if this file is included within a WordPress request. if (defined('ABSPATH')) { - error_log('moj-auth/standalone.php was accessed within the context of WordPress.'); + error_log('moj-auth/verify.php was accessed within the context of WordPress.'); http_response_code(401) && exit(); } -define('DOING_STANDALONE_AUTH', true); +define('DOING_STANDALONE_VERIFY', true); $autoload = '../../../../vendor/autoload.php'; if (!file_exists($autoload)) { - error_log('moj-auth/standalone.php autoloader.php was not found.'); + error_log('moj-auth/verify.php autoloader.php was not found.'); http_response_code(401) && exit(); } require_once $autoload; -require_once 'jwt.php'; -require_once 'utils.php'; +require_once 'traits/jwt.php'; +require_once 'traits/utils.php'; class StandaloneAuth { @@ -43,11 +43,18 @@ 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(); } } From 586231bbed98ba73af11b42db89ee961c6df73c1 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:47:45 +0100 Subject: [PATCH 6/9] Add heartbeat request --- public/app/themes/clarity/src/globals/js/auth-heartbeat.js | 6 ++++++ public/app/themes/clarity/src/globals/js/script-loader.js | 1 + 2 files changed, 7 insertions(+) create mode 100644 public/app/themes/clarity/src/globals/js/auth-heartbeat.js diff --git a/public/app/themes/clarity/src/globals/js/auth-heartbeat.js b/public/app/themes/clarity/src/globals/js/auth-heartbeat.js new file mode 100644 index 000000000..11b649813 --- /dev/null +++ b/public/app/themes/clarity/src/globals/js/auth-heartbeat.js @@ -0,0 +1,6 @@ +export default (function ($) { + // Sent a request to the heartbeat endpoint, this will refresh the oauth token. + setInterval(function(){ + $.get( "/auth/heartbeat" ) + }, 30000) +})(jQuery) diff --git a/public/app/themes/clarity/src/globals/js/script-loader.js b/public/app/themes/clarity/src/globals/js/script-loader.js index 1717c40dc..80bc6416b 100644 --- a/public/app/themes/clarity/src/globals/js/script-loader.js +++ b/public/app/themes/clarity/src/globals/js/script-loader.js @@ -13,6 +13,7 @@ import "../../components/c-input-container/on-change.js"; import "../../components/c-notes-from-antonia/lazy_load.js"; // Global scripts +import "./auth-heartbeat.js"; import "./blog-content_filter.js"; import "./condolences-filter.js"; import "./equaliser.js"; From ede507bdb83d0cc835e9aea49901a34b5cbc0fd0 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:49:23 +0100 Subject: [PATCH 7/9] Cleanup custom JWT properties. Add heartbeat endpoint. --- deploy/config/local/nginx/server.conf | 2 +- public/app/mu-plugins/moj-auth/401.php | 12 +- public/app/mu-plugins/moj-auth/moj-auth.php | 180 ++++++++---------- public/app/mu-plugins/moj-auth/traits/jwt.php | 23 +-- .../app/mu-plugins/moj-auth/traits/oauth.php | 4 +- public/app/mu-plugins/moj-auth/verify.php | 7 - 6 files changed, 94 insertions(+), 134 deletions(-) diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index a9f8e19f9..ecb4a0fa0 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -173,7 +173,7 @@ server { } # Rewrite auth endpoints to fpm (WordPress's index.php) - location ~ ^/auth/(login|callback|logout) { + location ~ ^/auth/(login|callback|heartbeat|logout) { auth_request off; fastcgi_param HTTP_X_IP_GROUP $geo; rewrite /auth/* /index.php?$args; diff --git a/public/app/mu-plugins/moj-auth/401.php b/public/app/mu-plugins/moj-auth/401.php index f3b8b885a..4fba2b8e8 100644 --- a/public/app/mu-plugins/moj-auth/401.php +++ b/public/app/mu-plugins/moj-auth/401.php @@ -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; @@ -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.'); @@ -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) { diff --git a/public/app/mu-plugins/moj-auth/moj-auth.php b/public/app/mu-plugins/moj-auth/moj-auth.php index 28997e5e0..2606a3dc0 100644 --- a/public/app/mu-plugins/moj-auth/moj-auth.php +++ b/public/app/mu-plugins/moj-auth/moj-auth.php @@ -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; @@ -49,8 +47,6 @@ class Auth private $debug = false; private $https = false; private $sub = ''; - private $login_attempts = null; - private $success_uri = null; /** * Constructor @@ -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(); @@ -79,136 +81,116 @@ public function handleLoginRequest(): void $jwt = $this->setJwt(); } - if ($this->oauth_action === 'login') { - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - $this->oauthLogin(); + if ('login' === $this->oauth_action) { + $this->handleLoginRequest(); + exit(); + } + + if ('callback' === $this->oauth_action) { + $this->handleCallbackRequest(); + exit(); + } + + if ('heartbeat' === $this->oauth_action) { + $this->handleHeartbeatRequest(); + exit(); + } + + if (!empty($this->oauth_action)) { + $this->log('Unknown oauth action'); + exit(); } } + public function handleLoginRequest(): void + { + $this->log('handleLoginRequest()'); + + // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. + $this->oauthLogin(); + } + public function handleCallbackRequest(): void { $this->log('handleCallbackRequest()'); // If we've hit the callback endpoint, then handle it here. On fail it exits with 401 & php code execution stops here. - $oauth_access_token = 'callback' === $this->oauth_action ? $this->oauthCallback() : null; + $oauth_access_token = $this->oauthCallback(); // The callback has returned an access token. - if (is_object($oauth_access_token) && !$oauth_access_token->hasExpired()) { - $this->log('Access token is valid. Will set JWT and store refresh token.'); - // Set a JWT cookie. - $this->setJwt((object)[ - 'expiry' => $oauth_access_token->getExpires(), - 'roles' => ['reader'], - ]); - // Store the tokens. - $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - // Redirect the user to the page they were trying to access. - header('Location: ' . \home_url($this->success_uri ?? '/')); - exit(); + if (!is_object($oauth_access_token) || $oauth_access_token->hasExpired()) { + $this->log('Access token is not valid, or expired.'); + return; } - } - /** - * Handle the page request - * - * This method is called on every page request. - * It checks the JWT cookie and the IP address to determine if the user should be allowed access. - * - * @param string $required_role The necessary role required to access the page. - * @return void - */ + $this->log('Access token is valid. Will set JWT and store refresh token.'); - public function handlePageRequest(string $required_role = 'reader'): void - { - $this->log('handlePageRequest()'); + $jwt = $this->getJwt() ?: (object)[]; - // TODO - is \headers_sent() a good idea? Could the user exploit a bug? - // If headers are already sent or we're doing a cron job, return early. - if (\headers_sent() || defined('DOING_CRON')) { - return; - } + $jwt->expiry = $oauth_access_token->getExpires(); + $jwt->roles = ['reader']; - // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. - $jwt = $this->getJwt(); + // Set a JWT cookie. + $this->setJwt($jwt); - // Set a JWT without a role, to persist the user's ID. - if (!$jwt) { - $jwt = $this->setJwt(); - } + // Store the tokens. + $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - $this->log('$this->oauth_action: ' . $this->oauth_action); + // Redirect the user to the page they were trying to access. + header('Location: ' . \home_url($jwt->success_uri ?? '/')) && exit(); + } - if ('login' === $this->oauth_action) { - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - $this->oauthLogin(); - } + public function handleHeartbeatRequest(): void + { + $this->log('handleHeartbeatRequest()'); - // If we've hit the callback endpoint, then handle it here. On fail it exits with 401 & php code execution stops here. - $oauth_access_token = 'callback' === $this->oauth_action ? $this->oauthCallback() : null; + // Get the JWT token from the request. Do this early so that we populate $this->sub if it's known. + $jwt = $this->getJwt(); - // The callback has returned an access token. - if (is_object($oauth_access_token) && !$oauth_access_token->hasExpired()) { - $this->log('Access token is valid. Will set JWT and store refresh token.'); - // Set a JWT cookie. - $this->setJwt((object)[ - 'expiry' => $oauth_access_token->getExpires(), - 'roles' => ['reader'] - ]); - // Store the tokens. - $this->storeTokens($this->sub, $oauth_access_token, 'refresh'); - // Get the origin request from the cookie. - $user_redirect = get_transient('oauth_user_url_' . $this->sub); - // Remove the transient. - delete_transient('oauth_user_url_' . $this->sub); - // Redirect the user to the page they were trying to access. - header('Location: ' . \home_url($user_redirect ?? '/')); - exit(); + if(!$jwt) { + return; } - // Get the roles from the JWT and check that they're sufficient. - $jwt_correct_role = $jwt && $jwt->roles ? in_array($required_role, $jwt->roles) : false; + // Keep track of JWT mutations. + $mutated_jwt = false; + + // Clear success_uri & login_attempts here? + if (!empty($jwt->login_attempts) || !empty($jwt->success_uri)) { + $mutated_jwt = true; + $jwt->login_attempts = null; + $jwt->success_uri = null; + } // Calculate the remaining time on the JWT token. $jwt_remaining_time = $jwt && $jwt->exp ? $jwt->exp - $this->now : 0; - // JWT is valid and it's not time to refresh it. - if ($jwt_correct_role && $jwt_remaining_time > $this::JWT_REFRESH) { + // It's not time to refresh the JWT, and we need to update the JWT. + if ($jwt_remaining_time > $this::JWT_REFRESH && $mutated_jwt) { + $jwt = $this->setJwt($jwt); + } + + // It's not time to refresh the JWT, return early. + if ($jwt_remaining_time > $this::JWT_REFRESH) { return; } /* - * There is no valid JWT, or it's about to expire. + * The JWT is about to expire. */ - // If the IP address is allowed, set a JWT and return. - if ($this->ipAddressIsAllowed()) { - $this->setJwt((object)['roles' => ['reader']]); - return; - } - // Refresh OAuth token if it's about to expire. $oauth_refresh_token = $this->sub ? $this->getStoredTokens($this->sub, 'refresh') : null; $oauth_refreshed_access_token = $oauth_refresh_token ? $this->oauthRefreshToken($oauth_refresh_token) : null; - + if (is_object($oauth_refreshed_access_token) && !$oauth_refreshed_access_token->hasExpired()) { $this->log('Refreshed access token is valid. Will set JWT and store refresh token.'); // Set a JWT cookie. - $jwt = $this->setJwt((object)[ - 'expiry' => $oauth_refreshed_access_token->getExpires(), - 'roles' => ['reader'] - ]); + $jwt->expiry = $oauth_refreshed_access_token->getExpires(); + $jwt->roles = ['reader']; + $jwt = $this->setJwt($jwt); // Store the tokens. $this->storeTokens($this->sub, $oauth_refreshed_access_token, 'refresh'); - return; - } - - // If there's any time left on the JWT then return. - if ($jwt_correct_role && $jwt_remaining_time > 0) { - return; } - - // Handle Azure AD/Entra ID OAuth. It redirects to Azure or exits with 401 if disabled. php code execution always stops here. - // $this->oauthLogin(); } /** @@ -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(); diff --git a/public/app/mu-plugins/moj-auth/traits/jwt.php b/public/app/mu-plugins/moj-auth/traits/jwt.php index 7b50464c7..18785409a 100644 --- a/public/app/mu-plugins/moj-auth/traits/jwt.php +++ b/public/app/mu-plugins/moj-auth/traits/jwt.php @@ -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()'); @@ -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; } @@ -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); diff --git a/public/app/mu-plugins/moj-auth/traits/oauth.php b/public/app/mu-plugins/moj-auth/traits/oauth.php index d3f5ef475..2c0bfdb80 100644 --- a/public/app/mu-plugins/moj-auth/traits/oauth.php +++ b/public/app/mu-plugins/moj-auth/traits/oauth.php @@ -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(); } /** diff --git a/public/app/mu-plugins/moj-auth/verify.php b/public/app/mu-plugins/moj-auth/verify.php index ae3e04a54..fc5daed7b 100644 --- a/public/app/mu-plugins/moj-auth/verify.php +++ b/public/app/mu-plugins/moj-auth/verify.php @@ -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(); } } From 9848caeaed2c4614e34e6cc9130740a5297a9893 Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:55:37 +0100 Subject: [PATCH 8/9] Add IPS_FORMATTED to develop --- .github/workflows/deploy.yml | 10 +++++++--- .github/workflows/integration.yml | 4 ++++ .github/workflows/ip-ranges-configure.yml | 14 ++++++++++++++ deploy/config/local/nginx/geo.conf | 7 +++++++ deploy/development/deployment.tpl.yml | 6 ++++++ deploy/development/secret.tpl.yml | 1 + 6 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 deploy/config/local/nginx/geo.conf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88360b2a1..2b6124900 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -51,6 +51,7 @@ jobs: BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} IP_RANGES: ${{ inputs.ip_ranges }} + IPS_FORMATTED: ${{ inputs.ips_formatted }} IGNORE_IP_RANGES: ${{ vars.IGNORE_IP_RANGES }} run: | ## - - - - - - - - - - @@ -83,16 +84,19 @@ jobs: ## IP Ranges - - - - - ## - - - - - - - - - - - ## Allow IP rangges to be ignored. + ## Allow IP ranges to be ignored. ## Nb. set IGNORE_IP_RANGES env var to `true` for the intended GH environment. if [ "$IGNORE_IP_RANGES" = "true" ]; then - IP_RANGES=$'{"ignore":true}' + IP_RANGES =$'{"ignore":true}' + IPS_FORMATTED="" fi - IP_RANGES_BASE64=$(echo -n "$IP_RANGES" | base64 -w 0) + IP_RANGES_BASE64 =$(echo -n "$IP_RANGES" | base64 -w 0) + IPS_FORMATTED_BASE64=$(echo -n "$IPS_FORMATTED" | base64 -w 0) export IP_RANGES_BASE64 + export IPS_FORMATTED_BASE64 ## - - - - - - - - - - - diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0ae3ccac9..a84b48f54 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -33,6 +33,7 @@ jobs: environment: development registry: ${{ needs.image.outputs.registry }} ip_ranges: ${{ needs.get_ip_ranges.outputs.ip_ranges }} + ips_formatted: ${{ needs.get_ip_ranges.outputs.ips_formatted }} secrets: inherit deploy_staging: @@ -44,6 +45,7 @@ jobs: environment: staging registry: ${{ needs.image.outputs.registry }} ip_ranges: ${{ needs.get_ip_ranges.outputs.ip_ranges }} + ips_formatted: ${{ needs.get_ip_ranges.outputs.ips_formatted }} secrets: inherit deploy_demo: @@ -55,6 +57,7 @@ jobs: environment: demo registry: ${{ needs.image.outputs.registry }} ip_ranges: ${{ needs.get_ip_ranges.outputs.ip_ranges }} + ips_formatted: ${{ needs.get_ip_ranges.outputs.ips_formatted }} secrets: inherit deploy_production: @@ -66,4 +69,5 @@ jobs: environment: production registry: ${{ needs.image.outputs.registry }} ip_ranges: ${{ needs.get_ip_ranges.outputs.ip_ranges }} + ips_formatted: ${{ needs.get_ip_ranges.outputs.ips_formatted }} secrets: inherit diff --git a/.github/workflows/ip-ranges-configure.yml b/.github/workflows/ip-ranges-configure.yml index a6590c8a9..194d05a56 100644 --- a/.github/workflows/ip-ranges-configure.yml +++ b/.github/workflows/ip-ranges-configure.yml @@ -6,6 +6,9 @@ on: ip_ranges: description: "IPs Ranges" value: ${{ jobs.get_ip_ranges.outputs.ip_ranges }} + ips_formatted: + description: "IPs Ranges (formatted)" + value: ${{ jobs.get_ip_ranges.outputs.ips_formatted }} jobs: get_ip_ranges: @@ -13,6 +16,7 @@ jobs: runs-on: ubuntu-latest outputs: ip_ranges: ${{ steps.get-ips.outputs.ip_ranges }} + ips_formatted: ${{ steps.get-ips.outputs.ips_formatted }} steps: - name: "Checkout" uses: actions/checkout@v4 @@ -30,3 +34,13 @@ jobs: cmd: | IP_RANGES=$(yq 'explode(.) | {"deprecating": .deprecating_access_to_moj_intranet | flatten, "allow": .allow_access_to_moj_intranet | flatten }' -o json -I=0 moj-cidr-addresses.yml) echo "ip_ranges=$IP_RANGES" >> $GITHUB_OUTPUT + + # Transform into 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 + ALLOW_VALUE=1 + DEPRI_VALUE=2 + + 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) + + echo "ips_formatted=$ALLOW_FORMATTED"$'\n'"$DEPRI_FORMATTED" >> $GITHUB_OUTPUT diff --git a/deploy/config/local/nginx/geo.conf b/deploy/config/local/nginx/geo.conf new file mode 100644 index 000000000..5ffeef8ae --- /dev/null +++ b/deploy/config/local/nginx/geo.conf @@ -0,0 +1,7 @@ +# Source IPs where 'X-Forwarded-For' is to be truested. +proxy 172.17.0.0/16; +proxy 172.25.0.0/16; + +# Maybe use init script to substitude env vars + +192.168.65.1 1; \ No newline at end of file diff --git a/deploy/development/deployment.tpl.yml b/deploy/development/deployment.tpl.yml index ac4de15db..b45c7722f 100644 --- a/deploy/development/deployment.tpl.yml +++ b/deploy/development/deployment.tpl.yml @@ -37,6 +37,12 @@ spec: mountPath: /var/www/html/public/app/uploads - name: php-socket mountPath: /sock + env: + - name: IPS_FORMATTED + valueFrom: + secretKeyRef: + name: ${KUBE_NAMESPACE}-base64-secrets + key: IPS_FORMATTED - name: cron image: ${ECR_URL}:${IMAGE_TAG_CRON} diff --git a/deploy/development/secret.tpl.yml b/deploy/development/secret.tpl.yml index 3d5cffa72..9b3f1c07e 100644 --- a/deploy/development/secret.tpl.yml +++ b/deploy/development/secret.tpl.yml @@ -28,6 +28,7 @@ type: Opaque data: AWS_CLOUDFRONT_PRIVATE_KEY: "${AWS_CLOUDFRONT_PRIVATE_KEY_BASE64}" IP_RANGES: "${IP_RANGES_BASE64}" + IPS_FORMATTED: "${IPS_FORMATTED_BASE64}" --- apiVersion: v1 kind: Secret From a37f3a676a00ee9d4cdfa423020ae66c19a9f01d Mon Sep 17 00:00:00 2001 From: EarthlingDavey <15802017+EarthlingDavey@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:25:45 +0100 Subject: [PATCH 9/9] Remove previous IP_RANGES, use nginx init script to populate conf file. --- .env.example | 13 ++-- .github/workflows/deploy.yml | 4 -- .github/workflows/ip-ranges-configure.yml | 9 +-- Dockerfile | 3 +- deploy/config/init/nginx-geo.sh | 3 + deploy/config/local/nginx/geo.conf | 7 --- deploy/config/local/nginx/server.conf | 12 +--- deploy/demo/config.yml | 7 --- deploy/development/config.yml | 3 - deploy/development/secret.tpl.yml | 1 - deploy/staging/config.yml | 7 --- docker-compose.yml | 3 + .../app/mu-plugins/moj-auth/traits/utils.php | 62 ------------------- 13 files changed, 18 insertions(+), 116 deletions(-) create mode 100644 deploy/config/init/nginx-geo.sh delete mode 100644 deploy/config/local/nginx/geo.conf diff --git a/.env.example b/.env.example index a613ca782..8cda8baef 100644 --- a/.env.example +++ b/.env.example @@ -62,11 +62,14 @@ OAUTH_CLIENT_ID= OAUTH_TENANT_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 -192.168.0.0/16 -" +# 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 +IPS_FORMATTED=" +proxy 172.17.0.0/16; +proxy 172.25.0.0/16; + +192.168.65.1 1; +192.168.65.3 2;" # The following 4 environment variables can be generated with `make key-gen`. JWT_SECRET="generated-key" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b6124900..8d93c0c8c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,6 @@ jobs: # AWS_CLOUDFRONT_PUBLIC_KEY_EXPIRING: "${{ secrets.AWS_CLOUDFRONT_PUBLIC_KEY_B }}" BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} - IP_RANGES: ${{ inputs.ip_ranges }} IPS_FORMATTED: ${{ inputs.ips_formatted }} IGNORE_IP_RANGES: ${{ vars.IGNORE_IP_RANGES }} run: | @@ -88,14 +87,11 @@ jobs: ## Nb. set IGNORE_IP_RANGES env var to `true` for the intended GH environment. if [ "$IGNORE_IP_RANGES" = "true" ]; then - IP_RANGES =$'{"ignore":true}' IPS_FORMATTED="" fi - IP_RANGES_BASE64 =$(echo -n "$IP_RANGES" | base64 -w 0) IPS_FORMATTED_BASE64=$(echo -n "$IPS_FORMATTED" | base64 -w 0) - export IP_RANGES_BASE64 export IPS_FORMATTED_BASE64 diff --git a/.github/workflows/ip-ranges-configure.yml b/.github/workflows/ip-ranges-configure.yml index 194d05a56..56d1e7293 100644 --- a/.github/workflows/ip-ranges-configure.yml +++ b/.github/workflows/ip-ranges-configure.yml @@ -3,9 +3,6 @@ name: "Get IP ranges" on: workflow_call: outputs: - ip_ranges: - description: "IPs Ranges" - value: ${{ jobs.get_ip_ranges.outputs.ip_ranges }} ips_formatted: description: "IPs Ranges (formatted)" value: ${{ jobs.get_ip_ranges.outputs.ips_formatted }} @@ -15,7 +12,6 @@ jobs: name: "Build" runs-on: ubuntu-latest outputs: - ip_ranges: ${{ steps.get-ips.outputs.ip_ranges }} ips_formatted: ${{ steps.get-ips.outputs.ips_formatted }} steps: - name: "Checkout" @@ -32,10 +28,7 @@ jobs: uses: mikefarah/yq@master with: cmd: | - IP_RANGES=$(yq 'explode(.) | {"deprecating": .deprecating_access_to_moj_intranet | flatten, "allow": .allow_access_to_moj_intranet | flatten }' -o json -I=0 moj-cidr-addresses.yml) - echo "ip_ranges=$IP_RANGES" >> $GITHUB_OUTPUT - - # Transform into nginx geo format. 1 IP range per line, each range is followed by it's value. + # Transform IPs into 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 ALLOW_VALUE=1 DEPRI_VALUE=2 diff --git a/Dockerfile b/Dockerfile index 647149da2..555b71d6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,8 +75,7 @@ RUN mkdir /var/run/nginx-cache && \ # contains gzip and module include COPY --chown=nginx:nginx deploy/config/nginx.conf /etc/nginx/nginx.conf -# Disabled on 22 July 2024 - no deployment init files for Nginx -#COPY deploy/config/init/nginx-* /docker-entrypoint.d/ +COPY deploy/config/init/nginx-* /docker-entrypoint.d/ RUN chmod +x /docker-entrypoint.d/*; \ echo "# This file is configured at runtime." > /etc/nginx/real_ip.conf diff --git a/deploy/config/init/nginx-geo.sh b/deploy/config/init/nginx-geo.sh new file mode 100644 index 000000000..dd2408e83 --- /dev/null +++ b/deploy/config/init/nginx-geo.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "$IPS_FORMATTED" >> /etc/nginx/geo.conf diff --git a/deploy/config/local/nginx/geo.conf b/deploy/config/local/nginx/geo.conf deleted file mode 100644 index 5ffeef8ae..000000000 --- a/deploy/config/local/nginx/geo.conf +++ /dev/null @@ -1,7 +0,0 @@ -# Source IPs where 'X-Forwarded-For' is to be truested. -proxy 172.17.0.0/16; -proxy 172.25.0.0/16; - -# Maybe use init script to substitude env vars - -192.168.65.1 1; \ No newline at end of file diff --git a/deploy/config/local/nginx/server.conf b/deploy/config/local/nginx/server.conf index ecb4a0fa0..9ecec1dbf 100644 --- a/deploy/config/local/nginx/server.conf +++ b/deploy/config/local/nginx/server.conf @@ -28,16 +28,8 @@ fastcgi_ignore_headers Cache-Control Expires Set-Cookie; geo $geo { - # Trusted IPs where 'X-Forwarded-For' is used. - proxy 172.17.0.0/16; - proxy 172.25.0.0/16; - - # TODO - # read from include a .conf file - # Maybe use init script to substitude env vars - default 0; - - 192.168.65.1 1; + default 0; + include /etc/nginx/geo.conf; } server { diff --git a/deploy/demo/config.yml b/deploy/demo/config.yml index bc22be3c5..db119a5db 100644 --- a/deploy/demo/config.yml +++ b/deploy/demo/config.yml @@ -12,10 +12,3 @@ data: # See Azure Setup for more information on how to get these values. OAUTH_CLIENT_ID: "8d928bcf-c45e-41ec-aedf-059828aa6e3f" OAUTH_TENANT_ID: "c6874728-71e6-41fe-a9e1-2e8c36776ad8" - # IP addresses, with optional CIDR notation. Separated by newlines and using # for comments. - ALLOWED_IPS: | - # Global Protect - 18.169.147.172 - 35.176.93.186 - 18.130.148.126 - 35.176.148.126 diff --git a/deploy/development/config.yml b/deploy/development/config.yml index 64acc99ce..7d73d5858 100644 --- a/deploy/development/config.yml +++ b/deploy/development/config.yml @@ -11,6 +11,3 @@ data: # See Azure Setup for more information on how to get these values. OAUTH_CLIENT_ID: "1dac3cbf-91d2-4c0e-9c80-0bf3f8fabd75" OAUTH_TENANT_ID: "c6874728-71e6-41fe-a9e1-2e8c36776ad8" - # IP addresses, with optional CIDR notation. Separated by newlines and using # for comments. - # TODO: This block will be removed as part of CDPT-887. - ALLOWED_IPS: "" diff --git a/deploy/development/secret.tpl.yml b/deploy/development/secret.tpl.yml index 9b3f1c07e..7c82586ec 100644 --- a/deploy/development/secret.tpl.yml +++ b/deploy/development/secret.tpl.yml @@ -27,7 +27,6 @@ metadata: type: Opaque data: AWS_CLOUDFRONT_PRIVATE_KEY: "${AWS_CLOUDFRONT_PRIVATE_KEY_BASE64}" - IP_RANGES: "${IP_RANGES_BASE64}" IPS_FORMATTED: "${IPS_FORMATTED_BASE64}" --- apiVersion: v1 diff --git a/deploy/staging/config.yml b/deploy/staging/config.yml index bf06c8976..6b7a56c2a 100644 --- a/deploy/staging/config.yml +++ b/deploy/staging/config.yml @@ -11,10 +11,3 @@ data: # See Azure Setup in the README for more information on how to get these values. OAUTH_CLIENT_ID: "ffb808d2-312b-4ffe-a6e5-d6eacfd9f06f" OAUTH_TENANT_ID: "c6874728-71e6-41fe-a9e1-2e8c36776ad8" - # IP addresses, with optional CIDR notation. Separated by newlines and using # for comments. - ALLOWED_IPS: | - # Global Protect - 18.169.147.172 - 35.176.93.186 - 18.130.148.126 - 35.176.148.126 diff --git a/docker-compose.yml b/docker-compose.yml index 19b213de3..c5aa3cfa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,17 +37,20 @@ services: - php-socket:/sock ### Deploy scripts - ./deploy/config/local/nginx/php-fpm.conf:/etc/nginx/php-fpm.conf + - ./deploy/config/local/nginx/php-fpm-auth.conf:/etc/nginx/php-fpm-auth.conf - ./deploy/config/local/nginx/server.conf:/etc/nginx/conf.d/default.conf - ./deploy/config/nginx.conf:/etc/nginx/nginx.conf ### Assets - ./public/app/plugins:/var/www/html/public/app/plugins - ./public/app/themes/clarity/screenshot.png:/var/www/html/public/app/themes/clarity/screenshot.png + - ./public/app/themes/clarity/error-pages:/var/www/html/public/app/themes/clarity/error-pages - ./public/wp:/var/www/html/public/wp - ./public/index.php:/var/www/html/public/index.php environment: VIRTUAL_HOST: ${SERVER_NAME} VIRTUAL_PORT: 8080 SERVER_NAME: ${SERVER_NAME} + IPS_FORMATTED: ${IPS_FORMATTED} ports: - "8080:8080" depends_on: diff --git a/public/app/mu-plugins/moj-auth/traits/utils.php b/public/app/mu-plugins/moj-auth/traits/utils.php index ff56604bf..53c850166 100644 --- a/public/app/mu-plugins/moj-auth/traits/utils.php +++ b/public/app/mu-plugins/moj-auth/traits/utils.php @@ -28,68 +28,6 @@ public function log(string $message, $data = null): void 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 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 - { - $this->log('ipAddressIsAllowed()'); - - return false; - - 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); - } - /** * Hash a value using SHA256 and a salt. *