Files
michaelschiemer/src/Framework/Auth/PasswordHasher.php
Michael Schiemer e30753ba0e fix: resolve RedisCache array offset error and improve discovery diagnostics
- 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
2025-09-12 20:05:18 +02:00

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);
}
}