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:
ogrechkoandClaude Sonnet 5 committed 2026-08-20 08:25:15 +00:00
1 parent 95522fdedd
commit 4ecb0e7698
17 files changed
+2285 -55

No files matched your search

+10
View File
@@ -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
View File
@@ -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>
);
}
+9
View File
@@ -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);
+41
View File
@@ -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 });
}
+37
View File
@@ -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 });
}
+35
View File
@@ -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 });
}
+61
View File
@@ -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 } });
}
+70
View File
@@ -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));
}
+41
View File
@@ -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]));
});
});
+43
View File
@@ -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
+7
View File
@@ -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
}
]
}
+42
View File
@@ -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(),