Skip to content

Only clear the login nonce once it has expired - #980

Merged
masteradhoc merged 1 commit into
masterfrom
fix/login-nonce-preserve-on-mismatch
Sep 15, 2026
Merged

masteradhoc merged 1 commit into
masterfrom
fix/login-nonce-preserve-on-mismatch

Conversation

@georgestephanis

@georgestephanis georgestephanis commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the destructive half of verify_login_nonce(): an unrecognized nonce no longer discards the pending one.

The behavior

verify_login_nonce() currently deletes the stored login nonce on any verification failure, including a simple mismatch:

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

// Require a fresh nonce if verification fails.
self::delete_login_nonce( $user_id );

Its one caller, validate_login_form_2fa(), runs that check before process_provider() — which is where rate limiting lives. So the destructive path is the only one in the flow with nothing throttling it, and it needs no authentication to reach. Anyone can end another user's in-progress 2FA login by submitting a garbage nonce with a guessed user ID. The victim isn't locked out, but their half-finished login dies and they start over at the password prompt.

Why the delete was there

Not as a considered defense. The original implementation collapsed both failure cases into one branch:

if ( $nonce != $login_nonce['key'] || time() > $login_nonce['expiration'] ) {
    self::delete_login_nonce( $user_id );
    return false;
}

Deleting an expired nonce is reasonable garbage collection. Deleting on mismatch came along for the ride. The 2021 hashing refactor (f3232d9) split the condition apart and added the comment "Require a fresh nonce if verification fails" — describing the existing behavior rather than arguing for it.

Why dropping it costs nothing

It isn't brute-forceable. create_login_nonce() uses bin2hex( random_bytes( 32 ) ) — 256 bits, inside a ten minute window.

The nonce alone isn't sufficient. Even a correct one only reaches the second factor prompt; process_provider() still demands the real TOTP code, email code, or backup code. A guessed nonce puts an attacker exactly where knowing the password would.

Rotation already covers it. On a failed second factor, validate_login_form_2fa() calls create_login_nonce() again and overwrites the stored value. The "one attempt per prompt" property delete-on-mismatch would supposedly provide is already provided by rotation.

There's no replay surface to protect. The stored hash binds user_id and expiration, so a nonce can't be reused for another user or have its window extended.

The change

if ( $hashes_match ) {
    if ( time() < $login_nonce['expiration'] ) {
        return true;
    }

    // Expired nonces can never succeed again; clear them.
    self::delete_login_nonce( $user_id );

    return false;
}

// A value we never issued. Leave the pending nonce alone.
return false;

verify_login_nonce() has exactly one caller and it treats every non-true return identically, so nothing downstream shifts.

Tests

test_invalid_nonce_deletes_valid_nonce locks in the behavior being removed, so it inverts:

  • Renamed to test_invalid_nonce_preserves_valid_nonce; the second assertion flips to assertTrue, plus a usermeta check that the pending nonce survived.
  • New test_expired_nonce_is_deleted covers the retained delete path.
  • test_can_verify_login_nonce drops a now-stale "Must create a new one since incorrect nonces deletes them" workaround.

Stacked on #973

This branches off #973 and reuses its log_login_nonce_failure() call sites, so the reason codes stay accurate across the split. Review and merge #973 first — the diff shown here includes its commits.


🤖 Generated with Claude Code

Open WordPress Playground Preview

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: georgestephanis <georgestephanis@git.wordpress.org>
Co-authored-by: masteradhoc <masteradhoc@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

As implemented, an expired stored nonce may remain in usermeta (and continue triggering mismatch logging) when presented with a wrong key; expiry cleanup should not depend on providing the correct nonce value.

Pull request overview

This PR adjusts Two_Factor_Core’s login nonce verification behavior to avoid deleting a pending (valid) login nonce on nonce mismatch, reducing the ability for unauthenticated requests to disrupt in-progress 2FA logins. It also adds/updates tests around nonce failure reasons, logging hooks, and the preserved-on-mismatch behavior (stacked with the diagnostics from #973).

Changes:

  • Update verify_login_nonce() to delete stored nonces only in the expired path, and add reasoned diagnostics via two_factor_login_nonce_failed + two_factor_log_login_nonce_failures.
  • Add/adjust PHPUnit coverage for mismatch preservation, expired nonce deletion, and per-reason logging defaults.
  • Add a test-suite-level filter to suppress nonce-failure error log output during tests.
File summaries
File Description
class-two-factor-core.php Refines login nonce verification semantics and adds structured logging/action hooks for failure diagnostics.
tests/class-two-factor-core.php Updates and extends nonce verification tests (mismatch preservation, expiry cleanup, and diagnostics/logging behavior).
Review details

Suppressed comments (1)

class-two-factor-core.php:1383

  • Expired login nonces are only deleted when the correct key is presented (the hashes_match branch). If the nonce has expired but the request presents an incorrect/garbage key (the common case for abandoned logins and any probing), the code logs mismatch and leaves an already-expired nonce in usermeta indefinitely (until the next successful password phase overwrites it). That also means mismatch logging can continue long after expiry. Consider checking expiration up front and deleting/logging expired regardless of whether the key matches, then doing the hash comparison only for non-expired nonces.

		$unverified_nonce = array(
			'user_id'    => $user_id,
			'expiration' => $login_nonce['expiration'],
			'key'        => $nonce,
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@georgestephanis
georgestephanis force-pushed the fix/login-nonce-preserve-on-mismatch branch from 841b444 to b57feb7 Compare September 14, 2026 17:51
@georgestephanis

Copy link
Copy Markdown
Collaborator Author

Good catch — adopted.

Gating the expiry cleanup on hashes_match was wrong, and the case you describe is the common one: someone starts a login, abandons it, and the stale nonce then sits in usermeta until their next password success overwrites it. Worse, every probe against that user logged mismatch instead of expired, so the reason code was actively misleading about what was in the database.

Checking expiry first is also just simpler, and it means the hash is never computed for a nonce that cannot succeed:

if ( time() >= $login_nonce['expiration'] ) {
    self::log_login_nonce_failure( $user_id, 'expired' );
    self::delete_login_nonce( $user_id );

    return false;
}

$unverified_hash = self::hash_login_nonce( $unverified_nonce );

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

self::log_login_nonce_failure( $user_id, 'mismatch' );

return false;

Worth being explicit that this does not weaken what the PR is for. Deleting an expired nonce is not destructive — it was already dead, and no request could have used it. The behavior being removed is deleting a live nonce because someone presented the wrong value against it, which is what let an unauthenticated request end another user's in-progress login. Expiry cleanup and mismatch preservation are independent, and checking expiry first is what keeps them that way.

Coverage added for exactly the case you named: a new 'past expiration, any value' data set asserting the reason is expired rather than mismatch when the key is wrong, and test_expired_nonce_is_deleted now runs a second pass with a garbage key to confirm cleanup does not depend on the caller knowing it.

@georgestephanis
georgestephanis force-pushed the fix/login-nonce-preserve-on-mismatch branch from b57feb7 to 19e0fb1 Compare September 14, 2026 18:02
An unrecognized nonce no longer discards the pending one. Preserve it and
let it run out its own expiration instead.

Delete-on-mismatch dates back to the original implementation, where the
mismatch and expiry cases shared one branch. It offers no brute-force
resistance -- the key is 256 bits from random_bytes() inside a ten minute
window, it is useless without the second factor, and
validate_login_form_2fa() already rotates it whenever a factor fails --
while allowing any unauthenticated request to end another user's
in-progress login.

Expiry is checked before the key, so an expired nonce is cleared whatever
was presented alongside it. It can never succeed again, so deleting it is
not destructive, and an abandoned login no longer leaves dead weight in
usermeta until the next password success overwrites it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Address log/hook flooding and clarify nonce failure-reason semantics.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

class-two-factor-core.php:1387

  • This early expiry check reports expired for every submitted value once the stored nonce has expired, including a wrong key. However, log_login_nonce_failure() documents expired as the correct value being past expiration, and consumers of its action/filter can rely on that distinction. Since the new tests intentionally cover an arbitrary value as expired, update that reason contract and related documentation, or preserve the hash check before assigning the reason.
		if ( time() >= $login_nonce['expiration'] ) {
			self::log_login_nonce_failure( $user_id, 'expired' );
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread class-two-factor-core.php
* of random_bytes(), and a failed second factor rotates it in
* validate_login_form_2fa() regardless.
*/
self::log_login_nonce_failure( $user_id, 'mismatch' );
@masteradhoc
masteradhoc self-requested a review September 14, 2026 18:48

@masteradhoc masteradhoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

@masteradhoc
masteradhoc merged commit 954c163 into master Sep 15, 2026
27 checks passed
@masteradhoc
masteradhoc deleted the fix/login-nonce-preserve-on-mismatch branch September 15, 2026 04:18
MrLBRD added a commit to MrLBRD/two-factor that referenced this pull request Sep 21, 2026
67 commits upstream, toujours sans release (dernière : 0.16.0 du 27/03/2026).
Parmi eux, WordPress#980 : un nonce de login invalide ne supprime plus le nonce en
cours, ce qui permettait à une requête non authentifiée d'interrompre la
connexion 2FA d'un autre utilisateur.

Upstream relève les prérequis à WordPress 7.0 et PHP 7.4.

- readme.txt : seul conflit, dû au passage CRLF -> LF en amont. Version
  upstream reprise, plus la ligne two_factor_fallback_provider_for_user de WordPress#927.
- pnpm-lock.yaml : régénéré par pnpm import.
- pnpm-workspace.yaml : scripts d'installation de @parcel/watcher et
  unrs-resolver refusés (binaires précompilés en dépendances optionnelles) ;
  sans décision explicite, pnpm 11 fait échouer l'installation.

Validé en local (wp-env, WordPress 7.1.1, PHP 7.4.33) : PHPUnit 218 tests,
711 assertions OK, dont les 10 du groupe enforcement ; PHPCS 23/23 ;
PHPStan niveau 5, 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MrLBRD added a commit to MrLBRD/two-factor that referenced this pull request Sep 28, 2026
Intègre les 175 commits accumulés depuis c8c8823 (20/09), dont la release
stable 0.17.0 publiée le 28/09 — la première depuis la 0.16.0 du 27/03.

Correctif de sécurité déterminant pour ce fork : WordPress#989 empêche un mot de passe
classique de contourner la 2FA sur REST et XML-RPC. Notre code testait
`did_action( 'application_password_did_authenticate' )`, vrai dès que
n'importe quel utilisateur s'était authentifié par application password
pendant la requête ; upstream scope désormais la vérification par ID
(`$app_password_auth_user_ids`). Les autres correctifs de sécurité de la
release (WordPress#877, WordPress#973, WordPress#980) étaient déjà présents ici.

Trois PR qui composaient le fork sont maintenant mergées upstream : WordPress#927
(fail-safe), WordPress#917 (rate-limit) et WordPress#882. Notre implémentation locale de WordPress#927
et celle d'upstream sont identiques, d'où l'absence de conflit sur
class-two-factor-core.php. Le fork ne repose plus que sur WordPress#845 (enforcement
par rôle, approuvée le 24/09 mais non mergée) et WordPress#958.

Résolution des trois conflits, upstream retenu dans les trois cas :
- tests/class-two-factor-core.php : nos noms de tests contredisaient leurs
  propres assertions (« invalidates » pour un token vérifié « preserved ») ;
  upstream les renomme et ajoute test_clear_login_rate_limit.
- tests/providers/class-two-factor-email.php : ajout de trois tests de
  lockout côté upstream, aucun test perdu ici.
- readme.txt : notre seul apport propre, la documentation du filtre
  two_factor_fallback_provider_for_user, est déjà dans leur version.

Validation (wp-env, WordPress 7.1 / PHP 7.4) : PHPUnit 308 tests et 932
assertions OK sur les suites single et multisite, dont 10/10 pour le groupe
enforcement ; PHPCS 29/29 ; PHPStan 0 erreur ; build OK. Le merge apporte
une suite multisite et le support WP-CLI, d'où le passage de 218 à 308
tests et la nouvelle dépendance php-stubs/wp-cli-stubs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants