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:
@@ -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}
|
||||
|
||||
@@ -11,6 +11,7 @@ const ITEMS: { href: string; label: string }[] = [
|
||||
{ href: "/settings/members", label: "Üyeler" },
|
||||
{ href: "/settings/notifications", label: "Bildirimler" },
|
||||
{ href: "/settings/appearance", label: "Görünüm" },
|
||||
{ href: "/settings/security", label: "Güvenlik" },
|
||||
{ href: "/settings/activity", label: "Hesap Aktivitesi" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, KeyRound, Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
disableMfaAction,
|
||||
initialMfaActionState,
|
||||
regenerateRecoveryCodesAction,
|
||||
startMfaEnrollAction,
|
||||
verifyMfaEnrollAction,
|
||||
} from "@/lib/appwrite/mfa-actions";
|
||||
|
||||
type EnrollStage =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "verify"; uri: string; secret: string }
|
||||
| { kind: "done"; recoveryCodes: string[] };
|
||||
|
||||
export function MfaPanel({ initiallyEnabled }: { initiallyEnabled: boolean }) {
|
||||
const router = useRouter();
|
||||
const [enabled, setEnabled] = useState(initiallyEnabled);
|
||||
const [stage, setStage] = useState<EnrollStage>({ kind: "idle" });
|
||||
const [verifyState, verifyAction, verifying] = useActionState(
|
||||
verifyMfaEnrollAction,
|
||||
initialMfaActionState,
|
||||
);
|
||||
const [busy, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyState.ok && verifyState.recoveryCodes) {
|
||||
setEnabled(true);
|
||||
setStage({ kind: "done", recoveryCodes: verifyState.recoveryCodes });
|
||||
toast.success("2FA etkinleştirildi.");
|
||||
router.refresh();
|
||||
} else if (verifyState.error) {
|
||||
toast.error(verifyState.error);
|
||||
}
|
||||
}, [verifyState, router]);
|
||||
|
||||
function beginEnroll() {
|
||||
setStage({ kind: "loading" });
|
||||
startTransition(async () => {
|
||||
const res = await startMfaEnrollAction();
|
||||
if (res.ok && res.uri && res.secret) {
|
||||
setStage({ kind: "verify", uri: res.uri, secret: res.secret });
|
||||
} else {
|
||||
toast.error(res.error ?? "Başlatılamadı.");
|
||||
setStage({ kind: "idle" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDisable() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"2FA devre dışı bırakılsın mı? Hesabınız sadece şifre ile korunacak.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
startTransition(async () => {
|
||||
const res = await disableMfaAction();
|
||||
if (res.ok) {
|
||||
setEnabled(false);
|
||||
setStage({ kind: "idle" });
|
||||
toast.success("2FA devre dışı bırakıldı.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Devre dışı bırakılamadı.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onRegenerateCodes() {
|
||||
startTransition(async () => {
|
||||
const res = await regenerateRecoveryCodesAction();
|
||||
if (res.ok && res.recoveryCodes) {
|
||||
setStage({ kind: "done", recoveryCodes: res.recoveryCodes });
|
||||
toast.success("Yeni yedek kodlar oluşturuldu — eskileri geçersiz.");
|
||||
} else {
|
||||
toast.error(res.error ?? "Üretilemedi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled && stage.kind !== "done") {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-emerald-600 text-white">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Aktif
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Authenticator uygulaması ile giriş yapıyorsunuz.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={onRegenerateCodes} disabled={busy}>
|
||||
<KeyRound className="size-4" />
|
||||
Yedek kodları yenile
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDisable} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <ShieldOff className="size-4" />}
|
||||
Devre dışı bırak
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.kind === "done") {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="bg-emerald-50 dark:bg-emerald-950 rounded-md border border-emerald-200 dark:border-emerald-900 p-4">
|
||||
<p className="flex items-center gap-2 font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="size-4" />
|
||||
Yedek kodlarınız
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Telefonunuza erişiminizi kaybederseniz bu kodlardan biriyle giriş
|
||||
yapabilirsiniz. Her kod tek seferlik. <strong>Şimdi güvenli bir yere kaydedin</strong> —
|
||||
bu sayfadan çıktığınızda tekrar gösterilmez.
|
||||
</p>
|
||||
<pre className="bg-background mt-3 grid grid-cols-2 gap-2 rounded-md border p-3 text-sm font-mono">
|
||||
{stage.recoveryCodes.map((c) => (
|
||||
<span key={c}>{c}</span>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setStage({ kind: "idle" })}>
|
||||
Tamamladım
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.kind === "verify") {
|
||||
const otpauthQrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(stage.uri)}`;
|
||||
return (
|
||||
<form action={verifyAction} className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[200px_1fr] sm:items-start">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={otpauthQrUrl}
|
||||
alt="QR kodu"
|
||||
className="size-[200px] rounded-md border bg-white p-2"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<p>Authenticator uygulamanızı açın, QR kodu tarayın.</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Tarayamıyorsanız bu kodu manuel girin:
|
||||
</p>
|
||||
<code className="bg-muted rounded-md p-2 font-mono text-xs">{stage.secret}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="otp">Uygulamadaki 6 haneli kod</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
name="otp"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
className="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
{verifyState.error && (
|
||||
<p className="text-destructive text-xs">{verifyState.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStage({ kind: "idle" })}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={verifying}>
|
||||
{verifying ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Doğrula ve etkinleştir
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Pasif</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Hesabınız yalnızca şifre ile korunuyor.
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={beginEnroll} disabled={busy || stage.kind === "loading"}>
|
||||
{(busy || stage.kind === "loading") ? <Loader2 className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
|
||||
İki adımlı doğrulamayı aç
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { createSessionClient } from "@/lib/appwrite/server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { MfaPanel } from "./components/mfa-panel";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — Güvenlik",
|
||||
};
|
||||
|
||||
export default async function SecurityPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
// Look up the user's current MFA status straight from the session
|
||||
// client so the panel knows whether to offer enroll or disable.
|
||||
let mfaEnabled = false;
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
const user = await account.get();
|
||||
mfaEnabled = Boolean(user.mfa);
|
||||
} catch {
|
||||
// ignore — panel will treat as not enabled
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Güvenlik</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Hesap erişiminizi koruyan ayarlar. İki adımlı doğrulamayı açtığınızda
|
||||
giriş yaparken authenticator uygulamanızdaki 6 haneli kod istenir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>İki Adımlı Doğrulama</CardTitle>
|
||||
<CardDescription>
|
||||
Authenticator uygulaması (Google Authenticator, 1Password, Authy, vs.)
|
||||
ile TOTP. SMS desteklenmiyor.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MfaPanel initiallyEnabled={mfaEnabled} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type AuthState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
/** Set when the account has MFA enabled and the OTP field was empty. */
|
||||
mfaRequired?: boolean;
|
||||
};
|
||||
|
||||
export const initialAuthState: AuthState = { ok: false };
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, AuthenticatorType } from "node-appwrite";
|
||||
|
||||
import { createSessionClient } from "./server";
|
||||
|
||||
export type MfaEnrollState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
/** otpauth:// URI for QR; only set on enroll start. */
|
||||
uri?: string;
|
||||
/** Plain TOTP secret as a fallback if the QR can't be scanned. */
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
export const initialMfaEnrollState: MfaEnrollState = { ok: false };
|
||||
|
||||
export type MfaActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
/** Recovery codes returned right after enable; show once, never stored again. */
|
||||
recoveryCodes?: string[];
|
||||
};
|
||||
|
||||
export const initialMfaActionState: MfaActionState = { ok: false };
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of TOTP enroll: produce a fresh secret and otpauth URI for the
|
||||
* user's authenticator app. Calling this when an authenticator already
|
||||
* exists yields the same secret back.
|
||||
*/
|
||||
export async function startMfaEnrollAction(): Promise<MfaEnrollState> {
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
const res = await account.createMFAAuthenticator(AuthenticatorType.Totp);
|
||||
return { ok: true, uri: res.uri, secret: res.secret };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "MFA başlatılamadı.") };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of TOTP enroll: user scanned the QR, opened their authenticator,
|
||||
* typed the 6-digit code. We verify, then flip account.mfa = true so
|
||||
* future sign-ins require the second factor. Returns recovery codes —
|
||||
* shown once.
|
||||
*/
|
||||
export async function verifyMfaEnrollAction(
|
||||
_prev: MfaActionState,
|
||||
formData: FormData,
|
||||
): Promise<MfaActionState> {
|
||||
const otp = String(formData.get("otp") ?? "").trim();
|
||||
if (!otp || otp.length < 6) {
|
||||
return { ok: false, error: "6 haneli kodu girin." };
|
||||
}
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
await account.updateMFAAuthenticator(AuthenticatorType.Totp, otp);
|
||||
await account.updateMFA(true);
|
||||
const codes = await account.createMFARecoveryCodes();
|
||||
return { ok: true, recoveryCodes: codes.recoveryCodes };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Doğrulanamadı.") };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable MFA: turn the account flag off and remove the TOTP authenticator
|
||||
* so the user can re-enroll later with a fresh secret. Requires a current
|
||||
* authenticated session.
|
||||
*/
|
||||
export async function disableMfaAction(): Promise<MfaActionState> {
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
await account.updateMFA(false);
|
||||
try {
|
||||
await account.deleteMFAAuthenticator(AuthenticatorType.Totp);
|
||||
} catch {
|
||||
// Already removed — ignore.
|
||||
}
|
||||
revalidatePath("/settings/security");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Devre dışı bırakılamadı.") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodesAction(): Promise<MfaActionState> {
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
const codes = await account.updateMFARecoveryCodes();
|
||||
return { ok: true, recoveryCodes: codes.recoveryCodes };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Yedek kodlar üretilemedi.") };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user