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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user