Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
Auth
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
11 / 11
29
100.00% covered (success)
100.00%
1 / 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
 attemptsExceeded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 resetAttempts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 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%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 authenticateMfa
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 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 INVALID_CREDENTIALS = 'INVALID_CREDENTIALS';
37    const string ATTEMPTS_EXCEEDED   = 'ATTEMPTS_EXCEEDED';
38    const string INVALID_MFA_CODE    = 'INVALID_MFA_CODE';
39    const string MFA_CODE_EXPIRED    = 'MFA_CODE_EXPIRED';
40
41    /**
42     * Username field
43     * @var string
44     */
45    protected string $usernameField = 'username';
46
47    /**
48     * Password field
49     * @var string
50     */
51    protected string $passwordField = 'password';
52
53    /**
54     * Attempts field
55     * @var string
56     */
57    protected string $attemptsField = 'attempts';
58
59    /**
60     * Attempts limit
61     * @var int
62     */
63    protected int $attemptsLimit = 3;
64
65    /**
66     * MFA config
67     * @var array
68     */
69    protected array $mfaConfig = [
70        'length'              => 6,              // Code length
71        'expires'             => 300,            // Seconds
72        'alphanumeric'        => false,          // Numeric by default, can be alphanumeric
73        'mfa_code_field'      => 'mfa_code',     // varchar database column, nullable
74        'mfa_timestamp_field' => 'mfa_timestamp' // integer database column, nullable
75    ];
76
77    /**
78     * Auth failure
79     * @var ?string
80     */
81    protected ?string $authFailure = null;
82
83    /**
84     * Auth failure messages
85     * @var array
86     */
87    protected array $authFailureMessages = [
88        self::USER_DOES_NOT_EXIST => 'The user does not exist',
89        self::INVALID_CREDENTIALS => 'Invalid credentials',
90        self::ATTEMPTS_EXCEEDED   => 'The authentication attempts have been exceeded',
91        self::INVALID_MFA_CODE    => 'Invalid MFA code',
92        self::MFA_CODE_EXPIRED    => 'MFA code has expired',
93    ];
94
95    /**
96     * Constructor
97     *
98     * Instantiate the Auth record object and ensure the password field is always hashed,
99     * regardless of whether the child class remembers to declare it in $hashFields
100     *
101     * @param  mixed ...$args
102     */
103    public function __construct(mixed ...$args)
104    {
105        if (!in_array($this->passwordField, $this->hashFields, true)) {
106            $this->hashFields[] = $this->passwordField;
107        }
108
109        parent::__construct(...$args);
110    }
111
112    /**
113     * Does user exist
114     *
115     * @param  ?string $attemptedUsername
116     * @return bool
117     */
118    public function userExists(?string $attemptedUsername = null): bool
119    {
120        // Check an attempted username value directly
121        if ($attemptedUsername !== null) {
122            return isset(static::findOne([$this->usernameField => $attemptedUsername])->id);
123        // Else, check if this instance is loaded with a valid user
124        } else {
125            return isset($this->{$this->usernameField});
126        }
127    }
128
129    /**
130     * Attempts exceeded
131     *
132     * @return bool
133     */
134    public function attemptsExceeded(): bool
135    {
136        return (($this->userExists()) && ((int)$this->{$this->attemptsField} >= $this->attemptsLimit));
137    }
138
139    /**
140     * Reset attempts
141     *
142     * @return void
143     */
144    public function resetAttempts(): void
145    {
146        if (($this->userExists()) && ((int)$this->{$this->attemptsField} !== 0)) {
147            $this->reset($this->attemptsField, 0);
148        }
149    }
150
151    /**
152     * Get user
153     *
154     * @param  string $attemptedUsername
155     * @return static
156     */
157    public function getUser(string $attemptedUsername): static
158    {
159        return $this->getOne([$this->usernameField => $attemptedUsername]);
160    }
161
162    /**
163     * Authenticate user attempt
164     *
165     *   - If auth is unsuccessful:
166     *       -> If the user was not found, it returns false
167     *       -> If user was found, but auth did not pass or the attempts have been exceeded,
168     *          the attempts field is incremented, then it returns false
169     *   - Else, if auth is successful:
170     *       -> If the stored password hash was made with an outdated algorithm/cost, it is
171     *          transparently rehashed and saved using the just-verified plaintext password
172     *       -> If $mfa = true, the user record is updated with a fresh MFA code and timestamp
173     *          from which the calling app can deploy the MFA notification
174     *       -> The user record is then returned
175     *
176     * @param  string $attemptedUsername
177     * @param  string $attemptedPassword
178     * @param  bool   $mfa
179     * @return bool|static
180     */
181    public function authenticate(string $attemptedUsername, string $attemptedPassword, bool $mfa = true): bool|static
182    {
183        $this->getUser($attemptedUsername);
184
185        // If user doesn't exist
186        if (!$this->userExists()) {
187            $this->authFailure = self::USER_DOES_NOT_EXIST;
188            return false;
189        // If attempts exceeded
190        } else if (($this->attemptsExceeded())) {
191            $this->authFailure = self::ATTEMPTS_EXCEEDED;
192            $this->increment($this->attemptsField);
193            return false;
194        // If auth fails
195        } else if (!$this->verify($this->passwordField, $attemptedPassword)) {
196            $this->authFailure = self::INVALID_CREDENTIALS;
197            $this->increment($this->attemptsField);
198            return false;
199        } else {
200            // Upon success, reset attempts field (the password hash, if outdated, was
201            // already transparently rehashed by verify() above)
202            $this->resetAttempts();
203            $this->authFailure = null;
204
205            // If not MFA, return true
206            if (!$mfa) {
207                return true;
208            } else {
209                $this->{$this->mfaConfig['mfa_timestamp_field']} = time() + $this->mfaConfig['expires'];
210                $this->{$this->mfaConfig['mfa_code_field']}      = ($this->mfaConfig['alphanumeric']) ?
211                    Str::createRandomAlphaNum($this->mfaConfig['length'], Str::UPPERCASE) :
212                    Str::createRandomNumeric($this->mfaConfig['length']);
213
214                $this->save();
215
216                return $this;
217            }
218        }
219    }
220
221    /**
222     * Authenticate MFA code
223     *
224     *   - The user records needs to be pre-fetched and loaded into this instance
225     *   - Wrong or expired code guesses count against the same $attemptsField/$attemptsLimit
226     *     as login attempts, so a locked-out user is also locked out of MFA guessing
227     *   - On success, the stored code and timestamp are cleared so the code cannot be reused
228     *
229     * @param  string $mfaCode
230     * @return bool
231     */
232    public function authenticateMfa(string $mfaCode): bool
233    {
234        if (!$this->userExists()) {
235            $this->authFailure = self::USER_DOES_NOT_EXIST;
236        } else if ($this->attemptsExceeded()) {
237            $this->authFailure = self::ATTEMPTS_EXCEEDED;
238            $this->increment($this->attemptsField);
239        } else if (!$this->verifyMfaCode($mfaCode)) {
240            $this->authFailure = self::INVALID_MFA_CODE;
241            $this->increment($this->attemptsField);
242        } else if (time() > (int)$this->{$this->mfaConfig['mfa_timestamp_field']}) {
243            $this->authFailure = self::MFA_CODE_EXPIRED;
244            $this->increment($this->attemptsField);
245        } else {
246            $this->authFailure = null;
247            $this->{$this->attemptsField}                    = 0;
248            $this->{$this->mfaConfig['mfa_code_field']}      = null;
249            $this->{$this->mfaConfig['mfa_timestamp_field']} = null;
250            $this->save();
251        }
252
253        return (!$this->hasAuthFailure());
254    }
255
256    /**
257     * Verify the attempted MFA code against the stored code using a timing-safe comparison
258     *
259     * @param  string $attemptedCode
260     * @return bool
261     */
262    protected function verifyMfaCode(string $attemptedCode): bool
263    {
264        $storedCode = $this->{$this->mfaConfig['mfa_code_field']};
265        return (is_string($storedCode) && ($storedCode !== '') && hash_equals($storedCode, $attemptedCode));
266    }
267
268
269    /**
270     * Has auth failure
271     *
272     * @return bool
273     */
274    public function hasAuthFailure(): bool
275    {
276        return ($this->authFailure !== null);
277    }
278
279    /**
280     * Get auth failure
281     *
282     * @return ?string
283     */
284    public function getAuthFailure(): ?string
285    {
286        return $this->authFailure;
287    }
288
289    /**
290     * Get auth failure message
291     *
292     * @return ?string
293     */
294    public function getAuthFailureMessage(): ?string
295    {
296        return ($this->hasAuthFailure() && array_key_exists($this->authFailure, $this->authFailureMessages)) ?
297            $this->authFailureMessages[$this->authFailure] : null;
298    }
299
300}