94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
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 { clientIp, 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);
|
|
}
|
|
|
|
/** Step 1: generate a secret, store it 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);
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { twoFactorSecret: encrypted, twoFactorConfirmedAt: null },
|
|
});
|
|
revalidatePath("/settings/2fa");
|
|
}
|
|
|
|
/** Step 2: verify a code against the pending secret, then confirm. */
|
|
export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
|
const id = await sessionUserId();
|
|
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
|
|
|
if (!rateLimit(`2fa-confirm:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
|
|
|
|
const code = String(formData.get("code") ?? "").trim();
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id },
|
|
select: { twoFactorSecret: true },
|
|
});
|
|
|
|
let ok = false;
|
|
if (user?.twoFactorSecret && code) {
|
|
try {
|
|
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
|
|
ok = verifyTotp(code, secret);
|
|
} catch {
|
|
ok = false;
|
|
}
|
|
}
|
|
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 (!rateLimit(`2fa-disable:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit");
|
|
|
|
const code = String(formData.get("code") ?? "").trim();
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id },
|
|
select: { twoFactorSecret: true },
|
|
});
|
|
|
|
let ok = false;
|
|
if (user?.twoFactorSecret && code) {
|
|
try {
|
|
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
|
|
ok = verifyTotp(code, secret);
|
|
} catch {
|
|
ok = false;
|
|
}
|
|
}
|
|
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");
|
|
}
|