Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.40% |
228 / 239 |
|
87.10% |
27 / 31 |
CRAP | |
50.00% |
1 / 2 |
| AuthService | |
94.27% |
181 / 192 |
|
75.00% |
12 / 16 |
52.51 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| login | |
100.00% |
64 / 64 |
|
100.00% |
1 / 1 |
13 | |||
| logout | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| refreshToken | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
6 | |||
| verifyPassword | |
70.00% |
14 / 20 |
|
0.00% |
0 / 1 |
8.32 | |||
| hashPassword | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| upgradePasswordHash | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| verifyMfaCode | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
4.13 | |||
| verifyBackupCode | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| loadUserByUsernameOrEmail | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| loadUserById | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getUserStores | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| getUserPermissions | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| recordFailedAttempt | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| resetFailedAttempts | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| updateLastLogin | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| AuthResult | |
100.00% |
47 / 47 |
|
100.00% |
15 / 15 |
20 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| success | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| failed | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| mfaRequired | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| rateLimited | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| locked | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| isSuccess | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getError | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getData | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isMfaRequired | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getMfaUserId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isRateLimited | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isLocked | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRetryAfter | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toArray | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
6 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Authentication Service |
| 4 | * |
| 5 | * Handles user authentication with support for multiple password hash formats, |
| 6 | * MFA verification, and token management. Implements Argon2id for new passwords |
| 7 | * with automatic upgrade of legacy hashes. |
| 8 | * |
| 9 | * @package BuyerKiosk\Auth\Services |
| 10 | */ |
| 11 | |
| 12 | namespace BuyerKiosk\Auth\Services; |
| 13 | |
| 14 | class AuthService |
| 15 | { |
| 16 | /** |
| 17 | * @var \PDO Database connection |
| 18 | */ |
| 19 | private $db; |
| 20 | |
| 21 | /** |
| 22 | * @var TokenService Token management service |
| 23 | */ |
| 24 | private $tokenService; |
| 25 | |
| 26 | /** |
| 27 | * @var AuditLogger Audit logging service |
| 28 | */ |
| 29 | private $auditLogger; |
| 30 | |
| 31 | /** |
| 32 | * @var RateLimiter Rate limiting service |
| 33 | */ |
| 34 | private $rateLimiter; |
| 35 | |
| 36 | /** |
| 37 | * Argon2id configuration (per SDD Section 6.1) |
| 38 | */ |
| 39 | private const ARGON2ID_OPTIONS = [ |
| 40 | 'memory_cost' => 65536, // 64 MB |
| 41 | 'time_cost' => 3, |
| 42 | 'threads' => 4 |
| 43 | ]; |
| 44 | |
| 45 | /** |
| 46 | * Constructor |
| 47 | * |
| 48 | * @param \PDO $db Database connection |
| 49 | * @param TokenService|null $tokenService Token service (auto-created if null) |
| 50 | * @param AuditLogger|null $auditLogger Audit logger (auto-created if null) |
| 51 | * @param RateLimiter|null $rateLimiter Rate limiter (auto-created if null) |
| 52 | */ |
| 53 | public function __construct( |
| 54 | \PDO $db, |
| 55 | ?TokenService $tokenService = null, |
| 56 | ?AuditLogger $auditLogger = null, |
| 57 | ?RateLimiter $rateLimiter = null |
| 58 | ) { |
| 59 | $this->db = $db; |
| 60 | $this->tokenService = $tokenService ?? new TokenService($db); |
| 61 | $this->auditLogger = $auditLogger ?? new AuditLogger($db); |
| 62 | $this->rateLimiter = $rateLimiter ?? new RateLimiter($db); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Authenticate a user and return tokens |
| 67 | * |
| 68 | * @param string $username Username or email |
| 69 | * @param string $password Password |
| 70 | * @param string|null $mfaCode MFA code if required |
| 71 | * @param array $deviceInfo Device information for token binding |
| 72 | * @return AuthResult |
| 73 | */ |
| 74 | public function login(string $username, string $password, ?string $mfaCode = null, array $deviceInfo = []): AuthResult |
| 75 | { |
| 76 | // Rate limit check (per-IP is done in controller, this is per-user) |
| 77 | $userRateResult = $this->rateLimiter->checkLimit('login_user', strtolower($username)); |
| 78 | if ($userRateResult->isBlocked()) { |
| 79 | $this->auditLogger->logLoginFailed(null, 'rate_limited', $username); |
| 80 | return AuthResult::rateLimited($userRateResult->getRetryAfter()); |
| 81 | } |
| 82 | |
| 83 | // Load user |
| 84 | $user = $this->loadUserByUsernameOrEmail($username); |
| 85 | |
| 86 | if (!$user) { |
| 87 | $this->rateLimiter->recordFailure('login_user', strtolower($username)); |
| 88 | $this->auditLogger->logLoginFailed(null, 'user_not_found', $username); |
| 89 | return AuthResult::failed('Invalid credentials'); |
| 90 | } |
| 91 | |
| 92 | // Check if user can login |
| 93 | if (!$user['canLogin']) { |
| 94 | $this->auditLogger->logLoginFailed((int)$user['id'], 'login_disabled'); |
| 95 | return AuthResult::failed('Login not enabled for this account'); |
| 96 | } |
| 97 | |
| 98 | // Check if account is enabled |
| 99 | if (!$user['enabled']) { |
| 100 | $this->auditLogger->logLoginFailed((int)$user['id'], 'account_disabled'); |
| 101 | return AuthResult::failed('Account is disabled'); |
| 102 | } |
| 103 | |
| 104 | // Check if account is activated |
| 105 | if (!$user['active']) { |
| 106 | $this->auditLogger->logLoginFailed((int)$user['id'], 'account_not_activated'); |
| 107 | return AuthResult::failed('Account has not been activated'); |
| 108 | } |
| 109 | |
| 110 | // Check account lockout |
| 111 | if ($user['lockedUntil'] && strtotime($user['lockedUntil']) > time()) { |
| 112 | $retryAfter = strtotime($user['lockedUntil']) - time(); |
| 113 | $this->auditLogger->logLoginFailed((int)$user['id'], 'account_locked'); |
| 114 | return AuthResult::locked($retryAfter); |
| 115 | } |
| 116 | |
| 117 | // Verify password |
| 118 | if (!$this->verifyPassword($user, $password)) { |
| 119 | $this->recordFailedAttempt((int)$user['id'], $user['failedLoginAttempts']); |
| 120 | $this->rateLimiter->recordFailure('login_user', strtolower($username)); |
| 121 | $this->auditLogger->logLoginFailed((int)$user['id'], 'invalid_password'); |
| 122 | return AuthResult::failed('Invalid credentials'); |
| 123 | } |
| 124 | |
| 125 | // Check MFA if enabled |
| 126 | if ($user['mfaEnabled']) { |
| 127 | if ($mfaCode === null) { |
| 128 | return AuthResult::mfaRequired((int)$user['id']); |
| 129 | } |
| 130 | |
| 131 | // Rate limit MFA attempts |
| 132 | $mfaRateResult = $this->rateLimiter->checkLimit('mfa_verify', (string)$user['id']); |
| 133 | if ($mfaRateResult->isBlocked()) { |
| 134 | $this->auditLogger->logMfaFailed((int)$user['id'], 'rate_limited'); |
| 135 | return AuthResult::rateLimited($mfaRateResult->getRetryAfter()); |
| 136 | } |
| 137 | |
| 138 | if (!$this->verifyMfaCode($user, $mfaCode)) { |
| 139 | $this->rateLimiter->recordFailure('mfa_verify', (string)$user['id']); |
| 140 | $this->auditLogger->logMfaFailed((int)$user['id'], 'invalid_code'); |
| 141 | return AuthResult::failed('Invalid MFA code'); |
| 142 | } |
| 143 | |
| 144 | $this->rateLimiter->clearAttempts('mfa_verify', (string)$user['id']); |
| 145 | $this->auditLogger->logMfaSuccess((int)$user['id']); |
| 146 | } |
| 147 | |
| 148 | // Clear rate limits and failed attempts |
| 149 | $this->rateLimiter->clearAttempts('login_user', strtolower($username)); |
| 150 | $this->resetFailedAttempts((int)$user['id']); |
| 151 | |
| 152 | // Load user permissions and stores |
| 153 | $stores = $this->getUserStores((int)$user['id']); |
| 154 | $permissions = $this->getUserPermissions((int)$user['id']); |
| 155 | |
| 156 | // Generate tokens |
| 157 | $accessToken = $this->tokenService->createAccessToken($user, $stores, $permissions); |
| 158 | $refreshToken = $this->tokenService->createRefreshToken((int)$user['id'], $deviceInfo); |
| 159 | |
| 160 | // Update last login |
| 161 | $this->updateLastLogin((int)$user['id'], $deviceInfo['ip'] ?? null); |
| 162 | |
| 163 | // Audit log |
| 164 | $this->auditLogger->logLogin((int)$user['id']); |
| 165 | $this->auditLogger->logTokenIssued((int)$user['id'], 'access'); |
| 166 | $this->auditLogger->logTokenIssued((int)$user['id'], 'refresh'); |
| 167 | |
| 168 | return AuthResult::success([ |
| 169 | 'accessToken' => $accessToken, |
| 170 | 'refreshToken' => $refreshToken, |
| 171 | 'expiresIn' => $this->tokenService->getAccessTokenTTL(), |
| 172 | 'user' => [ |
| 173 | 'id' => (int)$user['id'], |
| 174 | 'username' => $user['username'], |
| 175 | 'email' => $user['email'], |
| 176 | 'displayName' => $user['displayName'], |
| 177 | 'accountType' => $user['accountType'] |
| 178 | ], |
| 179 | 'stores' => $stores, |
| 180 | 'mfaEnabled' => (bool)$user['mfaEnabled'] |
| 181 | ]); |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Logout a user (revoke tokens) |
| 186 | * |
| 187 | * @param int $userId User ID |
| 188 | * @param string|null $refreshToken Specific refresh token to revoke |
| 189 | * @param bool $allDevices Revoke all tokens for user |
| 190 | */ |
| 191 | public function logout(int $userId, ?string $refreshToken = null, bool $allDevices = false): void |
| 192 | { |
| 193 | if ($allDevices) { |
| 194 | $count = $this->tokenService->revokeAllUserTokens($userId); |
| 195 | $this->auditLogger->logTokenRevoked($userId, 'logout_all', $count); |
| 196 | } elseif ($refreshToken) { |
| 197 | $this->tokenService->revokeRefreshToken($refreshToken); |
| 198 | $this->auditLogger->logTokenRevoked($userId, 'logout'); |
| 199 | } |
| 200 | |
| 201 | $this->auditLogger->logLogout($userId); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * Refresh access token using refresh token |
| 206 | * |
| 207 | * @param string $refreshToken Refresh token |
| 208 | * @param array $deviceInfo Device information |
| 209 | * @return AuthResult |
| 210 | */ |
| 211 | public function refreshToken(string $refreshToken, array $deviceInfo = []): AuthResult |
| 212 | { |
| 213 | $tokenData = $this->tokenService->validateRefreshToken($refreshToken); |
| 214 | |
| 215 | if (!$tokenData) { |
| 216 | return AuthResult::failed('Invalid or expired refresh token'); |
| 217 | } |
| 218 | |
| 219 | $userId = $tokenData['userId']; |
| 220 | |
| 221 | // Load user to check status |
| 222 | $user = $this->loadUserById($userId); |
| 223 | if (!$user || !$user['enabled'] || !$user['canLogin']) { |
| 224 | $this->tokenService->revokeRefreshToken($refreshToken); |
| 225 | return AuthResult::failed('User account is no longer valid'); |
| 226 | } |
| 227 | |
| 228 | // Rotate refresh token |
| 229 | $rotated = $this->tokenService->rotateRefreshToken($refreshToken, $deviceInfo); |
| 230 | if (!$rotated) { |
| 231 | return AuthResult::failed('Failed to rotate refresh token'); |
| 232 | } |
| 233 | |
| 234 | // Load permissions and stores |
| 235 | $stores = $this->getUserStores($userId); |
| 236 | $permissions = $this->getUserPermissions($userId); |
| 237 | |
| 238 | // Generate new access token |
| 239 | $accessToken = $this->tokenService->createAccessToken($user, $stores, $permissions); |
| 240 | |
| 241 | $this->auditLogger->logTokenRefreshed($userId); |
| 242 | |
| 243 | return AuthResult::success([ |
| 244 | 'accessToken' => $accessToken, |
| 245 | 'refreshToken' => $rotated['refreshToken'], |
| 246 | 'expiresIn' => $this->tokenService->getAccessTokenTTL() |
| 247 | ]); |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Verify a user's password against stored hash |
| 252 | * |
| 253 | * @param array $user User data with password hash |
| 254 | * @param string $password Plain text password |
| 255 | * @return bool True if password matches |
| 256 | */ |
| 257 | public function verifyPassword(array $user, string $password): bool |
| 258 | { |
| 259 | $hash = $user['password']; |
| 260 | $hashInfo = password_get_info($hash); |
| 261 | |
| 262 | // Modern Argon2id hash |
| 263 | if ($hashInfo['algo'] === PASSWORD_ARGON2ID) { |
| 264 | return password_verify($password, $hash); |
| 265 | } |
| 266 | |
| 267 | // Modern bcrypt hash (algo 1 = PASSWORD_BCRYPT) |
| 268 | if ($hashInfo['algo'] === PASSWORD_BCRYPT) { |
| 269 | if (password_verify($password, $hash)) { |
| 270 | $this->upgradePasswordHash((int)$user['id'], $password, 'bcrypt'); |
| 271 | return true; |
| 272 | } |
| 273 | return false; |
| 274 | } |
| 275 | |
| 276 | // Legacy SHA1 hash: first 25 chars are salt, remaining 40 are hash |
| 277 | if (strlen($hash) === 65) { |
| 278 | $salt = substr($hash, 0, 25); |
| 279 | $expectedHash = $salt . sha1($salt . $password); |
| 280 | if (hash_equals($hash, $expectedHash)) { |
| 281 | $this->upgradePasswordHash((int)$user['id'], $password, 'sha1'); |
| 282 | return true; |
| 283 | } |
| 284 | return false; |
| 285 | } |
| 286 | |
| 287 | // Try password_verify for any other format (older bcrypt variants) |
| 288 | if (password_verify($password, $hash)) { |
| 289 | $this->upgradePasswordHash((int)$user['id'], $password, 'legacy'); |
| 290 | return true; |
| 291 | } |
| 292 | |
| 293 | return false; |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Hash a password using Argon2id |
| 298 | * |
| 299 | * @param string $password Plain text password |
| 300 | * @return string Password hash |
| 301 | */ |
| 302 | public function hashPassword(string $password): string |
| 303 | { |
| 304 | return password_hash($password, PASSWORD_ARGON2ID, self::ARGON2ID_OPTIONS); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Upgrade a legacy password hash to Argon2id |
| 309 | * |
| 310 | * @param int $userId User ID |
| 311 | * @param string $password Plain text password |
| 312 | * @param string $oldType Type of old hash |
| 313 | */ |
| 314 | private function upgradePasswordHash(int $userId, string $password, string $oldType): void |
| 315 | { |
| 316 | $newHash = $this->hashPassword($password); |
| 317 | |
| 318 | $stmt = $this->db->prepare("UPDATE users SET password = :password WHERE id = :id"); |
| 319 | $stmt->execute(['password' => $newHash, 'id' => $userId]); |
| 320 | |
| 321 | $this->auditLogger->logPasswordHashUpgraded($userId, $oldType, 'argon2id'); |
| 322 | error_log("AuthService: Upgraded password hash for user $userId from $oldType to argon2id"); |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Verify MFA code against user's secret |
| 327 | * |
| 328 | * @param array $user User data with mfaSecret |
| 329 | * @param string $code 6-digit code or backup code to verify |
| 330 | * @return bool True if code is valid |
| 331 | */ |
| 332 | private function verifyMfaCode(array $user, string $code): bool |
| 333 | { |
| 334 | $userId = (int)$user['id']; |
| 335 | |
| 336 | // Check if it's a backup code first (format: XXXX-XXXX) |
| 337 | if ($this->verifyBackupCode($userId, $code)) { |
| 338 | $this->auditLogger->logMfaSuccess($userId, 'backup_code'); |
| 339 | return true; |
| 340 | } |
| 341 | |
| 342 | // TOTP verification using OTPHP library |
| 343 | if (!empty($user['mfaSecret'])) { |
| 344 | $mfaService = new MfaService($this->db, $this->auditLogger); |
| 345 | if ($mfaService->verifyCode($user['mfaSecret'], $code)) { |
| 346 | $this->auditLogger->logMfaSuccess($userId, 'totp'); |
| 347 | return true; |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | return false; |
| 352 | } |
| 353 | |
| 354 | /** |
| 355 | * Verify a backup code |
| 356 | * |
| 357 | * @param int $userId User ID |
| 358 | * @param string $code Backup code to verify |
| 359 | * @return bool True if valid (and consumed) |
| 360 | */ |
| 361 | private function verifyBackupCode(int $userId, string $code): bool |
| 362 | { |
| 363 | $stmt = $this->db->prepare("SELECT mfaBackupCodes FROM users WHERE id = :id"); |
| 364 | $stmt->execute(['id' => $userId]); |
| 365 | $user = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 366 | |
| 367 | if (!$user || empty($user['mfaBackupCodes'])) { |
| 368 | return false; |
| 369 | } |
| 370 | |
| 371 | $backupCodes = json_decode($user['mfaBackupCodes'], true); |
| 372 | if (!is_array($backupCodes)) { |
| 373 | return false; |
| 374 | } |
| 375 | |
| 376 | // Backup codes are stored hashed |
| 377 | $hashedInput = hash('sha256', $code); |
| 378 | |
| 379 | foreach ($backupCodes as $key => $storedHash) { |
| 380 | if (hash_equals($storedHash, $hashedInput)) { |
| 381 | // Remove used code |
| 382 | unset($backupCodes[$key]); |
| 383 | |
| 384 | $updateStmt = $this->db->prepare(" |
| 385 | UPDATE users SET mfaBackupCodes = :codes WHERE id = :id |
| 386 | "); |
| 387 | $updateStmt->execute([ |
| 388 | 'codes' => json_encode(array_values($backupCodes)), |
| 389 | 'id' => $userId |
| 390 | ]); |
| 391 | |
| 392 | return true; |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | return false; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Load user by username or email |
| 401 | * |
| 402 | * @param string $usernameOrEmail Username or email |
| 403 | * @return array|null User data or null |
| 404 | */ |
| 405 | private function loadUserByUsernameOrEmail(string $usernameOrEmail): ?array |
| 406 | { |
| 407 | $stmt = $this->db->prepare(" |
| 408 | SELECT id, username, email, password, displayName, firstName, lastName, |
| 409 | canLogin, accountType, enabled, active, mfaEnabled, mfaSecret, mfaBackupCodes, |
| 410 | failedLoginAttempts, lockedUntil |
| 411 | FROM users |
| 412 | WHERE username = :identifier OR email = :identifier |
| 413 | LIMIT 1 |
| 414 | "); |
| 415 | $stmt->execute(['identifier' => $usernameOrEmail]); |
| 416 | $row = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 417 | |
| 418 | return $row ?: null; |
| 419 | } |
| 420 | |
| 421 | /** |
| 422 | * Load user by ID |
| 423 | * |
| 424 | * @param int $userId User ID |
| 425 | * @return array|null User data or null |
| 426 | */ |
| 427 | private function loadUserById(int $userId): ?array |
| 428 | { |
| 429 | $stmt = $this->db->prepare(" |
| 430 | SELECT id, username, email, password, displayName, firstName, lastName, |
| 431 | canLogin, accountType, enabled, active, mfaEnabled, mfaSecret |
| 432 | FROM users |
| 433 | WHERE id = :id |
| 434 | "); |
| 435 | $stmt->execute(['id' => $userId]); |
| 436 | $row = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 437 | |
| 438 | return $row ?: null; |
| 439 | } |
| 440 | |
| 441 | /** |
| 442 | * Get stores a user has access to |
| 443 | * |
| 444 | * @param int $userId User ID |
| 445 | * @return array List of typeNums |
| 446 | */ |
| 447 | private function getUserStores(int $userId): array |
| 448 | { |
| 449 | $stmt = $this->db->prepare(" |
| 450 | SELECT typeNum FROM userStoreAssignments |
| 451 | WHERE userId = :userId AND isActive = 1 |
| 452 | "); |
| 453 | $stmt->execute(['userId' => $userId]); |
| 454 | |
| 455 | return $stmt->fetchAll(\PDO::FETCH_COLUMN); |
| 456 | } |
| 457 | |
| 458 | /** |
| 459 | * Get user's permissions |
| 460 | * |
| 461 | * @param int $userId User ID |
| 462 | * @return array List of permission hooks |
| 463 | */ |
| 464 | private function getUserPermissions(int $userId): array |
| 465 | { |
| 466 | // Get permissions from user's groups |
| 467 | $groupPerms = $this->db->prepare(" |
| 468 | SELECT DISTINCT ag.hook |
| 469 | FROM userGroups ug |
| 470 | JOIN uf_authorize_group ag ON ug.groupId = ag.group_id |
| 471 | WHERE ug.userId = :userId |
| 472 | "); |
| 473 | $groupPerms->execute(['userId' => $userId]); |
| 474 | $permissions = $groupPerms->fetchAll(\PDO::FETCH_COLUMN); |
| 475 | |
| 476 | // Get direct user permissions |
| 477 | $userPerms = $this->db->prepare(" |
| 478 | SELECT hook FROM userPermissions WHERE userId = :userId |
| 479 | "); |
| 480 | $userPerms->execute(['userId' => $userId]); |
| 481 | $directPerms = $userPerms->fetchAll(\PDO::FETCH_COLUMN); |
| 482 | |
| 483 | return array_unique(array_merge($permissions, $directPerms)); |
| 484 | } |
| 485 | |
| 486 | /** |
| 487 | * Record a failed login attempt |
| 488 | * |
| 489 | * @param int $userId User ID |
| 490 | * @param int $currentAttempts Current failed attempt count |
| 491 | */ |
| 492 | private function recordFailedAttempt(int $userId, int $currentAttempts): void |
| 493 | { |
| 494 | $newAttempts = $currentAttempts + 1; |
| 495 | $lockedUntil = null; |
| 496 | |
| 497 | // Lock account after 5 failed attempts |
| 498 | if ($newAttempts >= 5) { |
| 499 | $lockedUntil = date('Y-m-d H:i:s', strtotime('+15 minutes')); |
| 500 | $this->auditLogger->logAccountLocked($userId, $newAttempts, $lockedUntil); |
| 501 | } |
| 502 | |
| 503 | $stmt = $this->db->prepare(" |
| 504 | UPDATE users |
| 505 | SET failedLoginAttempts = :attempts, lockedUntil = :lockedUntil |
| 506 | WHERE id = :id |
| 507 | "); |
| 508 | $stmt->execute([ |
| 509 | 'attempts' => $newAttempts, |
| 510 | 'lockedUntil' => $lockedUntil, |
| 511 | 'id' => $userId |
| 512 | ]); |
| 513 | } |
| 514 | |
| 515 | /** |
| 516 | * Reset failed login attempts after successful login |
| 517 | * |
| 518 | * @param int $userId User ID |
| 519 | */ |
| 520 | private function resetFailedAttempts(int $userId): void |
| 521 | { |
| 522 | $stmt = $this->db->prepare(" |
| 523 | UPDATE users |
| 524 | SET failedLoginAttempts = 0, lockedUntil = NULL |
| 525 | WHERE id = :id |
| 526 | "); |
| 527 | $stmt->execute(['id' => $userId]); |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * Update last login timestamp and IP |
| 532 | * |
| 533 | * @param int $userId User ID |
| 534 | * @param string|null $ipAddress IP address |
| 535 | */ |
| 536 | private function updateLastLogin(int $userId, ?string $ipAddress = null): void |
| 537 | { |
| 538 | $stmt = $this->db->prepare(" |
| 539 | UPDATE users |
| 540 | SET lastLoginAt = NOW(), lastLoginIp = :ip |
| 541 | WHERE id = :id |
| 542 | "); |
| 543 | $stmt->execute(['ip' => $ipAddress, 'id' => $userId]); |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | /** |
| 548 | * Authentication result object |
| 549 | */ |
| 550 | class AuthResult |
| 551 | { |
| 552 | /** |
| 553 | * @var bool Whether authentication was successful |
| 554 | */ |
| 555 | private $success; |
| 556 | |
| 557 | /** |
| 558 | * @var string|null Error message if failed |
| 559 | */ |
| 560 | private $error; |
| 561 | |
| 562 | /** |
| 563 | * @var array|null Data if successful |
| 564 | */ |
| 565 | private $data; |
| 566 | |
| 567 | /** |
| 568 | * @var bool Whether MFA is required |
| 569 | */ |
| 570 | private $mfaRequired = false; |
| 571 | |
| 572 | /** |
| 573 | * @var int|null User ID if MFA required |
| 574 | */ |
| 575 | private $mfaUserId; |
| 576 | |
| 577 | /** |
| 578 | * @var bool Whether rate limited |
| 579 | */ |
| 580 | private $rateLimited = false; |
| 581 | |
| 582 | /** |
| 583 | * @var bool Whether account is locked |
| 584 | */ |
| 585 | private $locked = false; |
| 586 | |
| 587 | /** |
| 588 | * @var int Seconds until retry allowed |
| 589 | */ |
| 590 | private $retryAfter = 0; |
| 591 | |
| 592 | private function __construct() {} |
| 593 | |
| 594 | /** |
| 595 | * Create a successful result |
| 596 | */ |
| 597 | public static function success(array $data): self |
| 598 | { |
| 599 | $result = new self(); |
| 600 | $result->success = true; |
| 601 | $result->data = $data; |
| 602 | return $result; |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Create a failed result |
| 607 | */ |
| 608 | public static function failed(string $error): self |
| 609 | { |
| 610 | $result = new self(); |
| 611 | $result->success = false; |
| 612 | $result->error = $error; |
| 613 | return $result; |
| 614 | } |
| 615 | |
| 616 | /** |
| 617 | * Create an MFA required result |
| 618 | */ |
| 619 | public static function mfaRequired(int $userId): self |
| 620 | { |
| 621 | $result = new self(); |
| 622 | $result->success = false; |
| 623 | $result->mfaRequired = true; |
| 624 | $result->mfaUserId = $userId; |
| 625 | return $result; |
| 626 | } |
| 627 | |
| 628 | /** |
| 629 | * Create a rate limited result |
| 630 | */ |
| 631 | public static function rateLimited(int $retryAfter): self |
| 632 | { |
| 633 | $result = new self(); |
| 634 | $result->success = false; |
| 635 | $result->rateLimited = true; |
| 636 | $result->retryAfter = $retryAfter; |
| 637 | $result->error = 'Too many attempts'; |
| 638 | return $result; |
| 639 | } |
| 640 | |
| 641 | /** |
| 642 | * Create an account locked result |
| 643 | */ |
| 644 | public static function locked(int $retryAfter): self |
| 645 | { |
| 646 | $result = new self(); |
| 647 | $result->success = false; |
| 648 | $result->locked = true; |
| 649 | $result->retryAfter = $retryAfter; |
| 650 | $result->error = 'Account is temporarily locked'; |
| 651 | return $result; |
| 652 | } |
| 653 | |
| 654 | public function isSuccess(): bool { return $this->success; } |
| 655 | public function getError(): ?string { return $this->error; } |
| 656 | public function getData(): ?array { return $this->data; } |
| 657 | public function isMfaRequired(): bool { return $this->mfaRequired; } |
| 658 | public function getMfaUserId(): ?int { return $this->mfaUserId; } |
| 659 | public function isRateLimited(): bool { return $this->rateLimited; } |
| 660 | public function isLocked(): bool { return $this->locked; } |
| 661 | public function getRetryAfter(): int { return $this->retryAfter; } |
| 662 | |
| 663 | /** |
| 664 | * Convert result to array for JSON response |
| 665 | */ |
| 666 | public function toArray(): array |
| 667 | { |
| 668 | if ($this->success) { |
| 669 | return ['success' => true] + $this->data; |
| 670 | } |
| 671 | |
| 672 | $result = [ |
| 673 | 'success' => false, |
| 674 | 'error' => $this->error |
| 675 | ]; |
| 676 | |
| 677 | if ($this->mfaRequired) { |
| 678 | $result['mfa_required'] = true; |
| 679 | } |
| 680 | |
| 681 | if ($this->rateLimited || $this->locked) { |
| 682 | $result['retry_after'] = $this->retryAfter; |
| 683 | } |
| 684 | |
| 685 | if ($this->locked) { |
| 686 | $result['account_locked'] = true; |
| 687 | } |
| 688 | |
| 689 | return $result; |
| 690 | } |
| 691 | } |