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

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]>
This commit is contained in:
2026-07-29 22:13:31 -03:00
co-authored by Claude Opus 5
parent 2878beb221
commit b5b9a7ac54
10 changed files with 574 additions and 21 deletions
+31 -4
View File
@@ -124,6 +124,28 @@ class RegistrationPage {
// needed whenever the form itself is on screen.
if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' );
// The browser gate reads the same numbers the server enforces, so the
// two cannot drift into disagreeing about what it accepted.
wp_localize_script(
'us-scheduler-register',
'usSchedulerPassword',
[
'minLength' => PasswordPolicy::MIN_LENGTH,
'minScore' => PasswordPolicy::MIN_SCORE,
'strings' => [
'short' => sprintf(
/* translators: %d: minimum number of characters. */
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
PasswordPolicy::MIN_LENGTH
),
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
],
]
);
}
ob_start();
@@ -248,10 +270,6 @@ class RegistrationPage {
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
@@ -263,6 +281,15 @@ class RegistrationPage {
}
}
// After the email, so the password can be checked against it. The browser
// scores the password with zxcvbn and refuses to submit a weak one, but
// that is advice a client can decline to take — this is the check that
// holds. See PasswordPolicy for why the two halves differ.
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
if ( null !== $passwordError ) {
return esc_html( $passwordError );
}
$policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );