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.