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
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:
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? [] ) );
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user