Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show a notice on login when the user has no recovery codes recorded, is running low, or has logged in with a recovery code. #358

Closed
wants to merge 11 commits into from
74 changes: 73 additions & 1 deletion common/includes/wporg-sso/wp-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class WP_WPOrg_SSO extends WPOrg_SSO {
// Primarily for logged in users.
'updated-tos' => '/updated-policies',
'enable-2fa' => '/enable-2fa',
'recovery-codes' => '/recovery-codes',
'logout' => '/logout',

// Primarily for logged out users.
Expand All @@ -55,6 +56,13 @@ class WP_WPOrg_SSO extends WPOrg_SSO {
*/
static $matched_route_params = array();

/**
* Holds the last set auth cookie.
*
* @var array
*/
protected $last_auth_cookie = array();

/**
* Constructor: add our action(s)/filter(s)
*/
Expand Down Expand Up @@ -98,12 +106,29 @@ public function __construct() {
// Updated TOS interceptor.
add_filter( 'send_auth_cookies', [ $this, 'maybe_block_auth_cookies' ], 100, 5 );

// See https://core.trac.wordpress.org/ticket/61874
add_action( 'set_auth_cookie', [ $this, 'record_last_auth_cookie' ], 10, 6 );

// Maybe nag about 2FA
add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_enable_2fa' ], 1000, 3 );
add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_recovery_codes' ], 500, 3 );
add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_enable_2fa' ], 1100, 3 );
}
}
}

/**
* Records the last set cookies, because WordPress.
dd32 marked this conversation as resolved.
Show resolved Hide resolved
*
* During the WordPress login process, the authentication cookies are not yet available,
* but we need to know the user token (contained in those cookies) to retrieve their session.
* To work around this, we store the set authentication cookies here for later usage.
*
* @see https://core.trac.wordpress.org/ticket/61874
*/
function record_last_auth_cookie( $auth_cookie, $expire, $expiration, $user_id, $scheme, $token ) {
$this->last_auth_cookie = compact( 'auth_cookie', 'expire', 'expiration', 'user_id', 'scheme', 'token' );
}

/**
* Inherits the 'registration' option from the main network.
*
Expand Down Expand Up @@ -851,6 +876,53 @@ public function maybe_redirect_to_enable_2fa( $redirect, $orig_redirect, $user )
);
}

/**
* Redirects the user to the 2FA Recovery codes nag if needed.
*/
public function maybe_redirect_to_recovery_codes( $redirect, $orig_redirect, $user ) {
if (
// No valid user.
is_wp_error( $user ) ||
// Or we're already going there.
str_contains( $redirect, '/recovery-codes' ) ||
// Or the user doesn't use 2FA
! Two_Factor_Core::is_user_using_two_factor( $user->ID )
) {
// Then we don't need to redirect to the enable 2FA page.
return $redirect;
}

// If the user logged in with a recovery code..
$session_token = wp_get_session_token() ?: ( $this->last_auth_cookie['token'] ?? '' );
$session = WP_Session_Tokens::get_instance( $user->ID )->get( $session_token );
$used_recovery_code = str_contains( $session['two-factor-provider'] ?? '', 'Backup_Codes' );
$codes_available = Two_Factor_Backup_Codes::codes_remaining_for_user( $user );
dd32 marked this conversation as resolved.
Show resolved Hide resolved

if (
// If they didn't use a recovery code,
! $used_recovery_code &&
(
// They have ample codes available..
$codes_available > 3 ||
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$codes_available > 3 ||
$codes_available > 5 ||

This should be 5 to match the 2FA interface.

https://github.com/WordPress/wporg-two-factor/blob/4a154cc6a7e37a5acc28d467cff327cd80712e88/settings/src/components/backup-codes.js#L230

Copy link
Member Author

@dd32 dd32 Aug 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to leave this at 3 despite the settings UI switching to a warning at 5, it makes sense that we'd nag on login later than we'd nag at them just casually browsing the settings UI.

Mind you, having used more than 50% of your codes is probably a sign you use them fairly often..

// or they've already been nagged about only having a few left (and actually have them)
(
$codes_available &&
$codes_available >= (int) get_user_meta( $user->ID, 'last_2fa_recovery_codes_nag', true )
)
)
) {
// No need to nag.
return $redirect;
}

// Redirect to the Recovery Codes nag.
return add_query_arg(
'redirect_to',
urlencode( $redirect ),
home_url( '/recovery-codes' )
);
}

/**
* Whether the given user_id has agreed to the current version of the TOS.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@
$user = wp_get_current_user();
$requires_2fa = user_requires_2fa( $user );
$should_2fa = user_should_2fa( $user ); // If they're on this page, this should be truthful.
$redirect_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ?? '' ), wporg_login_wordpress_url() );
$redirect_to = wporg_login_wordpress_url();
if ( isset( $_REQUEST['redirect_to'] ) ) {
$redirect_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), $redirect_to );
}

// If the user is here in error, redirect off.
if ( ! is_user_logged_in() || Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
wp_safe_redirect( $redirect_to );
exit;
}

/*
* Record the last time we naged the user about 2FA.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
use function WordPressdotorg\Two_Factor\get_edit_account_url;
/**
* The 'Recovery Codes' post-login screen.
*
* This template is used for two primary purposes:
* 1. The user has logged in with a recovery code, we need to push them to verify their 2FA settings.
* 2. The user is running low on recovery codes (or has none!), we need to remind them to generate new ones.
*
* @package wporg-login
*/

$account_settings_url = get_edit_account_url();
$redirect_to = wporg_login_wordpress_url();
$user = wp_get_current_user();
$session = WP_Session_Tokens::get_instance( $user->ID )->get( wp_get_session_token() );
$used_recovery_code = str_contains( $session['two-factor-provider'] ?? '', 'Backup_Codes' );
$codes_available = Two_Factor_Backup_Codes::codes_remaining_for_user( $user );
$can_ignore = ! $used_recovery_code || ( $used_recovery_code && $codes_available > 1 );

if ( isset( $_REQUEST['redirect_to'] ) ) {
$redirect_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), $redirect_to );
}

// If the user is here in error, redirect off.
if ( ! is_user_logged_in() || ! Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
wp_safe_redirect( $redirect_to );
exit;
}

/**
* Record the last time we nagged the user about recovery codes, as we only want to do this once per code-use.
*/
update_user_meta( $user->ID, 'last_2fa_recovery_codes_nag', $codes_available );

get_header();
?>

<h2 class="center"><?php
if ( $used_recovery_code ) {
_e( 'Recovery Code used', 'wporg-login' );
} else {
_e( 'Account Recovery Codes', 'wporg-login' );
}
?></h2>

<p>&nbsp;</p>

<p><?php
if ( $used_recovery_code ) {
_e( "You've logged in with a Recovery Code.<br>These codes are intended to be used when you lose access to your authentication device.<br>Please take a moment to review your account settings and ensure your Two-Factor settings are up-to-date.", 'wporg-login' );
} else {
if ( ! $codes_available ) {
_e( "Warning! You don't have any Recovery Codes left.", 'wporg-login' );
} else {
printf(
_n(
"Warning! You've only got %s Recovery Code left.",
"Warning! You've only got %s Recovery Codes left.",
$codes_available,
'wporg-login'
),
'<code>' . number_format_i18n( $codes_available ) . '</code>'
);
}

// Direct to the backup codes screen.
$account_settings_url = add_query_arg( 'screen', 'backup-codes', $account_settings_url );
}
?></p>

<p>&nbsp;</p>

<p><?php
_e( 'If you run out of Recovery Codes and no longer have access to your Authentication device, you are at risk of being locked out of your WordPress.org account if we are unable to verify account ownership.', 'wporg-login' );
?></p>

<p>&nbsp;</p>

<p><a href="<?php echo esc_url( $account_settings_url ); ?>"><button class="button-primary"><?php _e( 'View my account settings', 'wporg-login' ); ?></button></a></p>

<?php if ( $can_ignore ) { ?>
<p id="nav">
<a href="<?php echo esc_url( $redirect_to ); ?>" style="font-style: italic;"><?php _e( "I'll do this later", 'wporg-login' ); ?></a>
</p>
<?php } ?>

<?php get_footer(); ?>