diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/Config/config.php b/freescout.wordpress.net/Modules/WPOrgSSO/Config/config.php
index 9e93cf2897..51f467e762 100644
--- a/freescout.wordpress.net/Modules/WPOrgSSO/Config/config.php
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/Config/config.php
@@ -27,4 +27,7 @@
// Break-glass: lets administrators log in with their FreeScout password. Every such login is logged.
'password_login' => filter_var( env( 'WPORG_SSO_PASSWORD_LOGIN', false ), FILTER_VALIDATE_BOOLEAN ),
+
+ // Keeps administrators out unless nginx marks the request as proxied, with the WPORG_PROXIED_REQUEST FastCGI param.
+ 'require_proxy' => filter_var( env( 'WPORG_SSO_REQUIRE_PROXY', false ), FILTER_VALIDATE_BOOLEAN ),
);
diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/Http/Middleware/RequireWordPressOrgLogin.php b/freescout.wordpress.net/Modules/WPOrgSSO/Http/Middleware/RequireWordPressOrgLogin.php
index 8603cd4aa2..ef3ece11af 100644
--- a/freescout.wordpress.net/Modules/WPOrgSSO/Http/Middleware/RequireWordPressOrgLogin.php
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/Http/Middleware/RequireWordPressOrgLogin.php
@@ -23,6 +23,7 @@
* Runs on every web request, after FreeScout's own middleware.
*
* - Logs out anyone who logs in some other way once WordPress.org is enforced, however they got in.
+ * - Logs out administrators outside the proxy, when that's required.
* - Replaces the login form, and closes password logins, resets, and invite setups.
* - Fills in new users from their WordPress.org account, and connects the account.
* - Keeps what comes from WordPress.org, and passwords, out of the profile form.
@@ -109,6 +110,18 @@ public function handle( Request $request, Closure $next ): Response {
$user = $request->user();
$action = $request->route() ? (string) $request->route()->getActionName() : '';
+ // Whatever way they logged in, so a stolen session doesn't work from outside the proxy either.
+ if (
+ $user instanceof User &&
+ $user->isAdmin() &&
+ WPOrgSSOServiceProvider::proxy_required() &&
+ ! WPOrgSSOServiceProvider::proxied( $request )
+ ) {
+ \Log::warning( '[WPOrgSSO] Logged out ' . $user->email . ', an administrator, from outside the proxy at ' . $request->ip() . '.' );
+
+ return self::log_out( $request, __( 'Administrators can only use the helpdesk through the proxy.' ) );
+ }
+
// Logins stay as they are until the identity provider is configured, so administrators can be connected first.
if ( WPOrgSSOServiceProvider::enforced() ) {
$response = self::enforce_login( $request, $user, $action );
@@ -231,15 +244,7 @@ private static function enforce_login( Request $request, ?User $user, string $ac
$enforced_since = WPOrgSSOServiceProvider::enforced_since();
if ( $user instanceof User && ! self::may_stay_logged_in( $user, $request, $enforced_since ) ) {
- \Auth::logout();
- $request->session()->invalidate();
-
- // Like core's auth middleware: polls get a 401, pages come back after the login.
- if ( $request->expectsJson() ) {
- return response()->json( array( 'message' => 'Unauthenticated.' ), 401 );
- }
-
- return redirect()->guest( route( 'login' ) )->with( WPOrgSSOServiceProvider::SESSION_ERROR, __( 'Please log in with your WordPress.org account.' ) );
+ return self::log_out( $request, __( 'Please log in with your WordPress.org account.' ) );
}
// Logged-in users are sent on by core's guest middleware.
@@ -282,6 +287,25 @@ private static function enforce_login( Request $request, ?User $user, string $ac
return null;
}
+ /**
+ * Ends the session, and sends the user to the login page.
+ *
+ * @param Request $request Request.
+ * @param string $error Error the login page shows.
+ * @return Response
+ */
+ private static function log_out( Request $request, string $error ): Response {
+ \Auth::logout();
+ $request->session()->invalidate();
+
+ // Like core's auth middleware: polls get a 401, pages come back after the login.
+ if ( $request->expectsJson() ) {
+ return response()->json( array( 'message' => 'Unauthenticated.' ), 401 );
+ }
+
+ return redirect()->guest( route( 'login' ) )->with( WPOrgSSOServiceProvider::SESSION_ERROR, $error );
+ }
+
/**
* Whether a logged-in user may stay logged in.
*
diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/Providers/WPOrgSSOServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgSSO/Providers/WPOrgSSOServiceProvider.php
index 4b263c203e..71a222622a 100644
--- a/freescout.wordpress.net/Modules/WPOrgSSO/Providers/WPOrgSSOServiceProvider.php
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/Providers/WPOrgSSOServiceProvider.php
@@ -101,6 +101,13 @@ final class WPOrgSSOServiceProvider extends ServiceProvider {
*/
public const OPTION_ENFORCED_SINCE = 'wporgsso.enforced_since';
+ /**
+ * Server variable nginx sets to 1 for requests from the proxy's IP addresses, and to 0 for all others.
+ *
+ * @var string
+ */
+ public const SERVER_PROXIED_REQUEST = 'WPORG_PROXIED_REQUEST';
+
/**
* Registers the module's dependencies.
*
@@ -145,6 +152,32 @@ public static function password_login_enabled(): bool {
return (bool) config( self::ALIAS . '.password_login' );
}
+ /**
+ * Whether administrators may only use the helpdesk through the proxy.
+ *
+ * @return bool
+ */
+ public static function proxy_required(): bool {
+ return (bool) config( self::ALIAS . '.require_proxy' );
+ }
+
+ /**
+ * Whether a request came through the proxy, as nginx tells.
+ *
+ * @param Request $request Request.
+ * @return bool
+ */
+ public static function proxied( Request $request ): bool {
+ $proxied = $request->server( self::SERVER_PROXIED_REQUEST );
+
+ // Without it, nobody counts as proxied, so administrators stay out until nginx passes it.
+ if ( null === $proxied && \Cache::add( self::ALIAS . '.proxy_misconfigured', 1, 60 ) ) {
+ \Log::error( '[WPOrgSSO] WPORG_SSO_REQUIRE_PROXY is on, but nginx doesn\'t pass ' . self::SERVER_PROXIED_REQUEST . '; administrators are logged out.' );
+ }
+
+ return '1' === $proxied;
+ }
+
/**
* Whether WordPress.org is the only way in: it takes both the identity provider and the API.
*
@@ -233,6 +266,17 @@ static function ( bool $available ): bool {
}
);
+ // Core's password form, shown until WordPress.org is enforced, doesn't show why someone was logged out.
+ \Eventy::addAction(
+ 'login_form.before',
+ static function (): void {
+ $error = (string) session( self::SESSION_ERROR, '' );
+ if ( '' !== $error ) {
+ echo '
' . e( $error ) . '
';
+ }
+ }
+ );
+
\Eventy::addAction(
'user.create.before_email',
static function (): void {
diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/README.md b/freescout.wordpress.net/Modules/WPOrgSSO/README.md
index 16cfc07481..ce51c110be 100644
--- a/freescout.wordpress.net/Modules/WPOrgSSO/README.md
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/README.md
@@ -10,11 +10,28 @@ Agents log in with their WordPress.org account, through login.wordpress.org. Apa
- **Existing users:** administrators connect them on their profile, or with `php artisan wporgsso:connect `.
- **Passwords:** password logins (except break-glass), resets, and invites are closed. Users have no FreeScout password: new users get none, and logging in with WordPress.org clears the one they had. Only break-glass passwords stay.
- **Deleting a mailbox:** instead of your password, you type the mailbox name to confirm, exactly as it's written. Administrators with a break-glass password are asked for both.
+- **Administrators and the proxy:** with `WPORG_SSO_REQUIRE_PROXY=true`, administrators are logged out of any request that doesn't come through the proxy, however they logged in, break-glass included. Agents aren't affected. See [the proxy](#the-proxy).
## Break-glass
If login.wordpress.org is down, `WPORG_SSO_PASSWORD_LOGIN=true` lets administrators, and only them, log in with a FreeScout password at `/login?password=1`. `php artisan wporgsso:password ` gives them one, which their logins with WordPress.org keep. Every such login is logged.
+## The proxy
+
+nginx tells FreeScout which requests are proxied, with the `WPORG_PROXIED_REQUEST` FastCGI param: `1` for the proxy's IP addresses, `0` for everyone else. For example:
+
+```nginx
+geo $wporg_proxied_request {
+ default 0;
+ include /path/to/proxy-ips.conf; # One " 1;" line each, IPv4 and IPv6.
+}
+
+# In the PHP location:
+fastcgi_param WPORG_PROXIED_REQUEST $wporg_proxied_request;
+```
+
+Once nginx passes the param, set `WPORG_SSO_REQUIRE_PROXY=true`. If it doesn't, nobody counts as proxied, so administrators are logged out and an error is logged; set `WPORG_SSO_REQUIRE_PROXY=false` to let them back in.
+
## Setup
Needs `WPORG_API_SECRET` and `WPORG_SSO_IDP_CERT`; see [configuration](../../README.md#deployment). Until both are set, logins stay as they are, so users can be connected first.
diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/module.json b/freescout.wordpress.net/Modules/WPOrgSSO/module.json
index abfd3fef63..a5934faa10 100644
--- a/freescout.wordpress.net/Modules/WPOrgSSO/module.json
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/module.json
@@ -3,7 +3,7 @@
"alias": "wporgsso",
"img": "/modules/wporgsso/img/icon.svg",
"description": "Signs agents in with their WordPress.org account, and connects every FreeScout user to one.",
- "version": "1.1.0",
+ "version": "1.2.0",
"detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSSO",
"author": "WordPress.org",
"authorUrl": "https://wordpress.org/",
diff --git a/freescout.wordpress.net/Modules/WPOrgSSO/tests/ProxyTest.php b/freescout.wordpress.net/Modules/WPOrgSSO/tests/ProxyTest.php
new file mode 100644
index 0000000000..8033b8e527
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgSSO/tests/ProxyTest.php
@@ -0,0 +1,167 @@
+ true ) );
+
+ $this->admin = $this->create_user( User::ROLE_ADMIN );
+ Account::connect( (int) $this->admin->id, 'rita' );
+ }
+
+ /**
+ * Marks the following requests as proxied, or not, like nginx does.
+ *
+ * @param string $proxied The param's value.
+ * @return $this
+ */
+ private function proxied( string $proxied ): self {
+ return $this->withServerVariables( array( WPOrgSSOServiceProvider::SERVER_PROXIED_REQUEST => $proxied ) );
+ }
+
+ /**
+ * A proxied administrator stays logged in.
+ *
+ * @return void
+ */
+ public function test_keeps_proxied_administrators(): void {
+ $this->proxied( '1' )->actingAs( $this->admin )->get( route( 'dashboard' ) )->assertStatus( 200 );
+
+ $this->assertAuthenticatedAs( $this->admin );
+ }
+
+ /**
+ * An administrator outside the proxy is logged out, and told why.
+ *
+ * @return void
+ */
+ public function test_logs_out_administrators_outside_the_proxy(): void {
+ $this->proxied( '0' )->actingAs( $this->admin )->get( route( 'dashboard' ) )->assertRedirect( route( 'login' ) );
+
+ $this->assertGuest();
+ $this->assertSame( 'Administrators can only use the helpdesk through the proxy.', session( WPOrgSSOServiceProvider::SESSION_ERROR ) );
+ }
+
+ /**
+ * Until WordPress.org is enforced, core's password form tells the administrator why, too.
+ *
+ * @return void
+ */
+ public function test_explains_the_logout_on_the_password_form(): void {
+ config( array( 'wporgsso.idp.cert' => '' ) );
+
+ $this->proxied( '0' )->actingAs( $this->admin )->get( route( 'dashboard' ) )->assertRedirect( route( 'login' ) );
+
+ // Core escapes old('email'), which is null on a first visit; PHP 8 deprecates that, and tests turn it into an exception.
+ $form = (string) $this->withSession( array( '_old_input' => array( 'email' => '' ) ) )->get( route( 'login' ) )->getContent();
+
+ $this->assertStringContainsString( 'name="password"', $form );
+ $this->assertStringContainsString( 'Administrators can only use the helpdesk through the proxy.', $form );
+ }
+
+ /**
+ * Logging in through WordPress.org from outside the proxy doesn't get an administrator in either.
+ *
+ * @return void
+ */
+ public function test_logs_out_administrators_after_logging_in_outside_the_proxy(): void {
+ $this->proxied( '0' );
+ $this->complete( $this->post_to_acs( $this->start_login( 'rita' ) ) )->assertRedirect( route( 'dashboard' ) );
+
+ $this->get( route( 'dashboard' ) )->assertRedirect( route( 'login' ) );
+ $this->assertGuest();
+ }
+
+ /**
+ * Polls from outside the proxy get a 401, like other ended sessions.
+ *
+ * @return void
+ */
+ public function test_answers_polls_outside_the_proxy_with_a_401(): void {
+ $this->proxied( '0' )->actingAs( $this->admin )->getJson( route( 'dashboard' ) )->assertStatus( 401 );
+ }
+
+ /**
+ * Without the param, nobody counts as proxied.
+ *
+ * @return void
+ */
+ public function test_logs_out_administrators_without_the_param(): void {
+ $this->actingAs( $this->admin )->get( route( 'dashboard' ) )->assertRedirect( route( 'login' ) );
+
+ $this->assertGuest();
+ }
+
+ /**
+ * A header of the same name doesn't count: only nginx sets the param.
+ *
+ * @return void
+ */
+ public function test_ignores_the_header(): void {
+ $this->proxied( '0' )
+ ->withHeaders( array( 'WPORG-PROXIED-REQUEST' => '1' ) )
+ ->actingAs( $this->admin )
+ ->get( route( 'dashboard' ) )
+ ->assertRedirect( route( 'login' ) );
+
+ $this->assertGuest();
+ }
+
+ /**
+ * Agents don't need the proxy.
+ *
+ * @return void
+ */
+ public function test_agents_need_no_proxy(): void {
+ $agent = $this->create_user( User::ROLE_USER );
+
+ $this->proxied( '0' )->actingAs( $agent )->get( route( 'dashboard' ) )->assertStatus( 200 );
+
+ $this->assertAuthenticatedAs( $agent );
+ }
+
+ /**
+ * Until it's required, administrators don't need the proxy.
+ *
+ * @return void
+ */
+ public function test_does_nothing_until_required(): void {
+ config( array( 'wporgsso.require_proxy' => false ) );
+
+ $this->proxied( '0' )->actingAs( $this->admin )->get( route( 'dashboard' ) )->assertStatus( 200 );
+
+ $this->assertAuthenticatedAs( $this->admin );
+ }
+}
diff --git a/freescout.wordpress.net/README.md b/freescout.wordpress.net/README.md
index adebb2abc2..6378a8e9bc 100644
--- a/freescout.wordpress.net/README.md
+++ b/freescout.wordpress.net/README.md
@@ -57,6 +57,7 @@ Optional:
| `WPORG_SSO_IDP_URL` | `https://login.wordpress.org/wp-login.php?action=idp` | The identity provider's login URL, from the same page. |
| `WPORG_HELPSCOUT_APP_ID`, `WPORG_HELPSCOUT_APP_SECRET` | | A HelpScout app's credentials, for `WPOrgHelpScoutImport`. Only needed while mailboxes move; delete the app after the last import. |
| `WPORG_SSO_PASSWORD_LOGIN` | `false` | Break-glass: `true` lets administrators log in with a FreeScout password at `/login?password=1`. `php artisan wporgsso:password ` gives them one; password reset emails stay closed. Every such login is logged. |
+| `WPORG_SSO_REQUIRE_PROXY` | `false` | `true` logs administrators out of requests that nginx doesn't mark as proxied with the `WPORG_PROXIED_REQUEST` FastCGI param. Set it up first; see [WPOrgSSO](Modules/WPOrgSSO/README.md#the-proxy). |
FreeScout's queue worker must be running (FreeScout's standard cron entry starts it), since `WPOrgWebhooks` sends events, `WPOrgSSO` updates avatars, `WPOrgAkismet` reports to Akismet, and `WPOrgHelpScoutImport` and `WPOrgFreeScoutImport` import from the queue.