- Fix RedisCache driver to handle MGET failures gracefully with fallback - Add comprehensive discovery context comparison debug tools - Identify root cause: WEB context discovery missing 166 items vs CLI - WEB context missing RequestFactory class entirely (52 vs 69 commands) - Improved exception handling with detailed binding diagnostics
425 lines
13 KiB
PHP
425 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Auth;
|
|
|
|
use App\Framework\Cryptography\KeyDerivationFunction;
|
|
use InvalidArgumentException;
|
|
use SensitiveParameter;
|
|
|
|
/**
|
|
* Password Hasher Service
|
|
*
|
|
* Provides secure password hashing and verification using the framework's
|
|
* cryptography module. Supports automatic rehashing when security standards
|
|
* are updated.
|
|
*/
|
|
final readonly class PasswordHasher
|
|
{
|
|
public const int MIN_PASSWORD_LENGTH = 8;
|
|
public const int MAX_PASSWORD_LENGTH = 4096;
|
|
|
|
// Default security levels
|
|
public const string LEVEL_LOW = 'low';
|
|
public const string LEVEL_STANDARD = 'standard';
|
|
public const string LEVEL_HIGH = 'high';
|
|
|
|
public function __construct(
|
|
private KeyDerivationFunction $kdf,
|
|
private string $defaultAlgorithm = 'argon2id',
|
|
private string $defaultSecurityLevel = self::LEVEL_STANDARD
|
|
) {
|
|
$this->validateConfiguration();
|
|
}
|
|
|
|
/**
|
|
* Hash a plain text password
|
|
*/
|
|
public function hash(
|
|
#[SensitiveParameter]
|
|
string $plainPassword,
|
|
?string $algorithm = null,
|
|
?string $securityLevel = null
|
|
): HashedPassword {
|
|
$this->validatePassword($plainPassword);
|
|
|
|
$algorithm = $algorithm ?? $this->defaultAlgorithm;
|
|
$securityLevel = $securityLevel ?? $this->defaultSecurityLevel;
|
|
|
|
$derivedKey = $this->kdf->hashPassword(
|
|
$plainPassword,
|
|
$algorithm,
|
|
$this->getParametersForLevel($algorithm, $securityLevel)
|
|
);
|
|
|
|
return HashedPassword::fromDerivedKey($derivedKey);
|
|
}
|
|
|
|
/**
|
|
* Verify a password against a hash
|
|
*/
|
|
public function verify(
|
|
#[SensitiveParameter]
|
|
string $plainPassword,
|
|
HashedPassword $hashedPassword
|
|
): bool {
|
|
if (empty($plainPassword)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return $this->kdf->verify($plainPassword, $hashedPassword->getDerivedKey());
|
|
} catch (\Exception) {
|
|
// Log exception for debugging but don't expose details
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a password hash needs to be rehashed
|
|
*/
|
|
public function needsRehash(
|
|
HashedPassword $hashedPassword,
|
|
?string $algorithm = null,
|
|
?string $securityLevel = null
|
|
): bool {
|
|
$algorithm = $algorithm ?? $this->defaultAlgorithm;
|
|
$securityLevel = $securityLevel ?? $this->defaultSecurityLevel;
|
|
|
|
$currentParameters = $this->getParametersForLevel($algorithm, $securityLevel);
|
|
|
|
return $hashedPassword->needsRehash($algorithm, $currentParameters);
|
|
}
|
|
|
|
/**
|
|
* Rehash a password if needed (requires plain password)
|
|
*/
|
|
public function rehashIfNeeded(
|
|
#[SensitiveParameter]
|
|
string $plainPassword,
|
|
HashedPassword $currentHash,
|
|
?string $algorithm = null,
|
|
?string $securityLevel = null
|
|
): ?HashedPassword {
|
|
if (! $this->needsRehash($currentHash, $algorithm, $securityLevel)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->hash($plainPassword, $algorithm, $securityLevel);
|
|
}
|
|
|
|
/**
|
|
* Validate password strength
|
|
*/
|
|
public function validatePasswordStrength(
|
|
#[SensitiveParameter]
|
|
string $plainPassword
|
|
): PasswordValidationResult {
|
|
$errors = [];
|
|
$warnings = [];
|
|
$score = 100;
|
|
|
|
$length = mb_strlen($plainPassword);
|
|
|
|
// Length validation
|
|
if ($length < self::MIN_PASSWORD_LENGTH) {
|
|
$errors[] = sprintf('Password must be at least %d characters long', self::MIN_PASSWORD_LENGTH);
|
|
$score -= 50;
|
|
} elseif ($length < 12) {
|
|
$warnings[] = 'Consider using a longer password (12+ characters recommended)';
|
|
$score -= 10;
|
|
}
|
|
|
|
// Complexity checks
|
|
$hasUppercase = preg_match('/[A-Z]/', $plainPassword);
|
|
$hasLowercase = preg_match('/[a-z]/', $plainPassword);
|
|
$hasNumbers = preg_match('/[0-9]/', $plainPassword);
|
|
$hasSpecialChars = preg_match('/[^A-Za-z0-9]/', $plainPassword);
|
|
|
|
$complexityCount = (int)$hasUppercase + (int)$hasLowercase + (int)$hasNumbers + (int)$hasSpecialChars;
|
|
|
|
if ($complexityCount < 2) {
|
|
$errors[] = 'Password must contain at least 2 different character types';
|
|
$score -= 30;
|
|
} elseif ($complexityCount < 3) {
|
|
$warnings[] = 'Consider using more character types for better security';
|
|
$score -= 10;
|
|
}
|
|
|
|
// Common patterns
|
|
if ($this->containsCommonPatterns($plainPassword)) {
|
|
$warnings[] = 'Password contains common patterns';
|
|
$score -= 20;
|
|
}
|
|
|
|
// Sequential characters
|
|
if ($this->hasSequentialCharacters($plainPassword)) {
|
|
$warnings[] = 'Avoid sequential characters (e.g., "123", "abc")';
|
|
$score -= 15;
|
|
}
|
|
|
|
// Repeated characters
|
|
if ($this->hasExcessiveRepeatedCharacters($plainPassword)) {
|
|
$warnings[] = 'Avoid excessive character repetition';
|
|
$score -= 10;
|
|
}
|
|
|
|
$score = max(0, $score);
|
|
|
|
return new PasswordValidationResult(
|
|
isValid: empty($errors),
|
|
errors: $errors,
|
|
warnings: $warnings,
|
|
strengthScore: $score,
|
|
strength: $this->calculateStrengthFromScore($score)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate a secure random password
|
|
*/
|
|
public function generateSecurePassword(
|
|
int $length = 16,
|
|
bool $includeUppercase = true,
|
|
bool $includeLowercase = true,
|
|
bool $includeNumbers = true,
|
|
bool $includeSpecialChars = true,
|
|
string $excludeChars = ''
|
|
): string {
|
|
if ($length < self::MIN_PASSWORD_LENGTH) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Password length must be at least %d', self::MIN_PASSWORD_LENGTH)
|
|
);
|
|
}
|
|
|
|
if ($length > self::MAX_PASSWORD_LENGTH) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Password length cannot exceed %d', self::MAX_PASSWORD_LENGTH)
|
|
);
|
|
}
|
|
|
|
$charset = '';
|
|
|
|
if ($includeUppercase) {
|
|
$charset .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
}
|
|
|
|
if ($includeLowercase) {
|
|
$charset .= 'abcdefghijklmnopqrstuvwxyz';
|
|
}
|
|
|
|
if ($includeNumbers) {
|
|
$charset .= '0123456789';
|
|
}
|
|
|
|
if ($includeSpecialChars) {
|
|
$charset .= '!@#$%^&*()-_=+[]{}|;:,.<>?/~`';
|
|
}
|
|
|
|
if (empty($charset)) {
|
|
throw new InvalidArgumentException('At least one character type must be included');
|
|
}
|
|
|
|
// Remove excluded characters
|
|
if (! empty($excludeChars)) {
|
|
$charset = str_replace(str_split($excludeChars), '', $charset);
|
|
}
|
|
|
|
$password = '';
|
|
$charsetLength = strlen($charset);
|
|
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$randomIndex = random_int(0, $charsetLength - 1);
|
|
$password .= $charset[$randomIndex];
|
|
}
|
|
|
|
return $password;
|
|
}
|
|
|
|
/**
|
|
* Get parameters for security level
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function getParametersForLevel(string $algorithm, string $level): array
|
|
{
|
|
try {
|
|
$params = $this->kdf->getRecommendedParameters($algorithm, $level);
|
|
|
|
return match ($algorithm) {
|
|
'argon2id' => [
|
|
'memory_cost' => $params['memory_cost'] ?? 65536,
|
|
'time_cost' => $params['time_cost'] ?? 4,
|
|
'threads' => $params['threads'] ?? 3,
|
|
'key_length' => $params['key_length'] ?? 32,
|
|
],
|
|
'pbkdf2-sha256', 'pbkdf2-sha512' => [
|
|
'iterations' => $params['iterations'] ?? 100000,
|
|
'key_length' => $params['key_length'] ?? 32,
|
|
],
|
|
'scrypt' => [
|
|
'cost_parameter' => $params['cost_parameter'] ?? 16384,
|
|
'block_size' => $params['block_size'] ?? 8,
|
|
'parallelization' => $params['parallelization'] ?? 1,
|
|
'key_length' => $params['key_length'] ?? 32,
|
|
],
|
|
default => []
|
|
};
|
|
} catch (\Exception) {
|
|
// Fallback to standard parameters
|
|
return $this->getDefaultParameters($algorithm);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get default parameters for algorithm
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function getDefaultParameters(string $algorithm): array
|
|
{
|
|
return match ($algorithm) {
|
|
'argon2id' => [
|
|
'memory_cost' => 65536,
|
|
'time_cost' => 4,
|
|
'threads' => 3,
|
|
'key_length' => 32,
|
|
],
|
|
'pbkdf2-sha256', 'pbkdf2-sha512' => [
|
|
'iterations' => 100000,
|
|
'key_length' => 32,
|
|
],
|
|
'scrypt' => [
|
|
'cost_parameter' => 16384,
|
|
'block_size' => 8,
|
|
'parallelization' => 1,
|
|
'key_length' => 32,
|
|
],
|
|
default => []
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate password
|
|
*/
|
|
private function validatePassword(#[SensitiveParameter] string $password): void
|
|
{
|
|
if (empty($password)) {
|
|
throw new InvalidArgumentException('Password cannot be empty');
|
|
}
|
|
|
|
$length = mb_strlen($password);
|
|
|
|
if ($length < self::MIN_PASSWORD_LENGTH) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Password must be at least %d characters long', self::MIN_PASSWORD_LENGTH)
|
|
);
|
|
}
|
|
|
|
if ($length > self::MAX_PASSWORD_LENGTH) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Password cannot exceed %d characters', self::MAX_PASSWORD_LENGTH)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate configuration
|
|
*/
|
|
private function validateConfiguration(): void
|
|
{
|
|
$supportedAlgorithms = ['argon2id', 'pbkdf2-sha256', 'pbkdf2-sha512', 'scrypt'];
|
|
|
|
if (! in_array($this->defaultAlgorithm, $supportedAlgorithms, true)) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Unsupported algorithm: %s', $this->defaultAlgorithm)
|
|
);
|
|
}
|
|
|
|
$supportedLevels = [self::LEVEL_LOW, self::LEVEL_STANDARD, self::LEVEL_HIGH];
|
|
|
|
if (! in_array($this->defaultSecurityLevel, $supportedLevels, true)) {
|
|
throw new InvalidArgumentException(
|
|
sprintf('Unsupported security level: %s', $this->defaultSecurityLevel)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for common patterns
|
|
*/
|
|
private function containsCommonPatterns(#[SensitiveParameter] string $password): bool
|
|
{
|
|
$commonPatterns = [
|
|
'password', '123456', 'qwerty', 'admin', 'letmein',
|
|
'welcome', 'monkey', 'dragon', 'master', 'abc123',
|
|
];
|
|
|
|
$lowerPassword = strtolower($password);
|
|
|
|
foreach ($commonPatterns as $pattern) {
|
|
if (str_contains($lowerPassword, $pattern)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check for sequential characters
|
|
*/
|
|
private function hasSequentialCharacters(#[SensitiveParameter] string $password): bool
|
|
{
|
|
$sequences = [
|
|
'012', '123', '234', '345', '456', '567', '678', '789',
|
|
'abc', 'bcd', 'cde', 'def', 'efg', 'fgh', 'ghi', 'hij',
|
|
'ijk', 'jkl', 'klm', 'lmn', 'mno', 'nop', 'opq', 'pqr',
|
|
'qrs', 'rst', 'stu', 'tuv', 'uvw', 'vwx', 'wxy', 'xyz',
|
|
];
|
|
|
|
$lowerPassword = strtolower($password);
|
|
|
|
foreach ($sequences as $sequence) {
|
|
if (str_contains($lowerPassword, $sequence)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check for excessive repeated characters
|
|
*/
|
|
private function hasExcessiveRepeatedCharacters(#[SensitiveParameter] string $password): bool
|
|
{
|
|
// Check for 3+ repeated characters
|
|
return preg_match('/(.)\1{2,}/', $password) === 1;
|
|
}
|
|
|
|
/**
|
|
* Calculate strength from score
|
|
*/
|
|
private function calculateStrengthFromScore(int $score): PasswordStrength
|
|
{
|
|
return match (true) {
|
|
$score >= 90 => PasswordStrength::VERY_STRONG,
|
|
$score >= 70 => PasswordStrength::STRONG,
|
|
$score >= 50 => PasswordStrength::MODERATE,
|
|
$score >= 30 => PasswordStrength::WEAK,
|
|
default => PasswordStrength::WEAK
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create hasher with custom configuration
|
|
*/
|
|
public static function create(
|
|
KeyDerivationFunction $kdf,
|
|
string $algorithm = 'argon2id',
|
|
string $securityLevel = self::LEVEL_STANDARD
|
|
): self {
|
|
return new self($kdf, $algorithm, $securityLevel);
|
|
}
|
|
}
|