Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
422
src/Framework/Auth/PasswordHasher.php
Normal file
422
src/Framework/Auth/PasswordHasher.php
Normal file
@@ -0,0 +1,422 @@
|
||||
<?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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user