Add opt-in TOTP 2FA for local accounts
New card in Настройки → Аккаунт: scan a QR code (otplib + qrcode), confirm with a live code, get 8 one-time recovery codes shown once. Only offered for authSource="local" — LDAP accounts already have their own MFA story at the directory level and are turned away with a clear message if they somehow hit the setup endpoint directly. Login flow: a 2FA account's password check now creates a short-lived "login challenge" (separate table from `sessions`, 5-minute TTL, capped at 6 verify attempts) instead of a real session, and the login page swaps to a second screen asking for a code — TOTP or a recovery code, either works. The LDAP branch of the login route is untouched; it returns before ever reaching the 2FA check. totpSecret is encrypted at rest via the existing lib/crypto/credentials.ts helper (same one already used for mailbox/LDAP bind passwords) rather than adding a second encryption scheme. Recovery codes are hashed, not stored plaintext, and each is single-use (marked usedAt, not deleted). Verified against the real deployment end-to-end with Playwright against a throwaway test account (created via the real admin-users API, fully deleted after): setup → confirm → recovery-code issuance → second login correctly prompted for a code → wrong code rejected → correct code and a recovery code both worked → a reused recovery code was correctly rejected → disable (password-gated) → subsequent login went straight through again. Also added unit tests for the TOTP/recovery-code helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
1 parent
95522fdedd
commit
4ecb0e7698
17 files changed
+2285
-55
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { ChangePasswordForm } from "./change-password-form";
|
||||
import { TwoFactorSettings } from "./two-factor-settings";
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const session = await getCurrentSession();
|
||||
@@ -9,6 +10,15 @@ export default async function AccountSettingsPage() {
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
||||
<ChangePasswordForm />
|
||||
{session?.user.authSource === "local" ? (
|
||||
<div className="mt-4">
|
||||
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 rounded-md border border-border bg-surface-hover px-3 py-2 text-sm text-text-muted">
|
||||
Двухфакторная аутентификация недоступна для LDAP-аккаунтов — вход защищён на уровне домена.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
|
||||
|
||||
type Stage = "idle" | "setting-up" | "recovery-codes" | "disabling";
|
||||
|
||||
export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean }) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [stage, setStage] = useState<Stage>("idle");
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setStage("idle");
|
||||
setQrDataUrl(null);
|
||||
setSecret(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function startSetup() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/auth/totp/setup", { method: "POST" });
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось начать настройку 2FA");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setQrDataUrl(data.qrDataUrl);
|
||||
setSecret(data.secret);
|
||||
setStage("setting-up");
|
||||
}
|
||||
|
||||
async function confirmSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/auth/totp/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось подтвердить код");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
setEnabled(true);
|
||||
setStage("recovery-codes");
|
||||
}
|
||||
|
||||
async function disable2fa(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/auth/totp/disable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось отключить 2FA");
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
reset();
|
||||
}
|
||||
|
||||
function copyRecoveryCodes() {
|
||||
if (!recoveryCodes) return;
|
||||
navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (stage === "recovery-codes" && recoveryCodes) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-success-soft-text">
|
||||
<ShieldCheck size={15} />
|
||||
2FA включена
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-text-muted">
|
||||
Сохраните эти резервные коды в надёжном месте — каждый работает один раз, если телефон с приложением
|
||||
потеряется. Второй раз их показать не получится.
|
||||
</p>
|
||||
<pre className="mb-3 whitespace-pre-wrap rounded-md border border-border bg-surface p-3 text-sm">
|
||||
{recoveryCodes.join("\n")}
|
||||
</pre>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={copyRecoveryCodes} className="btn btn-ghost">
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Скопировано" : "Скопировать"}
|
||||
</button>
|
||||
<button type="button" onClick={reset} className="btn btn-primary flex-1 justify-center">
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === "setting-up") {
|
||||
return (
|
||||
<form onSubmit={confirmSetup} className="card p-4">
|
||||
<p className="mb-3 text-sm font-semibold">Настройка 2FA</p>
|
||||
<p className="mb-3 text-sm text-text-muted">
|
||||
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy и т.п.) и введите код, чтобы
|
||||
подтвердить.
|
||||
</p>
|
||||
{qrDataUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URI QR code, not worth next/image config
|
||||
<img src={qrDataUrl} alt="QR-код для настройки 2FA" className="mb-3 h-40 w-40" />
|
||||
)}
|
||||
{secret && (
|
||||
<p className="mb-3 break-all rounded-md border border-border bg-surface px-2 py-1.5 font-mono text-xs text-text-muted">
|
||||
{secret}
|
||||
</p>
|
||||
)}
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Код из приложения</span>
|
||||
<input
|
||||
required
|
||||
inputMode="numeric"
|
||||
pattern="\d{6}"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary flex-1 justify-center">
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === "disabling") {
|
||||
return (
|
||||
<form onSubmit={disable2fa} className="card p-4">
|
||||
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Текущий пароль</span>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn bg-danger text-white hover:brightness-95 flex-1 justify-center">
|
||||
Отключить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold">
|
||||
{enabled ? <ShieldCheck size={15} className="text-success-soft-text" /> : <ShieldOff size={15} className="text-text-faint" />}
|
||||
Двухфакторная аутентификация
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-text-muted">
|
||||
{enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."}
|
||||
</p>
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
{enabled ? (
|
||||
<button type="button" onClick={() => setStage("disabling")} className="btn btn-ghost w-full justify-center">
|
||||
Отключить
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={startSetup} disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
Включить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+128
-50
@@ -3,12 +3,14 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Ticket, LogIn } from "lucide-react";
|
||||
import { Ticket, LogIn, ShieldCheck } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -30,6 +32,36 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
|
||||
if (data.totpRequired) {
|
||||
setTotpRequired(true);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleVerifyTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/auth/verify-totp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось войти");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
@@ -41,61 +73,107 @@ export default function LoginPage() {
|
||||
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
||||
}}
|
||||
>
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="card w-full max-w-sm p-8"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||
<Ticket size={18} strokeWidth={2.5} />
|
||||
{totpRequired ? (
|
||||
<motion.form
|
||||
onSubmit={handleVerifyTotp}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="card w-full max-w-sm p-8"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||
<ShieldCheck size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||
</div>
|
||||
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-1 font-display text-xl font-semibold">Вход для агентов</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
|
||||
<h1 className="mb-1 font-display text-xl font-semibold">Двухфакторная аутентификация</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Введите код из приложения-аутентификатора или резервный код.</p>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="mb-5 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Код</span>
|
||||
<input
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-5 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
<LogIn size={16} />
|
||||
{loading ? "Проверяем…" : "Войти"}
|
||||
</button>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="card w-full max-w-sm p-8"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||
<Ticket size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
<LogIn size={16} />
|
||||
{loading ? "Входим…" : "Войти"}
|
||||
</button>
|
||||
</motion.form>
|
||||
<h1 className="mb-1 font-display text-xl font-semibold">Вход для агентов</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-5 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
<LogIn size={16} />
|
||||
{loading ? "Входим…" : "Войти"}
|
||||
</button>
|
||||
</motion.form>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { createLoginChallenge, setLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||
import { authenticateLdapUser } from "@/lib/ldap/client";
|
||||
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||
@@ -78,6 +79,14 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2FA only ever applies to local password accounts (see api/auth/totp/setup) —
|
||||
// an LDAP login never reaches this branch at all, it returns above.
|
||||
if (user.totpEnabled) {
|
||||
const challengeToken = await createLoginChallenge(user.id);
|
||||
await setLoginChallengeCookie(challengeToken);
|
||||
return NextResponse.json({ ok: true, totpRequired: true });
|
||||
}
|
||||
|
||||
const token = await createSession(user.id);
|
||||
await setSessionCookie(token);
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||
import { decryptTotpSecret, verifyTotpCode, generateRecoveryCodes, hashRecoveryCode } from "@/lib/auth/totp";
|
||||
|
||||
const schema = z.object({ code: z.string().min(6).max(6) });
|
||||
|
||||
/** Flips totpEnabled on after the user proves they can generate a live code, and issues recovery codes — shown once, never retrievable again. */
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Введите 6-значный код" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||
if (!user?.totpSecret) {
|
||||
return NextResponse.json({ error: "Сначала начните настройку 2FA" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await verifyTotpCode(decryptTotpSecret(user.totpSecret), parsed.data.code);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||
}
|
||||
|
||||
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, user.id));
|
||||
|
||||
// Replace any codes from a previous enable/disable cycle.
|
||||
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
await db.insert(totpRecoveryCodes).values(recoveryCodes.map((code) => ({ userId: user.id, codeHash: hashRecoveryCode(code) })));
|
||||
|
||||
return NextResponse.json({ ok: true, recoveryCodes });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||
|
||||
const schema = z.object({ password: z.string().min(1) });
|
||||
|
||||
/** Requires the current password (not a TOTP code) — matches change-password's confirmation pattern, and means a stolen live session alone still can't turn off 2FA. */
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||
if (!user?.passwordHash) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
||||
}
|
||||
|
||||
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
|
||||
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users } from "@/lib/db/schema";
|
||||
import { generateTotpSecret, encryptTotpSecret, buildTotpQrCode } from "@/lib/auth/totp";
|
||||
|
||||
/**
|
||||
* Starts (or restarts) 2FA setup — generates a fresh secret and stores it
|
||||
* encrypted, but leaves totpEnabled false until /confirm proves the user
|
||||
* actually scanned it (see users.totpEnabled comment in schema.ts). Safe to
|
||||
* call again if the user abandons setup partway — it just overwrites the
|
||||
* unconfirmed secret with a new one.
|
||||
*/
|
||||
export async function POST() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
if (user.authSource !== "local") {
|
||||
return NextResponse.json(
|
||||
{ error: "2FA доступна только для локальных аккаунтов — вход через LDAP защищён на уровне домена" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
|
||||
|
||||
const qrDataUrl = await buildTotpQrCode(secret, user.email);
|
||||
return NextResponse.json({ secret, qrDataUrl });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { consumeLoginChallengeAttempt, deleteLoginChallenge, clearLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
|
||||
|
||||
const schema = z.object({ code: z.string().min(6).max(16) });
|
||||
|
||||
// Second step of login for a 2FA account — reads the pending-login cookie
|
||||
// set by /api/auth/login, so the client never has to carry the challenge
|
||||
// token itself. Accepts either a live 6-digit TOTP code or a recovery code.
|
||||
export async function POST(request: Request) {
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Burns one attempt regardless of whether the code below turns out to be
|
||||
// right — the whole point is capping brute-force *tries*, not just wrong ones.
|
||||
const challenge = await consumeLoginChallengeAttempt();
|
||||
if (!challenge) {
|
||||
return NextResponse.json({ error: "Сессия входа истекла — войдите заново" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, challenge.userId) });
|
||||
if (!user || !user.totpEnabled || !user.totpSecret) {
|
||||
await deleteLoginChallenge(challenge.tokenHash);
|
||||
return NextResponse.json({ error: "2FA не настроена для этого аккаунта" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = parsed.data.code.trim();
|
||||
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
|
||||
|
||||
if (!verified) {
|
||||
const codeHash = hashRecoveryCode(code);
|
||||
const match = await db.query.totpRecoveryCodes.findFirst({
|
||||
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
|
||||
});
|
||||
if (match) {
|
||||
await db.update(totpRecoveryCodes).set({ usedAt: new Date() }).where(eq(totpRecoveryCodes.id, match.id));
|
||||
verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||
}
|
||||
|
||||
await deleteLoginChallenge(challenge.tokenHash);
|
||||
await clearLoginChallengeCookie();
|
||||
|
||||
const token = await createSession(user.id);
|
||||
await setSessionCookie(token);
|
||||
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { loginChallenges } from "@/lib/db/schema";
|
||||
|
||||
// The "you passed the password check, now prove the TOTP code" state for a
|
||||
// 2FA account — separate from the real `sessions` table (see schema.ts) so
|
||||
// nothing can mistake a pending challenge for an authenticated session.
|
||||
const CHALLENGE_COOKIE = "pending_login";
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 6;
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function createLoginChallenge(userId: string): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
|
||||
await db.insert(loginChallenges).values({ tokenHash, userId, expiresAt });
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function setLoginChallengeCookie(token: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(CHALLENGE_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: CHALLENGE_TTL_MS / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearLoginChallengeCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(CHALLENGE_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the pending-login cookie and burns one attempt against it —
|
||||
* called once per verify-totp POST, before the code itself is even checked,
|
||||
* so a guessing script can't rack up unlimited tries by never calling this.
|
||||
* Returns null (nothing to resume) if the cookie is missing, the challenge
|
||||
* doesn't exist, it's expired, or attempts are exhausted — the last two
|
||||
* also delete the row so it can't be retried after the fact.
|
||||
*/
|
||||
export async function consumeLoginChallengeAttempt(): Promise<{ userId: string; tokenHash: string } | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const challenge = await db.query.loginChallenges.findFirst({ where: eq(loginChallenges.tokenHash, tokenHash) });
|
||||
if (!challenge) return null;
|
||||
|
||||
if (challenge.expiresAt.getTime() < Date.now() || challenge.attempts >= MAX_ATTEMPTS) {
|
||||
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||
return null;
|
||||
}
|
||||
|
||||
await db.update(loginChallenges).set({ attempts: challenge.attempts + 1 }).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||
return { userId: challenge.userId, tokenHash };
|
||||
}
|
||||
|
||||
export async function deleteLoginChallenge(tokenHash: string): Promise<void> {
|
||||
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
beforeAll(() => {
|
||||
// encryptTotpSecret/decryptTotpSecret delegate to lib/crypto/credentials.ts,
|
||||
// which reads this lazily (only when actually encrypting/decrypting) — a
|
||||
// throwaway key here keeps the test independent of .env.
|
||||
process.env.CREDENTIALS_ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64");
|
||||
});
|
||||
|
||||
describe("totp", () => {
|
||||
it("generates a code that verifies against its own secret", async () => {
|
||||
const { generateTotpSecret, verifyTotpCode } = await import("./totp");
|
||||
const { generate } = await import("otplib");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const code = await generate({ secret });
|
||||
expect(await verifyTotpCode(secret, code)).toBe(true);
|
||||
expect(await verifyTotpCode(secret, "000000")).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips a secret through encryption", async () => {
|
||||
const { generateTotpSecret, encryptTotpSecret, decryptTotpSecret } = await import("./totp");
|
||||
const secret = generateTotpSecret();
|
||||
const encrypted = encryptTotpSecret(secret);
|
||||
expect(encrypted).not.toContain(secret);
|
||||
expect(decryptTotpSecret(encrypted)).toBe(secret);
|
||||
});
|
||||
|
||||
it("generates unique, human-typeable recovery codes and hashes deterministically", async () => {
|
||||
const { generateRecoveryCodes, hashRecoveryCode } = await import("./totp");
|
||||
const codes = generateRecoveryCodes(8);
|
||||
expect(codes).toHaveLength(8);
|
||||
expect(new Set(codes).size).toBe(8);
|
||||
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
||||
|
||||
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0]));
|
||||
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0].toUpperCase()));
|
||||
expect(hashRecoveryCode(codes[0])).not.toBe(hashRecoveryCode(codes[1]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import crypto from "node:crypto";
|
||||
import { generateSecret, verify, generateURI } from "otplib";
|
||||
import QRCode from "qrcode";
|
||||
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||
|
||||
const ISSUER = "top-tickets";
|
||||
const RECOVERY_CODE_COUNT = 8;
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return generateSecret();
|
||||
}
|
||||
|
||||
export function encryptTotpSecret(secret: string): string {
|
||||
return encryptCredential(secret);
|
||||
}
|
||||
|
||||
export function decryptTotpSecret(encrypted: string): string {
|
||||
return decryptCredential(encrypted);
|
||||
}
|
||||
|
||||
export async function buildTotpQrCode(secret: string, email: string): Promise<string> {
|
||||
const uri = generateURI({ issuer: ISSUER, label: email, secret });
|
||||
return QRCode.toDataURL(uri);
|
||||
}
|
||||
|
||||
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
|
||||
const result = await verify({ secret, token: code.trim() });
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
/** Human-typeable: groups of 5 lowercase hex chars, e.g. "a1b2c-d3e4f". */
|
||||
function formatRecoveryCode(): string {
|
||||
const raw = crypto.randomBytes(5).toString("hex");
|
||||
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT): string[] {
|
||||
return Array.from({ length: count }, formatRecoveryCode);
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code: string): string {
|
||||
return crypto.createHash("sha256").update(code.trim().toLowerCase()).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `login_challenges` (
|
||||
`token_hash` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`attempts` integer DEFAULT 0 NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `totp_recovery_codes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`code_hash` text NOT NULL,
|
||||
`used_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `totp_secret` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `totp_enabled` integer DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,13 @@
|
||||
"when": 1787129904542,
|
||||
"tag": "0009_little_natasha_romanoff",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1787213219014,
|
||||
"tag": "0010_curved_jack_murdock",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export const users = sqliteTable("users", {
|
||||
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
||||
.notNull()
|
||||
.default("local"),
|
||||
// Opt-in TOTP 2FA — local accounts only (see api/auth/totp/setup). The
|
||||
// secret is AES-256-GCM encrypted at rest via lib/crypto/credentials.ts,
|
||||
// same as every other stored credential in this app. Set as soon as
|
||||
// "setup" generates a secret, but totpEnabled stays false until the user
|
||||
// proves they scanned it right by confirming one live code.
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: integer("totp_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
@@ -44,6 +51,41 @@ export const sessions = sqliteTable("sessions", {
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* One-time recovery codes, issued when 2FA is confirmed — SHA-256 hashed
|
||||
* (they're low-entropy compared to a password, but only ever checked
|
||||
* against a rate-limited login-challenge attempt counter, same as a TOTP
|
||||
* code guess would be). usedAt marks a code as spent, not deleted, so the
|
||||
* user can see in principle how many they've burned through — nothing
|
||||
* currently surfaces that, but there's no reason to throw the row away.
|
||||
*/
|
||||
export const totpRecoveryCodes = sqliteTable("totp_recovery_codes", {
|
||||
id: id(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
codeHash: text("code_hash").notNull(),
|
||||
usedAt: integer("used_at", { mode: "timestamp_ms" }),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* The "you passed step 1 (password), now prove step 2 (TOTP)" state for a
|
||||
* 2FA-enabled account — deliberately not the same table as `sessions`,
|
||||
* since a login challenge must never be usable as a real session no matter
|
||||
* what bug might otherwise conflate the two. Short TTL (5 min) and a capped
|
||||
* attempt counter so it can't be brute-forced; see lib/auth/login-challenge.ts.
|
||||
*/
|
||||
export const loginChallenges = sqliteTable("login_challenges", {
|
||||
tokenHash: text("token_hash").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
/** People who file tickets — identified by whichever channel they came in on. */
|
||||
export const customers = sqliteTable("customers", {
|
||||
id: id(),
|
||||
|
||||
Reference in new issue
Block a user