Security hardening: 12 improvements across the stack
1. env.ts: APP_KEY placeholder detection with validation 2. schema.prisma: password column widened to varchar(255) for argon2id 3. auth.ts: trustHost restricted to development only 4. next.config.ts: added CSP, HSTS, X-Frame-Options, and other security headers 5. api.ts: CORS restricted to APP_URL instead of wildcard 6. register-form.tsx: migrated from REST API fetch to server action (useActionState) 7. twofactor.ts + 2fa page: TOTP recovery codes (8 one-time codes, generated and displayed) 8. register.ts: password min length 8 + complexity requirements (upper, lower, digit) 9. register.ts + help-tickets.ts + radio-shouts.ts: Zod schema validation 10. rate-limit.ts: improved periodic cleanup with aggressive eviction at 10k buckets 11. guard.ts + admin actions: rate-limited admin actions (30 req/min per staff) 12. help-tickets.ts + radio-shouts.ts: content moderation via moderateOrThrow
This commit is contained in:
1 parent
a1950e5b65
commit
5628e7d6b7
19 files changed
+370
-220
No files matched your search
+50
-31
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -15,20 +16,64 @@ async function sessionUserId(): Promise<number> {
|
||||
return Number(session.user.id);
|
||||
}
|
||||
|
||||
/** Step 1: generate a secret, store it encrypted but UNconfirmed. */
|
||||
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 },
|
||||
data: {
|
||||
twoFactorSecret: encrypted,
|
||||
twoFactorConfirmedAt: null,
|
||||
twoFactorRecoveryCodes: JSON.stringify(codes),
|
||||
},
|
||||
});
|
||||
revalidatePath("/settings/2fa");
|
||||
}
|
||||
|
||||
/** Step 2: verify a code against the pending secret, then confirm. */
|
||||
/** 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");
|
||||
@@ -37,20 +82,7 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
|
||||
@@ -65,20 +97,7 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
await prisma.user.update({
|
||||
|
||||
Reference in new issue
Block a user