Skip to content
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
28 changes: 22 additions & 6 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1377,23 +1377,39 @@ public static function verify_login_nonce( $user_id, $nonce ) {
return false;
}

/*
* An expired nonce can never succeed again, whatever was presented alongside it,
* so clear it before looking at the key. Deleting it here is not destructive --
* it was already dead -- and it keeps abandoned logins from leaving dead weight
* in usermeta until the user's next password success overwrites it.
*/
if ( time() >= $login_nonce['expiration'] ) {
self::log_login_nonce_failure( $user_id, 'expired' );
self::delete_login_nonce( $user_id );

return false;
}

$unverified_nonce = array(
'user_id' => $user_id,
'expiration' => $login_nonce['expiration'],
'key' => $nonce,
);

$unverified_hash = self::hash_login_nonce( $unverified_nonce );
$hashes_match = $unverified_hash && hash_equals( $login_nonce['key'], $unverified_hash );

if ( $hashes_match && time() < $login_nonce['expiration'] ) {
if ( $unverified_hash && hash_equals( $login_nonce['key'], $unverified_hash ) ) {
return true;
}

self::log_login_nonce_failure( $user_id, $hashes_match ? 'expired' : 'mismatch' );

// Require a fresh nonce if verification fails.
self::delete_login_nonce( $user_id );
/*
* A value we never issued, presented against a nonce that is still live. Leave it
* in place: discarding it would let an unauthenticated request end someone else's
* in-progress login, and it buys no brute-force resistance -- the key is 256 bits
* of random_bytes(), and a failed second factor rotates it in
* validate_login_form_2fa() regardless.
*/
self::log_login_nonce_failure( $user_id, 'mismatch' );

return false;
}
Expand Down
58 changes: 51 additions & 7 deletions tests/class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,6 @@ public function test_can_verify_login_nonce() {
'Invalid nonce is invalid'
);

// Must create a new one since incorrect nonces deletes them.
$nonce = $this->create_expired_login_nonce( $user_id );

$this->assertFalse(
Expand Down Expand Up @@ -802,9 +801,10 @@ public function test_failed_login_nonce_fires_action( $expected_reason, $create_
*/
public function data_login_nonce_failure_reasons() {
return array(
'no pending login' => array( 'no_nonce_stored', false, false, false ),
'wrong value' => array( 'mismatch', true, false, false ),
'past expiration' => array( 'expired', true, true, true ),
'no pending login' => array( 'no_nonce_stored', false, false, false ),
'wrong value' => array( 'mismatch', true, false, false ),
'past expiration' => array( 'expired', true, true, true ),
'past expiration, any value' => array( 'expired', true, true, false ),
);
}

Expand Down Expand Up @@ -897,11 +897,14 @@ public function test_successful_login_nonce_does_not_fire_action() {
}

/**
* Invalid nonce deletes the valid nonce.
* An unrecognized nonce leaves the pending nonce intact.
*
* Discarding it would let an unauthenticated request end another user's in-progress
* login, and it buys no brute-force resistance against a 256-bit key.
*
* @covers Two_Factor_Core::verify_login_nonce()
*/
public function test_invalid_nonce_deletes_valid_nonce() {
public function test_invalid_nonce_preserves_valid_nonce() {
$user_id = 123456;
$nonce = Two_Factor_Core::create_login_nonce( $user_id );

Expand All @@ -910,9 +913,50 @@ public function test_invalid_nonce_deletes_valid_nonce() {
'Invalid nonce is invalid'
);

$this->assertNotEmpty(
get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ),
'The pending nonce survives an unrecognized value'
);

$this->assertTrue(
Two_Factor_Core::verify_login_nonce( $user_id, $nonce['key'] ),
'The correct nonce is still accepted after an invalid one has been attempted'
);
}

/**
* An expired nonce is cleared out of usermeta.
*
* Unlike an unrecognized value, an expired nonce can never succeed again, so there
* is nothing to preserve.
*
* @covers Two_Factor_Core::verify_login_nonce()
*/
public function test_expired_nonce_is_deleted() {
$user_id = 123456;
$nonce = $this->create_expired_login_nonce( $user_id );

$this->assertFalse(
Two_Factor_Core::verify_login_nonce( $user_id, $nonce['key'] ),
'The correct nonce is not accepted after an invalid has been attempted'
'Expired nonce is invalid'
);

$this->assertEmpty(
get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ),
'Expired nonce is removed from usermeta'
);

// Cleanup must not depend on the caller knowing the key.
$this->create_expired_login_nonce( $user_id );

$this->assertFalse(
Two_Factor_Core::verify_login_nonce( $user_id, 'not-a-real-nonce' ),
'Expired nonce is invalid whatever is presented'
);

$this->assertEmpty(
get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ),
'Expired nonce is removed even when the presented key is wrong'
);
}

Expand Down
Loading