Files
intotheeast-com-content/plugins/api/classes/Api/Services/PasswordPolicyService.php
T

147 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Services;
use Grav\Common\Config\Config;
/**
* Builds a structured password policy from Grav's single `system.pwd_regex`
* string. Admin-next uses the result to render a live rule checklist and
* strength meter without baking assumptions into the UI.
*
* Source of truth order:
* 1. system.pwd_rules (optional, admin-supplied list of labeled rules)
* 2. Heuristic parse of system.pwd_regex (handles the common lookahead form)
* 3. Opaque fallback — one generic "must match policy" rule
*
* The combined regex is always returned unchanged for server-side validation.
*/
class PasswordPolicyService
{
public static function build(Config $config): array
{
$regex = (string) $config->get('system.pwd_regex', '');
$rules = self::configuredRules($config);
if ($rules === null) {
$rules = self::parseRules($regex);
}
return [
'regex' => $regex,
'min_length' => self::extractMinLength($regex),
'rules' => $rules,
];
}
/**
* @return list<array{id:string,label:string,pattern:string}>|null
*/
private static function configuredRules(Config $config): ?array
{
$raw = $config->get('system.pwd_rules');
if (!is_array($raw) || $raw === []) {
return null;
}
$out = [];
foreach ($raw as $i => $entry) {
if (!is_array($entry)) continue;
$pattern = (string) ($entry['pattern'] ?? '');
$label = (string) ($entry['label'] ?? '');
if ($pattern === '' || $label === '') continue;
$out[] = [
'id' => (string) ($entry['id'] ?? ('rule_' . $i)),
'label' => $label,
'pattern' => $pattern,
];
}
return $out === [] ? null : $out;
}
/**
* Heuristic parse of the common lookahead form used by Grav's default
* pwd_regex: `(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}`.
*
* @return list<array{id:string,label:string,pattern:string}>
*/
private static function parseRules(string $regex): array
{
$rules = [];
$min = self::extractMinLength($regex);
if ($min > 0) {
$rules[] = [
'id' => 'length',
'label' => sprintf('At least %d characters', $min),
'pattern' => '.{' . $min . ',}',
];
}
$lookaheads = [];
if (preg_match_all('/\(\?=([^)]+)\)/', $regex, $m)) {
$lookaheads = $m[1];
}
$seen = [];
foreach ($lookaheads as $inner) {
$rule = self::classifyLookahead($inner);
if ($rule === null) continue;
if (isset($seen[$rule['id']])) continue;
$seen[$rule['id']] = true;
$rules[] = $rule;
}
if ($rules === []) {
$rules[] = [
'id' => 'policy',
'label' => 'Must match the configured password policy',
'pattern' => $regex !== '' ? $regex : '.+',
];
}
return $rules;
}
/**
* @return array{id:string,label:string,pattern:string}|null
*/
private static function classifyLookahead(string $inner): ?array
{
// Strip the `.*` prefix that typically precedes the character class.
$body = preg_replace('/^\.\*/', '', $inner) ?? $inner;
// Digit: \d or [0-9]
if ($body === '\\d' || preg_match('/^\[0-9\]$/', $body)) {
return ['id' => 'digit', 'label' => 'At least one number', 'pattern' => '\\d'];
}
if ($body === '[a-z]') {
return ['id' => 'lowercase', 'label' => 'At least one lowercase letter', 'pattern' => '[a-z]'];
}
if ($body === '[A-Z]') {
return ['id' => 'uppercase', 'label' => 'At least one uppercase letter', 'pattern' => '[A-Z]'];
}
// Special char — a handful of common forms
$specialForms = ['\\W', '[^\\w]', '[^a-zA-Z0-9]', '[^\\w\\s]'];
if (in_array($body, $specialForms, true) || preg_match('/^\[[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?`~\s]+\]$/', $body)) {
return ['id' => 'symbol', 'label' => 'At least one symbol', 'pattern' => '[^a-zA-Z0-9]'];
}
return null;
}
private static function extractMinLength(string $regex): int
{
if (preg_match('/\.\{(\d+),?\d*\}/', $regex, $m)) {
return (int) $m[1];
}
return 0;
}
}