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).
This commit is contained in:
Simo committed 2026-06-28 14:25:19 +02:00
1 parent 486ce51559
commit e668fa85ec
24 files changed
+1223 -34

No files matched your search

+16
View File
@@ -2,7 +2,9 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Discord from "next-auth/providers/discord";
import Google from "next-auth/providers/google";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
@@ -15,6 +17,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
code: { label: "2FA code", type: "text" },
},
authorize: async (credentials) => {
const username = String(credentials?.username ?? "").trim();
@@ -37,6 +40,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
});
}
// Two-factor: if enabled, a valid TOTP code is required. The secret is
// Laravel-encrypted with APP_KEY (fail closed if it cannot be read).
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
const code = String(credentials?.code ?? "").trim();
if (!code || !env.APP_KEY) return null;
try {
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
if (!verifyTotp(code, secret)) return null;
} catch {
return null;
}
}
return { id: String(user.id), name: user.username, rank: user.rank };
},
}),
+5
View File
@@ -18,6 +18,11 @@ export function generateTotp(secret: string): string {
return authenticator.generate(secret);
}
/** Generate a fresh base32 secret for enrolling a new authenticator. */
export function generateTotpSecret(): string {
return authenticator.generateSecret();
}
/** otpauth:// URI for provisioning a QR code. */
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
return authenticator.keyuri(accountName, issuer, secret);
+38
View File
@@ -0,0 +1,38 @@
import nodemailer, { type Transporter } from "nodemailer";
import { env } from "@/env";
let transporter: Transporter | null = null;
function getTransport(): Transporter | null {
if (!env.SMTP_HOST) return null;
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
secure: env.SMTP_SECURE,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
});
}
return transporter;
}
/** Send an HTML email. No-ops (returns false) when SMTP isn't configured. */
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
const t = getTransport();
if (!t) {
console.warn("[email] SMTP not configured — skipping mail to", to);
return false;
}
try {
await t.sendMail({
from: env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`,
to,
subject,
html,
});
return true;
} catch (e) {
console.error("[email] send failed:", (e as Error).message);
return false;
}
}