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

Store the two-factor details in the user session at login time #528

Merged
merged 17 commits into from
Apr 19, 2023
Merged
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
95 changes: 73 additions & 22 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1082,28 +1082,66 @@ public static function is_user_rate_limited( $user ) {
}

/**
* Login form validation.
* Determine if the current user session is logged in with 2FA.
*
* @since 0.9.0
*
* @return int|false The last time the two-factor was validated on success, false if not currently using a 2FA session.
*/
public static function is_current_user_session_two_factor() {
$user_id = get_current_user_id();
$token = wp_get_session_token();
$manager = WP_Session_Tokens::get_instance( $user_id );
$session = $manager->get( $token );

if ( empty( $session['two-factor-login'] ) ) {
return false;
}

return (int) $session['two-factor-login'];
}

/**
* Login form validation handler.
*
* @since 0.1-dev
*/
public static function login_form_validate_2fa() {
$wp_auth_id = ! empty( $_REQUEST['wp-auth-id'] ) ? absint( $_REQUEST['wp-auth-id'] ) : 0;
$nonce = ! empty( $_REQUEST['wp-auth-nonce'] ) ? wp_unslash( $_REQUEST['wp-auth-nonce'] ) : '';
$provider = ! empty( $_REQUEST['provider'] ) ? wp_unslash( $_REQUEST['provider'] ) : false;
$provider = ! empty( $_REQUEST['provider'] ) ? wp_unslash( $_REQUEST['provider'] ) : '';
$redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : '';
$is_post_request = ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) );
$user = get_user_by( 'id', $wp_auth_id );

if ( ! $wp_auth_id || ! $nonce ) {
if ( ! $wp_auth_id || ! $nonce || ! $user ) {
return;
}

$user = get_userdata( $wp_auth_id );
if ( ! $user ) {
return;
}
self::_login_form_validate_2fa( $user, $nonce, $provider, $redirect_to, $is_post_request );
exit;
}

/**
* Login form validation.
*
* This function exists for unit testing, as `exit` prevents testing.
* This function expects the caller exiting after calling.
*
* @since 0.9.0
*
* @param WP_User $user The WP_User instance.
* @param string $nonce The nonce provided.
* @param string $provider The provider to use, if known.
* @param string $redirect_to The redirection location.
* @param bool $is_post_request Whether the incoming request was a POST request or not.
* @return void
*/
public static function _login_form_validate_2fa( $user, $nonce = '', $provider = '', $redirect_to = '', $is_post_request = false ) {
// Validate the request.
if ( true !== self::verify_login_nonce( $user->ID, $nonce ) ) {
wp_safe_redirect( home_url() );
exit;
return;
}

if ( $provider ) {
Expand All @@ -1124,8 +1162,8 @@ public static function login_form_validate_2fa() {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}

self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], '', $provider );
exit;
self::login_html( $user, $login_nonce['key'], $redirect_to, '', $provider );
return;
}

// If the form hasn't been submitted, just display the auth form.
Expand All @@ -1135,8 +1173,8 @@ public static function login_form_validate_2fa() {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}

self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], '', $provider );
exit;
self::login_html( $user, $login_nonce['key'], $redirect_to, '', $provider );
return;
}

// Rate limit two factor authentication attempts.
Expand All @@ -1159,8 +1197,8 @@ public static function login_form_validate_2fa() {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}

self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], esc_html( $error->get_error_message() ), $provider );
exit;
self::login_html( $user, $login_nonce['key'], $redirect_to, esc_html( $error->get_error_message() ), $provider );
return;
}

// Ask the provider to verify the second factor.
Expand All @@ -1177,16 +1215,16 @@ public static function login_form_validate_2fa() {
self::reset_compromised_password( $user );
self::send_password_reset_emails( $user );
self::show_password_reset_error();
exit;
return;
}

$login_nonce = self::create_login_nonce( $user->ID );
if ( ! $login_nonce ) {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}

self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], esc_html__( 'ERROR: Invalid verification code.', 'two-factor' ), $provider );
exit;
self::login_html( $user, $login_nonce['key'], $redirect_to, esc_html__( 'ERROR: Invalid verification code.', 'two-factor' ), $provider );
return;
}

self::delete_login_nonce( $user->ID );
Expand All @@ -1198,15 +1236,29 @@ public static function login_form_validate_2fa() {
$rememberme = true;
}

$session_information_callback = static function( $session, $user_id ) use( $provider, $user ) {
if ( $user->ID === $user_id ) {
$session['two-factor-login'] = time();
$session['two-factor-provider'] = $provider->get_key();
}

return $session;
};

add_filter( 'attach_session_information', $session_information_callback, 10, 2 );

/*
* NOTE: This filter removal is not normally required, this is included for protection against
* a plugin/two factor provider which runs the `authenticate` filter during it's validation.
* Such a plugin would cause self::filter_authenticate_block_cookies() to run and add this filter.
*/
remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX );

wp_set_auth_cookie( $user->ID, $rememberme );

do_action( 'two_factor_user_authenticated', $user );
do_action( 'two_factor_user_authenticated', $user, $provider );

remove_filter( 'attach_session_information', $session_information_callback );

// Must be global because that's how login_header() uses it.
global $interim_login;
Expand All @@ -1231,12 +1283,11 @@ public static function login_form_validate_2fa() {
<?php endif; ?>
</body></html>
<?php
exit;
return;
}
$redirect_to = apply_filters( 'login_redirect', $_REQUEST['redirect_to'], $_REQUEST['redirect_to'], $user );
wp_safe_redirect( $redirect_to );

exit;
$redirect_to = apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user );
wp_safe_redirect( $redirect_to );
}

/**
Expand Down
101 changes: 101 additions & 0 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Test_ClassTwoFactorCore extends WP_UnitTestCase {
*/
public static function wpSetUpBeforeClass() {
set_error_handler( array( 'Test_ClassTwoFactorCore', 'error_handler' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
add_action( 'set_auth_cookie', [ __CLASS__, 'set_auth_cookie' ] );
add_action( 'set_logged_in_cookie', [ __CLASS__, 'set_logged_in_cookie' ] );
}

/**
Expand All @@ -36,6 +38,17 @@ public static function wpSetUpBeforeClass() {
*/
public static function wpTearDownAfterClass() {
restore_error_handler();
remove_action( 'set_auth_cookie', [ __CLASS__, 'set_auth_cookie' ] );
remove_action( 'set_logged_in_cookie', [ __CLASS__, 'set_logged_in_cookie' ] );
}

/**
* Cleanup after each test.
*/
public function tearDown(): void {
parent::tearDown();

unset( $_COOKIE[ AUTH_COOKIE ], $_COOKIE[ LOGGED_IN_COOKIE ] );
}

/**
Expand All @@ -56,6 +69,20 @@ public static function error_handler( $errno, $errstr ) {
return true;
}

/**
* Simulate the auth cookie having being sent.
*/
public static function set_auth_cookie( $auth_cookie ) {
$_COOKIE[ AUTH_COOKIE ] = $auth_cookie;
}

/**
* Simulate the logged_in cookie having being sent.
*/
public static function set_logged_in_cookie( $logged_in_cookie ) {
$_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie;
}

/**
* Get a dummy user object.
*
Expand Down Expand Up @@ -783,4 +810,78 @@ public function test_show_password_reset_error() {

$this->assertStringContainsString( 'check your email for instructions on regaining access', $contents );
}

/**
* Validate that a non-2fa login doesn't set the session two-factor data.
*
* @covers Two_Factor_Core::is_current_user_session_two_factor()
*/
public function test_is_current_user_session_two_factor_without_two_factor() {
$user = $this->get_dummy_user();

// Assert no cookies are set.
$this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE );
$this->assertArrayNotHasKey( LOGGED_IN_COOKIE, $_COOKIE );

// Assert user not logged in is false.
$this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() );

// Set the cookie without going through two-factor, and fill in $_COOKIE.
wp_set_auth_cookie( $user->ID );

$this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] );
$this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] );

// Validate that the session is not flagged as 2FA
$this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() );

$manager = WP_Session_Tokens::get_instance( $user->ID );
$token = wp_get_session_token();
$session = $manager->get( $token );

// Validate the Session data is not set.
$this->assertArrayNotHasKey( 'two-factor-provider', $session );
}

/**
* Validate that a simulated 2fa login sets the session two-factor data.
*
* @covers Two_Factor_Core::is_current_user_session_two_factor()
* @covers Two_Factor_Core::_login_form_validate_2fa()
*/
public function test_is_current_user_session_two_factor_with_two_factor() {
$user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) );

// Assert no cookies are set.
$this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE );
$this->assertArrayNotHasKey( LOGGED_IN_COOKIE, $_COOKIE );

// Assert user not logged in is false.
$this->assertFalse( Two_Factor_Core::is_current_user_session_two_factor() );

// Simulate a 2FA login.
$login_nonce = Two_Factor_Core::create_login_nonce( $user->ID );

$this->assertNotFalse( $login_nonce );

ob_start();
Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true );
ob_end_clean();

$this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] );
$this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] );

// Validate that the session is flagged as 2FA, the return value being int.
$this->assertNotFalse( Two_Factor_Core::is_current_user_session_two_factor() );

$manager = WP_Session_Tokens::get_instance( $user->ID );
$token = wp_get_session_token();
$session = $manager->get( $token );

// Validate that the session provider is as expected.
$this->assertArrayHasKey( 'two-factor-provider', $session );
$this->assertEquals( 'Two_Factor_Dummy', $session['two-factor-provider'] );

}

}