Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
114 / 117
92.31% covered (success)
92.31%
24 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Auth
97.44% covered (success)
97.44%
114 / 117
92.31% covered (success)
92.31%
24 / 26
68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 userExists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setMfaConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMfaConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAttemptsLimit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAttemptsLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAttemptsLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLockoutExpiration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLockoutExpiration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLockoutExpiration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lockoutExpired
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 attemptsExceeded
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 userActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 userVerified
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 resolveMfa
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 resetAttempts
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 recordFailedAttempt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
8
 authenticateMfa
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
 generateMfaCode
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 wasMfaCodeGenerated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyMfaCode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 hasAuthFailure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthFailure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthFailureMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Record;
16
17use Pop\Utils\Str;
18
19/**
20 * User authentication record class
21 *
22 * @category   Pop
23 * @package    Pop\Db
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    7.0.0
28 */
29class Auth extends Encoded
30{
31
32    /**
33     * Auth constants
34     */
35    const string USER_DOES_NOT_EXIST = 'USER_DOES_NOT_EXIST';
36    const string USER_NOT_ACTIVE     = 'USER_NOT_ACTIVE';
37    const string USER_NOT_VERIFIED   = 'USER_NOT_VERIFIED';
38    const string ATTEMPTS_EXCEEDED   = 'ATTEMPTS_EXCEEDED';
39    const string INVALID_CREDENTIALS = 'INVALID_CREDENTIALS';
40    const string INVALID_MFA_CODE    = 'INVALID_MFA_CODE';
41    const string MFA_CODE_EXPIRED    = 'MFA_CODE_EXPIRED';
42
43    /**
44     * Username field
45     * @var string
46     */
47    protected string $usernameField = 'username';
48
49    /**
50     * Password field
51     * @var string
52     */
53    protected string $passwordField = 'password';
54
55    /**
56     * Attempts field
57     * @var string
58     */
59    protected string $attemptsField = 'attempts';
60
61    /**
62     * Active field
63     * @var ?string
64     */
65    protected ?string $activeField = 'active';
66
67    /**
68     * Verified field
69     * @var ?string
70     */
71    protected ?string $verifiedField = 'verified';
72
73    /**
74     * Last attempt timestamp field
75     * @var ?string
76     */
77    protected ?string $lastAttemptField = 'last_attempt';
78
79    /**
80     * MFA flag field
81     * @var ?string
82     */
83    protected ?string $mfaField = 'mfa';
84
85    /**
86     * Attempts limit - set to zero to skip attempts enforcement
87     * @var int
88     */
89    protected int $attemptsLimit = 3;
90
91    /**
92     * Lockout expiration (in seconds) - set to zero to never expire (admin would have to manually reset the attempts)
93     * @var int
94     */
95    protected int $lockoutExpiration = 900; // 15 minute default
96
97    /**
98     * MFA config
99     * @var array
100     */
101    protected array $mfaConfig = [
102        'length'              => 6,              // Code length
103        'expires'             => 300,            // Seconds
104        'alphanumeric'        => false,          // Numeric by default, can be alphanumeric
105        'mfa_code_field'      => 'mfa_code',     // varchar database column, nullable
106        'mfa_timestamp_field' => 'mfa_timestamp' // integer database column, nullable
107    ];
108
109    /**
110     * Auth failure
111     * @var ?string
112     */
113    protected ?string $authFailure = null;
114
115    /**
116     * Flag if MFA code was generated
117     * @var bool
118     */
119    protected bool $mfaCodeGenerated = false;
120
121    /**
122     * Auth failure messages
123     * @var array
124     */
125    protected array $authFailureMessages = [
126        self::USER_DOES_NOT_EXIST => 'The user does not exist',
127        self::USER_NOT_ACTIVE     => 'The user is not active',
128        self::USER_NOT_VERIFIED   => 'The user is not verified',
129        self::ATTEMPTS_EXCEEDED   => 'The authentication attempts have been exceeded',
130        self::INVALID_CREDENTIALS => 'Invalid credentials',
131        self::INVALID_MFA_CODE    => 'Invalid MFA code',
132        self::MFA_CODE_EXPIRED    => 'MFA code has expired',
133    ];
134
135    /**
136     * Constructor
137     *
138     * Instantiate the Auth record object and ensure the password field is always hashed,
139     * regardless of whether the child class remembers to declare it in $hashFields
140     *
141     * @param  mixed ...$args
142     */
143    public function __construct(mixed ...$args)
144    {
145        if (!in_array($this->passwordField, $this->hashFields, true)) {
146            $this->hashFields[] = $this->passwordField;
147        }
148
149        parent::__construct(...$args);
150    }
151
152    /**
153     * Does user exist
154     *
155     * @param  ?string $attemptedUsername
156     * @return bool
157     */
158    public function userExists(?string $attemptedUsername = null): bool
159    {
160        // Check an attempted username value directly
161        if ($attemptedUsername !== null) {
162            return isset(static::findOne([$this->usernameField => $attemptedUsername])->id);
163        // Else, check if this instance is loaded with a valid user
164        } else {
165            return isset($this->{$this->usernameField});
166        }
167    }
168
169    /**
170     * Set MFA config
171     *
172     * @param  array $mfaConfig
173     * @return static
174     */
175    public function setMfaConfig(array $mfaConfig): static
176    {
177        $this->mfaConfig = array_merge($this->mfaConfig, array_intersect_key($mfaConfig, $this->mfaConfig));
178        return $this;
179    }
180
181    /**
182     * Get MFA config
183     *
184     * @return array
185     */
186    public function getMfaConfig(): array
187    {
188        return $this->mfaConfig;
189    }
190
191    /**
192     * Set attempts limit
193     *
194     * @param  int $attemptsLimit
195     * @return static
196     */
197    public function setAttemptsLimit(int $attemptsLimit): static
198    {
199        $this->attemptsLimit = $attemptsLimit;
200        return $this;
201    }
202
203    /**
204     * Get attempts limit
205     *
206     * @return int
207     */
208    public function getAttemptsLimit(): int
209    {
210        return $this->attemptsLimit;
211    }
212
213    /**
214     * Has attempts limit
215     *
216     * @return bool
217     */
218    public function hasAttemptsLimit(): bool
219    {
220        return ($this->attemptsLimit > 0);
221    }
222
223    /**
224     * Set lockout expiration
225     *
226     * @param  int $lockoutExpiration
227     * @return static
228     */
229    public function setLockoutExpiration(int $lockoutExpiration): static
230    {
231        $this->lockoutExpiration = $lockoutExpiration;
232        return $this;
233    }
234
235    /**
236     * Get lockout expiration
237     *
238     * @return int
239     */
240    public function getLockoutExpiration(): int
241    {
242        return $this->lockoutExpiration;
243    }
244
245    /**
246     * Has lockout expiration
247     *
248     * @return bool
249     */
250    public function hasLockoutExpiration(): bool
251    {
252        return ($this->lockoutExpiration > 0);
253    }
254
255    /**
256     * Lockout has expired
257     *
258     * True once $lockoutExpiration seconds have passed since $lastAttemptField was last set -
259     * always false if lockout expiration or $lastAttemptField tracking is disabled, in which
260     * case an exceeded lockout can only be cleared with an explicit resetAttempts() call
261     *
262     * @return bool
263     */
264    public function lockoutExpired(): bool
265    {
266        return (
267            $this->hasLockoutExpiration() && !empty($this->lastAttemptField) && $this->userExists() &&
268            isset($this->{$this->lastAttemptField}) &&
269            (time() >= ((int)$this->{$this->lastAttemptField} + $this->lockoutExpiration))
270        );
271    }
272
273    /**
274     * Attempts exceeded
275     *
276     * Auto-clears (resets attempts, returns false) once the lockout has expired
277     *
278     * @return bool
279     */
280    public function attemptsExceeded(): bool
281    {
282        if ((!$this->userExists()) || (!$this->hasAttemptsLimit()) || ((int)$this->{$this->attemptsField} < $this->attemptsLimit)) {
283            return false;
284        }
285
286        if ($this->lockoutExpired()) {
287            $this->resetAttempts();
288            return false;
289        }
290
291        return true;
292    }
293
294    /**
295     * Is user active
296     *
297     * @return bool
298     */
299    public function userActive(): bool
300    {
301        return (empty($this->activeField) || (($this->userExists()) && ($this->{$this->activeField})));
302    }
303
304    /**
305     * Is user verified
306     *
307     * @return bool
308     */
309    public function userVerified(): bool
310    {
311        return (empty($this->verifiedField) || (($this->userExists()) && ($this->{$this->verifiedField})));
312    }
313
314    /**
315     * Resolve whether MFA should be enforced for this authentication attempt
316     *
317     * $mfaCapable is a hard veto: if the calling context cannot perform an MFA challenge at
318     * all (e.g. a console command with no way to prompt for or deliver a code), MFA is always
319     * skipped, regardless of $mfa or $mfaField - there is no per-user override for capability
320     *
321     * Otherwise, $mfa is the enforcement default, and if $mfaField is configured and actually
322     * set on the user record, it overrides $mfa in either direction; if not (no field
323     * configured, or the column is null/unset - e.g. a not-yet-migrated row), $mfa is returned
324     * untouched, so a missing value can never silently disable MFA
325     *
326     * @param  bool $mfa
327     * @param  bool $mfaCapable
328     * @return bool
329     */
330    protected function resolveMfa(bool $mfa, bool $mfaCapable): bool
331    {
332        if (!$mfaCapable) {
333            return false;
334        }
335
336        return (!empty($this->mfaField) && isset($this->{$this->mfaField})) ?
337            (bool)$this->{$this->mfaField} : $mfa;
338    }
339
340    /**
341     * Reset attempts
342     *
343     * @return static
344     */
345    public function resetAttempts(): static
346    {
347        if (($this->userExists()) && ((int)$this->{$this->attemptsField} !== 0)) {
348            $this->reset($this->attemptsField, 0);
349        }
350
351        return $this;
352    }
353
354    /**
355     * Record a failed (guess-type) attempt: increment the attempts field and, if
356     * $lastAttemptField is configured, stamp it with now() to (re)anchor the lockout clock
357     *
358     * Only called for actual credential/code guesses - not for attempts already blocked by
359     * attemptsExceeded(), so repeatedly hitting an already-locked-out account cannot keep
360     * pushing the lockout expiration further into the future
361     *
362     * @return void
363     */
364    protected function recordFailedAttempt(): void
365    {
366        $this->{$this->attemptsField} = (int)$this->{$this->attemptsField} + 1;
367        if (!empty($this->lastAttemptField)) {
368            $this->{$this->lastAttemptField} = time();
369        }
370        $this->save();
371    }
372
373    /**
374     * Get user
375     *
376     * @param  string $attemptedUsername
377     * @return static
378     */
379    public function getUser(string $attemptedUsername): static
380    {
381        return $this->getOne([$this->usernameField => $attemptedUsername]);
382    }
383
384    /**
385     * Authenticate user attempt
386     *
387     *   - If auth is unsuccessful:
388     *       -> If the user was not found, not active, or not verified, it returns false without
389     *          incrementing the attempts field - these are hard blocks, not guess failures
390     *       -> If the attempts have been exceeded, or the credentials themselves were wrong,
391     *          the attempts field is incremented, then it returns false - unless the lockout
392     *          has since expired (see $lockoutExpiration), in which case attempts are reset
393     *          first and the check falls through to the credentials themselves
394     *   - Else, if auth is successful:
395     *       -> If the stored password hash was made with an outdated algorithm/cost, it is
396     *          transparently rehashed and saved using the just-verified plaintext password
397     *       -> If $mfaCapable is false, MFA is always skipped - this is a hard veto for
398     *          calling contexts that cannot perform an MFA challenge (e.g. a console command),
399     *          and is not overridable via $mfaField
400     *       -> Otherwise, if $mfaField is configured and set on the user record, it overrides
401     *          $mfa in either direction (see resolveMfa()) - otherwise $mfa is used as passed in
402     *       -> If MFA applies, the user record is updated with a fresh MFA code and timestamp
403     *          from which the calling app can deploy the MFA notification
404     *       -> The user record is then returned
405     *
406     * @param  string $attemptedUsername
407     * @param  string $attemptedPassword
408     * @param  bool   $mfa               default, overridable per-user via $mfaField
409     * @param  bool   $mfaCapable        set false when the caller cannot perform an MFA
410     *                                   challenge at all (e.g. console) - not overridable
411     * @param  ?int   $attemptsLimit
412     * @return bool|static
413     */
414    public function authenticate(
415        string $attemptedUsername, string $attemptedPassword, bool $mfa = true, bool $mfaCapable = true,
416        ?int $attemptsLimit = null
417    ): bool|static
418    {
419        if ($attemptsLimit !== null) {
420            $this->setAttemptsLimit($attemptsLimit);
421        }
422
423        $this->getUser($attemptedUsername);
424
425        // If user doesn't exist
426        if (!$this->userExists()) {
427            $this->authFailure = self::USER_DOES_NOT_EXIST;
428            return false;
429        // If user is not active
430        } else if ((!$this->userActive())) {
431            $this->authFailure = self::USER_NOT_ACTIVE;
432            return false;
433        // If user is not verified
434        } else if ((!$this->userVerified())) {
435            $this->authFailure = self::USER_NOT_VERIFIED;
436            return false;
437        // If attempts exceeded
438        } else if (($this->attemptsExceeded())) {
439            $this->authFailure = self::ATTEMPTS_EXCEEDED;
440            $this->increment($this->attemptsField);
441            return false;
442        // If auth fails
443        } else if (!$this->verify($this->passwordField, $attemptedPassword)) {
444            $this->authFailure = self::INVALID_CREDENTIALS;
445            $this->recordFailedAttempt();
446            return false;
447        } else {
448            // Upon success, reset attempts field (the password hash, if outdated, was
449            // already transparently rehashed by verify() above)
450            $this->resetAttempts();
451            $this->authFailure = null;
452            $mfa               = $this->resolveMfa($mfa, $mfaCapable);
453
454            // If not MFA, return true
455            if (!$mfa) {
456                return true;
457            } else {
458                $this->generateMfaCode();
459                return $this;
460            }
461        }
462    }
463
464    /**
465     * Authenticate MFA code
466     *
467     *   - The user records needs to be pre-fetched and loaded into this instance
468     *   - Not active/not verified are rechecked here too, same as authenticate() - a hard
469     *     block, so neither increments the attempts field
470     *   - Wrong or expired code guesses count against the same $attemptsField/$attemptsLimit
471     *     as login attempts, so a locked-out user is also locked out of MFA guessing
472     *   - A lockout that has since expired (see $lockoutExpiration) is auto-cleared here too,
473     *     the same as in authenticate()
474     *   - On success, the stored code and timestamp are cleared so the code cannot be reused
475     *
476     * @param  string $mfaCode
477     * @return bool
478     */
479    public function authenticateMfa(string $mfaCode): bool
480    {
481        if (!$this->userExists()) {
482            $this->authFailure = self::USER_DOES_NOT_EXIST;
483        } else if (!$this->userActive()) {
484            $this->authFailure = self::USER_NOT_ACTIVE;
485        } else if (!$this->userVerified()) {
486            $this->authFailure = self::USER_NOT_VERIFIED;
487        } else if ($this->attemptsExceeded()) {
488            $this->authFailure = self::ATTEMPTS_EXCEEDED;
489            $this->increment($this->attemptsField);
490        } else if (!$this->verifyMfaCode($mfaCode)) {
491            $this->authFailure = self::INVALID_MFA_CODE;
492            $this->recordFailedAttempt();
493        } else if (time() > (int)$this->{$this->mfaConfig['mfa_timestamp_field']}) {
494            $this->authFailure = self::MFA_CODE_EXPIRED;
495            $this->recordFailedAttempt();
496        } else {
497            $this->authFailure = null;
498            $this->{$this->attemptsField}                    = 0;
499            $this->{$this->mfaConfig['mfa_code_field']}      = null;
500            $this->{$this->mfaConfig['mfa_timestamp_field']} = null;
501            $this->save();
502        }
503
504        return (!$this->hasAuthFailure());
505    }
506
507    /**
508     * Generate (or regenerate/resend) an MFA code, expiration timestamp, and persist them
509     *
510     * No-ops on an unloaded, not active, or not verified user, or once attempts have been
511     * exceeded - a locked-out account cannot be handed a fresh, usable code via resend; it
512     * must go through resetAttempts() first
513     *
514     * @return static
515     */
516    public function generateMfaCode(): static
517    {
518        if (!$this->userExists()) {
519            $this->mfaCodeGenerated = false;
520            $this->authFailure      = self::USER_DOES_NOT_EXIST;
521        } else if (!$this->userActive()) {
522            $this->mfaCodeGenerated = false;
523            $this->authFailure      = self::USER_NOT_ACTIVE;
524        } else if (!$this->userVerified()) {
525            $this->mfaCodeGenerated = false;
526            $this->authFailure      = self::USER_NOT_VERIFIED;
527        } else if ($this->attemptsExceeded()) {
528            $this->mfaCodeGenerated = false;
529            $this->authFailure      = self::ATTEMPTS_EXCEEDED;
530        } else {
531            $this->{$this->mfaConfig['mfa_timestamp_field']} = time() + $this->mfaConfig['expires'];
532            $this->{$this->mfaConfig['mfa_code_field']}      = ($this->mfaConfig['alphanumeric']) ?
533                Str::createRandomAlphaNum($this->mfaConfig['length'], Str::UPPERCASE) :
534                Str::createRandomNumeric($this->mfaConfig['length']);
535
536            $this->save();
537
538            $this->authFailure      = null;
539            $this->mfaCodeGenerated = true;
540        }
541
542        return $this;
543    }
544
545    /**
546     * Get MFA code generated flag
547     *
548     * @return bool
549     */
550    public function wasMfaCodeGenerated(): bool
551    {
552        return $this->mfaCodeGenerated;
553    }
554
555    /**
556     * Verify the attempted MFA code against the stored code using a timing-safe comparison
557     *
558     * @param  string $attemptedCode
559     * @return bool
560     */
561    protected function verifyMfaCode(string $attemptedCode): bool
562    {
563        $storedCode = $this->{$this->mfaConfig['mfa_code_field']};
564        return (is_string($storedCode) && ($storedCode !== '') && hash_equals($storedCode, $attemptedCode));
565    }
566
567
568    /**
569     * Has auth failure
570     *
571     * @return bool
572     */
573    public function hasAuthFailure(): bool
574    {
575        return ($this->authFailure !== null);
576    }
577
578    /**
579     * Get auth failure
580     *
581     * @return ?string
582     */
583    public function getAuthFailure(): ?string
584    {
585        return $this->authFailure;
586    }
587
588    /**
589     * Get auth failure message
590     *
591     * @return ?string
592     */
593    public function getAuthFailureMessage(): ?string
594    {
595        return ($this->hasAuthFailure() && array_key_exists($this->authFailure, $this->authFailureMessages)) ?
596            $this->authFailureMessages[$this->authFailure] : null;
597    }
598
599}