Validate signup email and password strength #155

Merged
thatguygriff merged 1 commits from feature/150-signup-credential-validation into main 2026-07-30 01:27:05 +00:00
Owner

Closes #150.

The password was only ever checked for length (strlen < 8). It is now checked on both sides, with each side doing the job it can actually do.

The split, and why

The browser scores the password with zxcvbn — through WordPress's own password-strength-meter script rather than a second opinion of our own — and refuses to submit below "medium". That is the nuanced test; it knows Tr0ub4dor&3 is weaker than it looks. But it is advice a client can decline to take.

Auth\PasswordPolicy runs on the server and is the rule that holds. It deliberately does not try to reproduce a strength score in PHP. It rejects the categorically bad, which is what a server can check without shipping a dictionary:

  • shorter than 8 characters
  • one of the well-known leaked passwords (password123, qwertyuiop, …)
  • built from fewer than four distinct characters (aaaaaaaa, abababab)
  • containing the user's own display name, email, or the part before the @

Both thresholds come from the same two constants, handed to JavaScript by wp_localize_script(), so the two sides cannot drift into disagreeing about what was accepted.

The decisions the issue asked for

Threshold: zxcvbn score ≥ 2 of 4 ("medium"). Enough to stop a password a stranger would guess, without demanding a passphrase to book a piano lesson. WP core itself allows weak passwords behind a confirmation tick; this is stricter than that.

Minimum length: 8, unchanged. NIST SP 800-63B's floor. I did not add composition rules ("must contain a symbol") — the same guidance advises against them, because they mostly produce predictable substitutions like Password1! while feeling strict.

No confirmation field. A second box is the older answer to "did you typo it", and it roughly doubles the abandonment surface. If you'd rather guard against a typo, a show/hide toggle on the single field is the better trade — say the word and I'll add one; I left it out as scope.

Implementation notes

The verdict is applied with setCustomValidity() on the password field, not by disabling a button. The form has up to three submits (plain, guardian-mode early, step two's) plus a "Next" that already gates on checkValidity() — an invalid field stops all of them without any needing to know why.

Email validation moved ahead of the password check, since the password is now checked against the email. A blank form therefore reports the email problem first, which also matches the order the fields appear in.

Verification

composer test (795, up from 770), composer lint, composer cs all pass. New: PasswordPolicyTest covering every rejection reason plus the cases that must not trip it (a 2-letter name overlap, an empty identity, surrounding spaces counted rather than trimmed), and signup-path tests proving nothing is created for a refused password or a malformed email.

The browser half was driven in a headless browser against a controllable scorer — I'm testing my gating, not zxcvbn:

too short → blocked, says so ok
score 0 / 1 → blocked, weak message, form invalid ok
score 2 → allowed, "Good enough" ok
score 4 → allowed, form valid ok
identity list reaches the meter ok
zxcvbn still loading → not blocked, silent ok
field emptied → no stale message ok

That second-to-last row matters: zxcvbn's dictionary is fetched after page load, and the gate has to stay open in that window. The server is what covers it.

Closes #150. The password was only ever checked for length (`strlen < 8`). It is now checked on both sides, with each side doing the job it can actually do. ## The split, and why **The browser** scores the password with zxcvbn — through WordPress's own `password-strength-meter` script rather than a second opinion of our own — and refuses to submit below "medium". That is the nuanced test; it knows `Tr0ub4dor&3` is weaker than it looks. But it is advice a client can decline to take. **`Auth\PasswordPolicy`** runs on the server and is the rule that holds. It deliberately does *not* try to reproduce a strength score in PHP. It rejects the categorically bad, which is what a server can check without shipping a dictionary: - shorter than 8 characters - one of the well-known leaked passwords (`password123`, `qwertyuiop`, …) - built from fewer than four distinct characters (`aaaaaaaa`, `abababab`) - containing the user's own display name, email, or the part before the `@` Both thresholds come from the same two constants, handed to JavaScript by `wp_localize_script()`, so the two sides cannot drift into disagreeing about what was accepted. ## The decisions the issue asked for **Threshold: zxcvbn score ≥ 2 of 4 ("medium").** Enough to stop a password a stranger would guess, without demanding a passphrase to book a piano lesson. WP core itself allows weak passwords behind a confirmation tick; this is stricter than that. **Minimum length: 8, unchanged.** NIST SP 800-63B's floor. I did **not** add composition rules ("must contain a symbol") — the same guidance advises against them, because they mostly produce predictable substitutions like `Password1!` while feeling strict. **No confirmation field.** A second box is the older answer to "did you typo it", and it roughly doubles the abandonment surface. If you'd rather guard against a typo, a show/hide toggle on the single field is the better trade — say the word and I'll add one; I left it out as scope. ## Implementation notes The verdict is applied with `setCustomValidity()` on the password field, not by disabling a button. The form has up to three submits (plain, guardian-mode early, step two's) plus a "Next" that already gates on `checkValidity()` — an invalid field stops all of them without any needing to know why. Email validation moved **ahead** of the password check, since the password is now checked against the email. A blank form therefore reports the email problem first, which also matches the order the fields appear in. ## Verification `composer test` (795, up from 770), `composer lint`, `composer cs` all pass. New: `PasswordPolicyTest` covering every rejection reason plus the cases that must *not* trip it (a 2-letter name overlap, an empty identity, surrounding spaces counted rather than trimmed), and signup-path tests proving nothing is created for a refused password or a malformed email. The browser half was driven in a headless browser against a controllable scorer — I'm testing my gating, not zxcvbn: | | | |---|---| | too short → blocked, says so | ok | | score 0 / 1 → blocked, weak message, form invalid | ok | | score 2 → allowed, "Good enough" | ok | | score 4 → allowed, form valid | ok | | identity list reaches the meter | ok | | **zxcvbn still loading → not blocked, silent** | ok | | field emptied → no stale message | ok | That second-to-last row matters: zxcvbn's dictionary is fetched after page load, and the gate has to stay open in that window. The server is what covers it.
thatguygriff added 1 commit 2026-07-30 01:13:57 +00:00
Validate signup email and password strength
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
b5b9a7ac54
The password was only ever checked for length. It is now checked on both
sides, with each side doing the job it can actually do.

The browser scores it with zxcvbn, through WordPress's own
password-strength-meter script rather than a second opinion of our own, and
refuses to submit below "medium". That is the nuanced test — it knows
Tr0ub4dor&3 is weaker than it looks — but it is advice a client can decline
to take.

Auth\PasswordPolicy runs on the server and is the rule that holds. It does
not try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary: too
short, a well-known leaked password, fewer than four distinct characters, or
the user's own name or email inside it. No composition rules — NIST advises
against them, and they mostly produce predictable substitutions.

Both thresholds come from the same two constants, handed to JavaScript by
wp_localize_script, so the sides cannot drift into disagreeing about what
was accepted.

The verdict is attached to the field with setCustomValidity() rather than by
disabling a button. The form has up to three submits plus a "Next" that
already gates on checkValidity(), and an invalid field stops all of them
without any of them needing to know why.

Email validation moved ahead of the password check, since the password is
now checked against the email. A blank form therefore reports the email
first, which also matches the order the fields appear in.

Verified the browser half against a controllable scorer: each score band
blocks or allows as intended, the identity list reaches the meter, and the
gate stays open while zxcvbn's dictionary is still loading — the server
covers that window.

Closes #150

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff merged commit d554e35d80 into main 2026-07-30 01:27:05 +00:00
thatguygriff deleted branch feature/150-signup-credential-validation 2026-07-30 01:27:05 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#155