fix: production hardening — migration script, security fixes, structured logging, API docs, component splitting

- Create apply-migrations.ts and jobs-worker.ts scripts (package.json references)
- Convert badge leaderboard from $queryRawUnsafe to $queryRaw with Prisma.sql templates
- Fix OAuth email binding: add oauth_require_link site setting, skip 2FA-protected accounts
- Add per-user 2FA rate limiting (5/30s) to prevent TOTP brute-force
- Add structured JSON logger with levels (debug/info/warn/error)
- Split 341-line HomePage into GuestView + UserView components
- Add OpenAPI v3.1 spec at /api/openapi.json
- Add LOG_LEVEL env var, regenerate Prisma client
- Add mysql2 dependency for migration scripts
- All 58 tests pass, typecheck clean
This commit is contained in:
openhands committed 2026-07-08 13:06:02 +02:00
1 parent 5c638cd6bc
commit c5db7f5156
17 files changed
+1175 -449

No files matched your search

+49 -30
View File
@@ -7,6 +7,7 @@ import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { siteSettings } from "@/lib/services/site-settings";
import { env } from "@/env";
async function verify2faCode(userId: number, code: string): Promise<boolean> {
@@ -89,6 +90,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
const code = String(credentials?.code ?? "").trim();
if (!code || !env.APP_KEY) return null;
// Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force
// even when the attacker rotates IPs or knows the password.
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
if (!(await verify2faCode(user.id, code))) return null;
}
@@ -118,20 +124,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
callbacks: {
async signIn({ user, account }) {
if (account?.provider === "credentials") return true;
// OAuth: try to match by email first.
const email = user.email;
if (email) {
try {
const dbUser = await prisma.user.findFirst({
where: { mail: email },
select: { id: true },
});
if (dbUser) return true;
} catch {
return "/login?error=Unavailable";
}
}
// If email didn't match, try Discord ID via SocialAccounts.
const requireLink = await siteSettings.getBool("oauth_require_link", false);
// Always allow explicitly linked accounts.
if (account?.provider === "discord" && account.providerAccountId) {
try {
const linked = await prisma.socialAccounts.findUnique({
@@ -143,28 +139,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return "/login?error=Unavailable";
}
}
// Email-based binding: only allowed when oauth_require_link is disabled
// AND the matched account does NOT have 2FA enabled (account takeover guard).
if (!requireLink && user.email) {
try {
const dbUser = await prisma.user.findFirst({
where: { mail: user.email, twoFactorConfirmedAt: null },
select: { id: true },
});
if (dbUser) return true;
} catch {
return "/login?error=Unavailable";
}
}
return "/login?error=NoAccount";
},
async jwt({ token, user, account }) {
if (user && account?.provider === "credentials") {
token.rank = (user as { rank?: number }).rank;
} else if (user?.email) {
// OAuth with email: bind to matching hotel account.
try {
const dbUser = await prisma.user.findFirst({
where: { mail: user.email },
select: { id: true, rank: true, username: true },
});
if (dbUser) {
token.sub = String(dbUser.id);
token.rank = dbUser.rank;
token.name = dbUser.username;
}
} catch {
// leave token as-is on lookup failure
}
return token;
}
// OAuth without email match: try Discord ID via SocialAccounts.
const requireLink = await siteSettings.getBool("oauth_require_link", false);
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
if (!token.sub && account?.provider === "discord" && account.providerAccountId) {
try {
const linked = await prisma.socialAccounts.findUnique({
@@ -179,12 +179,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
token.sub = String(dbUser.id);
token.rank = dbUser.rank;
token.name = dbUser.username;
return token;
}
}
} catch {
// leave token as-is on lookup failure
}
}
// Email-based binding: only when requireLink is off AND account has no 2FA.
if (!requireLink && user?.email && !token.sub) {
try {
const dbUser = await prisma.user.findFirst({
where: { mail: user.email, twoFactorConfirmedAt: null },
select: { id: true, rank: true, username: true },
});
if (dbUser) {
token.sub = String(dbUser.id);
token.rank = dbUser.rank;
token.name = dbUser.username;
}
} catch {
// leave token as-is on lookup failure
}
}
return token;
},
session({ session, token }) {
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { generateRequestId } from "./logger";
describe("generateRequestId", () => {
it("produces a non-empty string", () => {
const id = generateRequestId();
expect(id).toBeTruthy();
expect(typeof id).toBe("string");
});
it("produces unique values on successive calls", () => {
const a = generateRequestId();
const b = generateRequestId();
expect(a).not.toBe(b);
});
});
+71
View File
@@ -0,0 +1,71 @@
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
requestId?: string;
module?: string;
[key: string]: unknown;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const currentLevel: LogLevel =
(process.env.LOG_LEVEL as LogLevel) ?? (process.env.NODE_ENV === "production" ? "info" : "debug");
let requestIdCounter = 0;
export function generateRequestId(): string {
requestIdCounter += 1;
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function formatLog(entry: LogEntry): string {
return JSON.stringify(entry);
}
function writeLog(entry: LogEntry): void {
if (!shouldLog(entry.level)) return;
const formatted = formatLog(entry);
switch (entry.level) {
case "error":
console.error(formatted);
break;
case "warn":
console.warn(formatted);
break;
default:
console.log(formatted);
break;
}
}
export const logger = {
debug(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta });
},
info(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta });
},
warn(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta });
},
error(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta });
},
};