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
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:
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Auth\PasswordPolicy;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PasswordPolicyTest extends TestCase
|
||||
{
|
||||
public function testAcceptsAnOrdinaryMemorablePassword(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading or trailing space is a character like any other. Trimming it
|
||||
* would accept a password the user could then never type back.
|
||||
*/
|
||||
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
|
||||
|
||||
// Seven characters counting both spaces: one short, and still one short
|
||||
// after the spaces are counted rather than stripped.
|
||||
self::assertNotNull(PasswordPolicy::validate(' short '));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider tooShort
|
||||
*/
|
||||
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function tooShort(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [''],
|
||||
'one short' => ['sevench'],
|
||||
'a few chars' => ['abc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider commonPasswords
|
||||
*/
|
||||
public function testRejectsAWellKnownPassword(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function commonPasswords(): array
|
||||
{
|
||||
return [
|
||||
'password123' => ['password123'],
|
||||
'shouting' => ['PASSWORD123'],
|
||||
'mixed case' => ['PassWord123'],
|
||||
'a keyboard walk' => ['qwertyuiop'],
|
||||
'digits in a row' => ['123456789'],
|
||||
'the classic' => ['iloveyou'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider tooFewDistinctCharacters
|
||||
*/
|
||||
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function tooFewDistinctCharacters(): array
|
||||
{
|
||||
return [
|
||||
'one character' => ['aaaaaaaaaa'],
|
||||
'two alternating' => ['abababababab'],
|
||||
'three' => ['abcabcabcabc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider identityEchoes
|
||||
*/
|
||||
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
|
||||
{
|
||||
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string, string}> */
|
||||
public static function identityEchoes(): array
|
||||
{
|
||||
return [
|
||||
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
|
||||
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
|
||||
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
|
||||
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A two- or three-letter overlap with a name is coincidence, not a weakness,
|
||||
* and refusing it would be baffling to the person typing.
|
||||
*/
|
||||
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
|
||||
}
|
||||
|
||||
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user