Files
EpicNext-Cms/src/actions/twofactor.ts
T
Simo e668fa85ec Add 2FA, email + password reset, and batch-7 pages
Auth (hand-built on the auth core):
- 2FA: User model gains two_factor_secret/recovery_codes/confirmed_at (+ idempotent
  MariaDB migration). authorize() requires a valid TOTP code when 2FA is confirmed
  (secret decrypted via Laravel APP_KEY, fail-closed). Two-step login (precheckLogin
  reveals the code field). /settings/2fa enable/confirm/disable flow.
- Password reset: nodemailer email service; PasswordReset model + migration;
  /forgot (request, generic response) + /reset (token sha256 + 1h TTL, sets argon2id
  hash). Login links to forgot.

Batch 7 (parallel agents): /admin/commandocentrum (RCON controls + emulator_errors),
social write actions (friend request + guild forum new thread), /help/[category],
/badges (public). env: APP_KEY, APP_URL, SMTP_*. Nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (64 page routes).
2026-06-28 14:25:19 +02:00

68 lines
2.1 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 { 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");
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(): Promise<void> {
const id = await sessionUserId();
await prisma.user.update({
where: { id },
data: {
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null,
},
});
redirect("/settings/2fa?disabled=1");
}