feat(security): two-factor authentication (TOTP)

Hesap güvenliği için authenticator app (Google Authenticator, 1Password,
Authy etc.) based TOTP. SMS yok — sadece app-based per user request.

Enroll flow (/settings/security)
  - startMfaEnrollAction → account.createMFAAuthenticator('totp'),
    returns otpauth URI + plain secret as backup.
  - MfaPanel client island: starts the flow, shows the QR (rendered via
    api.qrserver.com for zero deps) plus the secret as text. Picks the
    6-digit code → verifyMfaEnrollAction calls
    updateMFAAuthenticator(totp, otp) + updateMFA(true) +
    createMFARecoveryCodes(). The recovery codes are surfaced once on
    success with a 'save these now' warning.
  - disableMfaAction + regenerateRecoveryCodesAction give the same
    panel a disable + 'yeni yedek kodlar' option once MFA is active.
  - settings-nav now has 'Güvenlik' between 'Görünüm' and 'Hesap
    Aktivitesi'.

Sign-in flow
  - signInAction:
      1. createEmailPasswordSession (sets cookie as before)
      2. users.get(userId).mfa? If yes:
         a. otp empty → return { mfaRequired: true, error }
         b. otp present → createMfaChallenge({factor: totp}) +
            updateMfaChallenge(challengeId, otp). Failure tears the
            partial session down and bounces back with mfaRequired.
  - AuthState gained an mfaRequired field. The login form watches it
    and reveals an autofocused 6-digit OTP input on the next render.
    User types the code, submits the form again, the same action
    finishes the challenge and redirects.

Existing accounts without MFA are unaffected — they never hit the
challenge branch.
This commit is contained in:
kovakmedya
2026-05-22 16:25:26 +03:00
parent 424a323952
commit 3e15d9f937
7 changed files with 437 additions and 1 deletions
@@ -88,6 +88,24 @@ export function LoginForm1({
/>
</div>
{state.mfaRequired && (
<div className="grid gap-2">
<Label htmlFor="otp">Authenticator kodu</Label>
<Input
id="otp"
name="otp"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
autoComplete="one-time-code"
autoFocus
placeholder="000000"
className="font-mono text-lg tracking-widest"
required
/>
</div>
)}
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}