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
+47 -1
View File
@@ -2,7 +2,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AppwriteException, ID, Query } from "node-appwrite";
import { AppwriteException, AuthenticationFactor, ID, Query } from "node-appwrite";
import { APPWRITE_SESSION_COOKIE, createAdminClient, createSessionClient } from "./server";
import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema";
@@ -85,6 +85,7 @@ async function resolveTenantOnLogin(
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const otp = String(formData.get("otp") ?? "").trim();
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
const rawKind = String(formData.get("kind") ?? "").trim();
const kind: TenantKind | null =
@@ -107,6 +108,51 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis
return { ok: false, error: appwriteError(e) };
}
// MFA: if the user has TOTP enabled, the session above is half-confirmed.
// Either pass the OTP they typed in this submission or ask for it.
try {
const { users } = createAdminClient();
const user = await users.get({ userId: sessionUserId });
if (user.mfa) {
if (!otp) {
return {
ok: false,
mfaRequired: true,
error: "Hesabınız 2FA korumalı. Authenticator uygulamasındaki 6 haneli kodu girin.",
};
}
try {
const { account: sessionAccount } = await createSessionClient();
const challenge = await sessionAccount.createMfaChallenge({
factor: AuthenticationFactor.Totp,
});
await sessionAccount.updateMfaChallenge({
challengeId: challenge.$id,
otp,
});
} catch (e) {
// Wrong code or expired challenge — kill the partial session and ask
// them to start over with the OTP visible.
try {
if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId });
} catch {
/* best-effort */
}
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
return {
ok: false,
mfaRequired: true,
error: "Kod doğrulanamadı, yeniden deneyin.",
};
}
}
} catch (e) {
console.error("[signInAction] MFA check", e);
// Fail-open on MFA check errors only when the user has no MFA configured;
// for safety, surface a generic error here.
return { ok: false, error: "Oturum doğrulanamadı." };
}
// Invite flow short-circuits the kind check — invite code drives team membership
if (inviteCode) {
redirect(`/d/${inviteCode}`);