Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgSSO/Config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.' ) );
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// 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 );
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 '<div class="alert alert-danger">' . e( $error ) . '</div>';
}
}
);

\Eventy::addAction(
'user.create.before_email',
static function (): void {
Expand Down
17 changes: 17 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgSSO/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email> <wporg-username>`.
- **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 <email>` 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 "<address or range> 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.
2 changes: 1 addition & 1 deletion freescout.wordpress.net/Modules/WPOrgSSO/module.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
167 changes: 167 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgSSO/tests/ProxyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php
/**
* Tests for keeping administrators to the proxy.
*
* @package WordPressdotorg\FreeScout\WPOrgSSO
*/

declare( strict_types = 1 );

namespace Modules\WPOrgSSO\Tests;

use App\User;
use Modules\WPOrgSSO\Entities\Account;
use Modules\WPOrgSSO\Providers\WPOrgSSOServiceProvider;

require_once __DIR__ . '/SsoTestCase.php';

/**
* Covers logging administrators out of requests that don't come through the proxy.
*/
final class ProxyTest extends SsoTestCase {

/**
* Administrator connected to the "rita" account.
*
* @var User
*/
private $admin;

/**
* Requires the proxy, and creates a connected administrator.
*
* @return void
*/
protected function setUp(): void {
parent::setUp();

config( array( 'wporgsso.require_proxy' => 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 );
}
}
1 change: 1 addition & 0 deletions freescout.wordpress.net/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email>` 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.

Expand Down
Loading