Merge pull request 'Validate signup email and password strength' (#155) from feature/150-signup-credential-validation into main
CI / Tests (PHP 8.2) (push) Failing after 43s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Failing after 2m44s
CI / Build Plugin Zip (push) Skipped
CI / Tests (PHP 8.1) (push) Failing after 50s

Reviewed-on: #155
This commit was merged in pull request #155.
This commit is contained in:
2026-07-30 01:27:05 +00:00
10 changed files with 574 additions and 21 deletions
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* What counts as an acceptable signup password.
*
* The check is deliberately split across the two sides, because the two sides
* can do different things:
*
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test —
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple` —
* but it is only advice, because anything in a browser can be turned off.
* - **This class** runs on the server and is the rule that actually holds. It
* cannot score a password the way zxcvbn does without shipping a dictionary,
* so it does not pretend to: it rejects the categorically bad — too short,
* the user's own name or email, a password from the well-known lists, or one
* built from almost no distinct characters.
*
* Neither half is sufficient alone, which is the point. A password that clears
* both is not guaranteed strong; one that fails either is definitely not.
*/
class PasswordPolicy {
/**
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
* against composition rules ("must contain a symbol") on the grounds that they
* push people towards predictable substitutions. Length plus the checks below
* does more for less annoyance.
*/
public const MIN_LENGTH = 8;
/**
* The zxcvbn score the browser demands before it will let the form submit,
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
* passwords a stranger would guess while still accepting an ordinary
* memorable one — a studio signup form is not a bank.
*/
public const MIN_SCORE = 2;
/**
* How much of the user's own identity has to appear in the password before it
* is refused. Short enough to catch a name inside a longer password, long
* enough that a two- or three-letter coincidence does not trip it.
*/
private const IDENTITY_FRAGMENT_LENGTH = 4;
/** Fewest distinct characters a password may be built from. */
private const MIN_DISTINCT_CHARACTERS = 4;
/**
* Why this password is unacceptable, or null when it passes.
*
* `$email` and `$displayName` are what the same submission is claiming as an
* identity, so they can be checked against the password before either exists
* as a user.
*/
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
// Not trimmed: a leading or trailing space is a legitimate character, and
// silently changing what someone typed would lock them out later.
if ( strlen( $password ) < self::MIN_LENGTH ) {
return sprintf(
/* translators: %d: minimum number of characters. */
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
self::MIN_LENGTH
);
}
$lower = strtolower( $password );
if ( in_array( $lower, self::commonPasswords(), true ) ) {
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
}
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
}
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
}
return null;
}
/**
* Whether the password contains the user's display name, their email address,
* or the part of it before the `@` — the first things anyone guessing would
* try, and the reason "grace2019" is worse than its length suggests.
*/
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
$email = strtolower( trim( $email ) );
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
foreach ( $fragments as $fragment ) {
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
return true;
}
}
return false;
}
/**
* Passwords common enough that a guess costs nothing. Only entries at least
* {@see MIN_LENGTH} long are worth listing — anything shorter is already
* refused — so this is the long tail of the usual leaked-password lists
* rather than the whole of it. zxcvbn in the browser covers the rest.
*
* @return list<string>
*/
private static function commonPasswords(): array {
return [
'password',
'password1',
'password12',
'password123',
'passw0rd',
'p@ssword',
'p@ssw0rd',
'12345678',
'123456789',
'1234567890',
'123123123',
'qwertyui',
'qwertyuiop',
'qwerty123',
'qwerty12',
'1qaz2wsx',
'zaq12wsx',
'iloveyou',
'princess',
'sunshine',
'football',
'baseball',
'basketball',
'superman',
'batman123',
'trustno1',
'welcome1',
'welcome123',
'letmein1',
'letmein123',
'admin123',
'administrator',
'abc12345',
'abcd1234',
'monkey123',
'dragon123',
'michael1',
'jennifer',
'starwars',
'computer',
'whatever',
'freedom1',
'changeme',
'secret123',
'login123',
];
}
}
+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'] ?? [] ) );
+16 -2
View File
@@ -88,7 +88,21 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
// Progressive enhancement for the two-step registration form (no dependencies).
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
/*
* Progressive enhancement for the two-step registration form.
*
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
* fetches the (large) dictionary only once the page has loaded — hence
* the guard in register.js for the window where it is not there yet.
*/
wp_register_script(
'us-scheduler-register',
USC_PLUGIN_URL . 'assets/js/register.js',
[ 'password-strength-meter' ],
USC_VERSION,
true
);
}
}