Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Second review pass covering security, performance, admin tooling and the public/room flows. All HIGH and MEDIUM findings from the audit are resolved; nothing in this commit changes the visible feature set. Authentication & session security - CSP is now set on the request headers in the proxy, which is what Next.js uses to derive the render nonce, so the nonce is effective. - 2FA: an already-enabled user cannot re-enroll, the setup endpoint is rate-limited per account, and confirmed codes are persisted so the second secret no longer silently never applies. - Password reset revokes the ticket, authTicket and all personal access tokens, and bumps the token version so existing sessions die. The same revocation is now wired into the staff-side password reset. - /reset and /verify return a stable error code instead of raw text; the mail lookups are ordered by id so duplicates cannot vary between runs. - Resending the verification mail gets a per-address cooldown on top of the per-user limit. - Issue API tokens with the narrower radio/ticket ability set instead of "*". Authorization & input handling - Mid-rank staff can no longer keep dynamically granted non-view admin.* permissions: existing grants are revoked by migration and the grant lookup is restricted to "%.view". Rank guards use the dynamic super-admin check. - Alerting a user is permission-checked and audited like the other tools. - Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user actions route, bulk user actions) are capped and rank-guarded, and bulk ids are bounded. - updateRoom / updateRoomItem write through a field allowlist, and items may only be edited through their own room. - Classnames reaching the filesystem are validated before use so a crafted value cannot escape the asset directories. - The word filter now also covers offline mails, guild forum threads and replies, and user mottos. - Media uploads are validated by magic bytes, /api/media requires the page edit permission, APP_URL must be configured once mail is enabled, and the diagnostics error route checks the fetch site header. Admin tooling - Secret settings render masked and cannot be overwritten with a blank or an arbitrary raw key; radio credentials are new password inputs. - Commandocentrum balance changes are audited. - Admin list pagination reads the caller's per-page instead of the max, and the log exporter caps offset and search length. Performance - Catalog translations are cached per module, with a cheap revision hash; the public online count uses a stale window instead of hammering the DB. - The cache warmup now primes the payload the home route actually reads. - TopHeader batches its queries into one round trip, and LCP avatars load eagerly. - motion/react and sonner are no longer part of the root layout; the nav dropdown and mobile nav panels are lazy client chunks. Anonymous visitors again get the navigation chrome, and public pages get an edge cacheable response. Accessibility - Nested <main> elements in phase pages became <section>; the page entrance and route progress animations are pure CSS that respect reduced motion.
164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
"use server";
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { env } from "@/env";
|
|
import { auth } from "@/lib/auth";
|
|
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
|
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
|
import { db, User } from "@/lib/db";
|
|
import { rateLimit } from "@/lib/rate-limit";
|
|
|
|
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 db
|
|
.select({
|
|
twoFactorSecret: User.twoFactorSecret,
|
|
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
|
|
})
|
|
.from(User)
|
|
.where(eq(User.id, userId))
|
|
.limit(1);
|
|
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");
|
|
|
|
if (!(await rateLimit(`2fa-begin:${id}`, 5, 30_000)).ok)
|
|
redirect("/settings/2fa?error=ratelimit");
|
|
|
|
// Re-running this action while 2FA is confirmed would be a silent *downgrade*
|
|
// (the new secret is stored unconfirmed, and unconfirmed means "login gate
|
|
// off"), so the existing setup has to be disabled through the proper flow
|
|
// first: a valid code, not just an authenticated session.
|
|
const [current] = await db
|
|
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
|
.from(User)
|
|
.where(eq(User.id, id))
|
|
.limit(1);
|
|
if (current?.twoFactorConfirmedAt)
|
|
redirect("/settings/2fa?error=alreadyenabled");
|
|
|
|
const secret = generateTotpSecret();
|
|
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
|
|
const codes = generateRecoveryCodes();
|
|
await db
|
|
.update(User)
|
|
.set({
|
|
twoFactorSecret: encrypted,
|
|
twoFactorConfirmedAt: null,
|
|
twoFactorRecoveryCodes: JSON.stringify(codes),
|
|
})
|
|
.where(eq(User.id, id));
|
|
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") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
|
|
const { ok, updatedRecoveryCodes } = await verifyTwoFactorCode(id, code);
|
|
if (!ok) redirect("/settings/2fa?error=badcode");
|
|
|
|
// A recovery code spends itself on use, so persist the remainder together
|
|
// with the confirmation instead of dropping the caller's own update.
|
|
await db
|
|
.update(User)
|
|
.set({
|
|
twoFactorConfirmedAt: new Date(),
|
|
...(updatedRecoveryCodes !== undefined
|
|
? { twoFactorRecoveryCodes: updatedRecoveryCodes }
|
|
: {}),
|
|
})
|
|
.where(eq(User.id, id));
|
|
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") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
|
|
const { ok } = await verifyTwoFactorCode(id, code);
|
|
if (!ok) redirect("/settings/2fa?error=badcode");
|
|
|
|
await db
|
|
.update(User)
|
|
.set({
|
|
twoFactorSecret: null,
|
|
twoFactorRecoveryCodes: null,
|
|
twoFactorConfirmedAt: null,
|
|
})
|
|
.where(eq(User.id, id));
|
|
redirect("/settings/2fa?disabled=1");
|
|
}
|