Files
Epicnabbo-Catalogus-Updated…/src/actions/twofactor.ts
T
openhands 942bc6fc8d Security hardening, code quality, and ESLint setup
- Remove production DB dump (db_backup_*.sql) and update.log from git tracking
- Add DB backups to .gitignore
- Replace all console.log/console.error with structured logger module
- Translate Dutch error messages to English (link-discord.ts)
- Remove dead code blocks (register-form.tsx false && pattern)
- Add ESLint flat config with TypeScript, React, Next.js, jsx-a11y, and security plugins
- Add Prettier config
- Add eslint-plugin-security for security-aware linting
- Fix all 119+ ESLint warnings across the codebase:
  - Resolve security/detect-object-injection with safe access patterns
  - Resolve security/detect-non-literal-fs-filename with path traversal validation
  - Replace <img> with next/image <Image> component
  - Remove unused variables and imports
  - Replace non-null assertions with proper type guards
  - Replace <a> with <Link> for internal navigation
  - Use next/script Script component for external scripts
- Fix setState-in-useEffect anti-patterns (navbar-color-picker, logo-generator, theme-switcher)
- Add lint and format scripts to package.json

All checks: typecheck ✓, tests 58/58 ✓, lint 0 errors 0 warnings ✓
2026-07-10 22:48:22 +02:00

113 lines
3.8 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { randomBytes } from "node:crypto";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
async function sessionUserId(): Promise<number> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return Number(session.user.id);
}
function generateRecoveryCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < 8; i++) {
codes.push(randomBytes(4).toString("hex").toUpperCase().replace(/(.{4})/, "$1-"));
}
return codes;
}
/** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */
async function verifyTwoFactorCode(
userId: number, code: string,
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
});
if (!user?.twoFactorSecret) return { ok: false };
// Try TOTP first
try {
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
if (verifyTotp(code, secret)) return { ok: true };
} catch { /* fall through to recovery */ }
// Try recovery codes
if (user.twoFactorRecoveryCodes) {
let codes: string[];
try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { codes = []; }
const idx = codes.indexOf(code);
if (idx !== -1) {
codes.splice(idx, 1);
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
return { ok: true, updatedRecoveryCodes: remaining };
}
}
return { ok: false };
}
/** Step 1: generate a secret and recovery codes, store encrypted but UNconfirmed. */
export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes();
await prisma.user.update({
where: { id },
data: {
twoFactorSecret: encrypted,
twoFactorConfirmedAt: null,
twoFactorRecoveryCodes: JSON.stringify(codes),
},
});
revalidatePath("/settings/2fa");
}
/** Step 2: verify a code against the pending secret, then confirm and show recovery codes. */
export async function confirmTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "").trim();
const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
redirect("/settings/2fa?enabled=1");
}
export async function disableTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "").trim();
const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({
where: { id },
data: {
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null,
},
});
redirect("/settings/2fa?disabled=1");
}