Files

73 lines
2.2 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
namespace App\Domain\User;
/**
* Applies the same password rules to initial credentials and password resets.
* It validates only; callers remain responsible for hashing accepted passwords.
*/
final class PasswordPolicy
{
public const MINIMUM_LENGTH = 12;
/** @return array{valid: bool, errors: list<string>} */
public function validate(mixed $password): array
{
$errors = [];
if (!is_string($password)) {
return ['valid' => false, 'errors' => ['password must be a string']];
}
if (mb_strlen($password) < self::MINIMUM_LENGTH) {
$errors[] = 'minimum length';
}
if (preg_match('/\p{Lu}/u', $password) !== 1) {
$errors[] = 'uppercase letter';
}
if (preg_match('/\p{Ll}/u', $password) !== 1) {
$errors[] = 'lowercase letter';
}
if (preg_match('/\p{N}/u', $password) !== 1) {
$errors[] = 'number';
}
if (preg_match('/[^\p{L}\p{N}\s]/u', $password) !== 1) {
$errors[] = 'symbol';
}
if ($this->isCommonPlaceholder($password)) {
$errors[] = 'common placeholder';
}
return ['valid' => $errors === [], 'errors' => $errors];
}
/** @return array{valid: bool, errors: list<string>} */
public function validateInitial(mixed $password): array
{
return $this->validate($password);
}
/** @return array{valid: bool, errors: list<string>} */
public function validateReset(mixed $password): array
{
return $this->validate($password);
}
public function isValid(mixed $password): bool
{
return $this->validate($password)['valid'];
}
private function isCommonPlaceholder(string $password): bool
{
$canonical = strtolower($password);
$canonical = strtr($canonical, ['@' => 'a', '$' => 's', '0' => 'o']);
$canonical = preg_replace('/[^a-z0-9]/', '', $canonical) ?? '';
return preg_match(
'/^(?:password|changeme|welcome|admin|administrator|temporary|temppassword|qwerty|letmein)[0-9]*$/',
$canonical,
) === 1;
}
}