Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.40% covered (success)
95.40%
228 / 239
87.10% covered (warning)
87.10%
27 / 31
CRAP
50.00% covered (danger)
50.00%
1 / 2
AuthService
94.27% covered (success)
94.27%
181 / 192
75.00% covered (warning)
75.00%
12 / 16
52.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 login
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
13
 logout
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 refreshToken
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 verifyPassword
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
8.32
 hashPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 upgradePasswordHash
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 verifyMfaCode
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 verifyBackupCode
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 loadUserByUsernameOrEmail
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 loadUserById
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getUserStores
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getUserPermissions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 recordFailedAttempt
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 resetFailedAttempts
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateLastLogin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
AuthResult
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
15 / 15
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 success
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 failed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 mfaRequired
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 rateLimited
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 locked
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isSuccess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMfaRequired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMfaUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRateLimited
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLocked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRetryAfter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
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
12namespace BuyerKiosk\Auth\Services;
13
14class 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 */
550class 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}