Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.44% |
114 / 117 |
|
92.31% |
24 / 26 |
CRAP | |
0.00% |
0 / 1 |
| Auth | |
97.44% |
114 / 117 |
|
92.31% |
24 / 26 |
68 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| userExists | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| setMfaConfig | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMfaConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setAttemptsLimit | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getAttemptsLimit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasAttemptsLimit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setLockoutExpiration | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getLockoutExpiration | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasLockoutExpiration | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| lockoutExpired | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| attemptsExceeded | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| userActive | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
3 | |||
| userVerified | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
3 | |||
| resolveMfa | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| resetAttempts | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| recordFailedAttempt | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| getUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| authenticate | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
8 | |||
| authenticateMfa | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
7 | |||
| generateMfaCode | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| wasMfaCodeGenerated | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| verifyMfaCode | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| hasAuthFailure | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAuthFailure | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAuthFailureMessage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace Pop\Db\Record; |
| 16 | |
| 17 | use 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 | */ |
| 29 | class 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 | } |