Repository navigation
Add diagnostics for failed login nonce verification - #973
Conversation
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Scope note: this is about the login nonce, not the second-factor codeWorth spelling out, since "nonce" and "code" get used interchangeably in discussion and this PR only touches one of them. Completing a second factor requires two independent secrets:
Despite the name it is not a This PR adds logging to the nonce check only. It is provider-agnostic — a TOTP-only user has a login nonce too — but a wrong TOTP code never reaches the code path this PR touches. The two failure pathsflowchart TD
A["Request to validate_2fa"] --> B{"verify_login_nonce()"}
B -->|fails| C["Stored nonce discarded<br/><i>(existing behavior)</i>"]
C --> D["<b>NEW:</b> record reason<br/>+ fire two_factor_login_nonce_failed"]
D --> E["Redirect to home_url()<br/>flow ends, no code examined"]
B -->|passes| F{"provider->validate_authentication()"}
F -->|fails| G["Increment USER_FAILED_LOGIN_ATTEMPTS_KEY<br/>stamp USER_RATE_LIMIT_KEY"]
G --> H["Fire wp_login_failed"]
H --> I["Mint a fresh nonce,<br/>re-render form — user continues"]
F -->|passes| J["Delete nonce, clear counters,<br/>issue auth cookie"]
style D fill:#dbf5dc,stroke:#2da44e,color:#1a1a1a
The two behave quite differently on failure:
That asymmetry is why the nonce side was the gap worth closing. A wrong code is already counted, throttled, and broadcast on The reverse gap — no error log line for provider failures — is real but separate, and arguably already covered by Aside, for anyone adding a click-to-login email linkThe stale Putting the nonce in a URL has three practical problems:
The shape that survives all three: an opaque single-use token in the link, landing on an interstitial with a POST-only confirm button, with nothing consumed until the user actually clicks it. |
masteradhoc
left a comment
There was a problem hiding this comment.
small early feedback about filter docs and a CI failure.
Failed login nonce verification is currently silent: the request is redirected to the home page and nothing is recorded anywhere. An administrator has no way to tell whether a user's report of being bounced back to the login screen was a stale tab, two sessions racing each other, or something that deserves a closer look. #534 covers the user-facing half of that gap; this covers the operator-facing half. verify_login_nonce() now reports every failure and distinguishes three cases: no pending login for that user, a correct-but-expired value, and a value that did not match. Each failure fires a new `two_factor_login_nonce_failed` action, so sites can route these into an audit log, an IDS, or a rate limiter, and writes a line to the PHP error log by default so they are visible with no configuration. The error log write can be turned off with the `two_factor_log_login_nonce_failures` filter. The presented value is never written to the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'expired' and 'mismatch' both require a nonce to already be stored for the user, which only happens after a successful password check, so their volume is bounded by real login activity. 'no_nonce_stored' is not bounded. Any unauthenticated request carrying a guessed user ID reaches it, so logging it by default would hand anyone an unthrottled write to the error log. It is also the least informative of the three, firing for every stale bookmark and resubmitted form. The action still fires for all three, so anything routing these into an audit log or intrusion detection system is unaffected, and sites that want 'no_nonce_stored' in the error log can opt in through the filter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
506f0be to
a766c19
Compare
|
Rebased onto Also narrowed the error log default. Originally every failure was written unconditionally, which is defensible while the nonce deletion is still in place but becomes awkward once #980 removes it: So One thing worth being explicit about: this PR does not close #534. That issue asks for an error message for the person who just got silently bounced to the homepage; this writes to the error log, which is a different audience. It is adjacent, not a fix. |
The stored hash covers the expiration, so moving the expiration into the past in usermeta invalidates the hash: verify_login_nonce() then reports a mismatch, and the expiry branch is never reached. The existing "Expired nonce is invalid" assertion in test_can_verify_login_nonce has been passing for that wrong reason since the nonce was first hashed for storage in f3232d9. It only surfaced now because the new tests assert the failure reason rather than just the return value. Add a helper that rehashes the nonce for the new expiration, and use it everywhere a test needs an expired one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Green now, and the first run turned up something worth calling out for review: these tests found a latent bug in an existing test.
Its The branch is reachable in production — create a nonce, wait past ten minutes, submit the correct key — just not by mutating usermeta. So there is a new |
masteradhoc
left a comment
There was a problem hiding this comment.
apart from my comments - LGTM!
Every other public hook is listed there already. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The change is additive and well-tested, and the only feedback is minor test isolation hardening to avoid hook/filter leakage on failed assertions.
Pull request overview
This PR adds operator-facing diagnostics for failed 2FA login nonce verification in Two_Factor_Core, making nonce failures observable via a new action hook and (optionally) PHP error-log entries to support auditing and troubleshooting without changing authentication behavior.
Changes:
- Emit a new
two_factor_login_nonce_failedaction with a structured failure reason (no_nonce_stored,expired,mismatch) on nonce verification failures. - Optionally write a structured PHP error-log line for selected failure reasons, controllable via a new
two_factor_log_login_nonce_failuresfilter. - Expand PHPUnit coverage to assert action firing behavior and per-reason logging defaults/filtering.
File summaries
| File | Description |
|---|---|
class-two-factor-core.php |
Adds nonce-failure recording (do_action + optional error_log) and calls it from verify_login_nonce() with explicit reasons. |
tests/class-two-factor-core.php |
Adds helper for creating an actually-expired nonce and new tests covering the new action and logging filter behavior. |
Review details
Suppressed comments (1)
tests/class-two-factor-core.php:844
- This test temporarily removes the class-wide suppression filter and adds a recorder filter, but restoration only happens on the happy path. If an assertion fails, the filter state can leak to later tests. Use try/finally to always remove the recorder filter and restore __return_false.
// Stand in for the class-wide suppression so the default value is observable.
remove_filter( 'two_factor_log_login_nonce_failures', '__return_false' );
add_filter( 'two_factor_log_login_nonce_failures', $recorder, 10, 3 );
$this->assertFalse( Two_Factor_Core::verify_login_nonce( $user_id, $nonce ) );
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
The new test helper calls ReflectionMethod::setAccessible(true) unconditionally, which can trigger deprecation notices on newer PHP versions and risk CI/test failures.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
| $hash_login_nonce = new ReflectionMethod( Two_Factor_Core::class, 'hash_login_nonce' ); | ||
| $hash_login_nonce->setAccessible( true ); | ||
|
|
There was a problem hiding this comment.
since 8.1 method has no effect. since 8.5 its deprecated - good call.
…ails The filter test drops the class-wide error log suppression so it can observe the default value. If its assertion failed, the suppression was never put back and the recorder stayed registered, so the damage landed on whatever ran next rather than on the test that caused it. Wrap the restoration in finally, here and in the two action tests that register a recorder the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — both addressed. @masteradhoc: both hooks are now in @copilot-pull-request-reviewer: fair point, and taken. Restoration is now wrapped in |
|
@georgestephanis #973 (comment) what do you think here? |
|
I think it's mergeable now |
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>
Description
Failed login nonce verification is currently silent.
validate_login_form_2fa()redirects to the home page and nothing is recorded anywhere, so when a user reports being bounced back to the login screen there is no way for an administrator to tell what happened.#534 describes the user-facing half of this — the person logging in gets no explanation. This PR covers the operator-facing half: making the failures visible in the log so they can be diagnosed and, where the volume warrants it, acted on. It does not close #534, which is also asking for an error message on the login form.
A handful of these are entirely routine (a stale browser tab, the back button, two sessions on a shared account racing each other, as in #534). A sustained run of them against a single account is a different signal, and today nothing distinguishes the two.
What changed
verify_login_nonce()now reports every failure, distinguishing three cases:no_nonce_storedexpiredmismatchEach failure:
Fires a new
two_factor_login_nonce_failedaction ($user_id,$reason), for sites that want to route these into an audit log, an IDS, or a rate limiter.Writes a line to the PHP error log, including the validated remote address:
The error log write defaults on for
expiredandmismatch, and off forno_nonce_stored.The first two require a nonce to already be stored for the user, which only happens after a successful password check, so their volume is bounded by real login activity.
no_nonce_storedis not bounded — any unauthenticated request carrying a guessed user ID reaches it, so defaulting it on would hand anyone an unthrottled write to the error log. It is also the least informative of the three, firing for every stale bookmark and resubmitted form.The action fires for all three regardless, so anything routing these into an audit log or intrusion detection system sees everything. The new
two_factor_log_login_nonce_failuresfilter receives the per-reason default and can flip it either way — optno_nonce_storedin, or turn the whole thing off.The presented value is never written to the log, only the reason it was rejected.
Notes
REMOTE_ADDRis unreliable behind a proxy or load balancer; the docblock says so, and the action hook is the better place for sites that can resolve provenance properly.User-Agentin the log line, deliberately. Every field written today is an integer, a fixed-vocabulary string, or an IP that passedFILTER_VALIDATE_IP— nothing attacker-controlled. A raw UA would be the first, and a newline in one lets a caller forge additional lines that look exactly like ours. It is also trivially spoofed, roughly triples the line length, and pairs with the IP as a browser fingerprint. Sites that want it can capture it from the action hook with their own sanitization and retention rules.Partially addresses #534.
Testing
test_failed_login_nonce_fires_action(data provider covering all three reasons),test_successful_login_nonce_does_not_fire_action,test_login_nonce_failure_logging_can_be_filtered(data provider asserting the per-reason default reaching the filter).🤖 Generated with Claude Code