*/ use HasFactory, Notifiable; protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_secret' => 'encrypted', 'two_factor_recovery_codes' => 'encrypted:array', 'two_factor_confirmed_at' => 'datetime', 'must_change_password' => 'boolean', ]; } /** @return array The 8 freshly generated one-time codes (also persisted). */ public function replaceRecoveryCodes(): array { $codes = array_map( fn () => Str::random(10).'-'.Str::random(10), range(1, 8), ); $this->forceFill(['two_factor_recovery_codes' => $codes])->save(); return $codes; } /** @return array */ public function recoveryCodes(): array { return $this->two_factor_recovery_codes ?? []; } public function hasRecoveryCodes(): bool { return count($this->recoveryCodes()) > 0; } /** * Consume one recovery code (one-time). Returns true if it was valid + removed. * Atomic: the read/check/remove runs under a row lock so two concurrent requests * can never both spend the same code (replay). */ public function useRecoveryCode(string $code): bool { $code = trim($code); return DB::transaction(function () use ($code) { $locked = static::whereKey($this->getKey())->lockForUpdate()->first(); $codes = $locked?->recoveryCodes() ?? []; if (! in_array($code, $codes, true)) { return false; } $remaining = array_values(array_filter($codes, fn ($c) => $c !== $code)); $locked->forceFill(['two_factor_recovery_codes' => $remaining])->save(); // Keep the in-memory instance consistent for the rest of the request. $this->setRawAttributes($locked->getAttributes(), true); return true; }); } /** TOTP factor only: an authenticator secret was confirmed. */ public function hasTotp(): bool { return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); } /** * Verify a TOTP code against the stored secret. Returns false (never throws) when the * user has no TOTP or the stored secret is malformed — callers then fall back to a * backup code. */ public function verifyTotp(string $code): bool { if (! $this->hasTotp()) { return false; } try { return (new Google2FA)->verifyKey($this->two_factor_secret, preg_replace('/\s+/', '', $code) ?? ''); } catch (\Throwable) { return false; } } /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */ public function hasTwoFactorEnabled(): bool { return $this->hasTotp() || $this->hasWebauthnCredentials(); } /** * Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced * password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use. */ public function securityOnboarded(): bool { return ! $this->must_change_password; } /** * When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup * codes (nothing left to recover into). Call after removing any factor. */ public function resetIfNoFactor(): void { if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) { $this->forceFill(['two_factor_recovery_codes' => null])->save(); } } public function webauthnCredentials(): HasMany { return $this->hasMany(WebauthnCredential::class); } public function hasWebauthnCredentials(): bool { return $this->webauthnCredentials()->exists(); } }