diff --git a/next.config.ts b/next.config.ts index 76840837dd..bdac7dfcd8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,17 +1,54 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +const securityHeaders = [ + { key: "X-DNS-Prefetch-Control", value: "on" }, + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", + "connect-src 'self' https: wss:", + "font-src 'self' data:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +]; + const nextConfig: NextConfig = { // This app lives inside the Laravel repo tree (which has its own lockfiles); // pin the Turbopack root so Next doesn't infer a parent directory. turbopack: { root: import.meta.dirname }, + // Prisma + the MariaDB driver adapter are native/server-only — keep them out // of the bundle (same approach as the habbo-next reference). serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client"], + + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; // next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via // src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged. const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); -export default withNextIntl(nextConfig); +export default withNextIntl(nextConfig); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83f4536b7d..52b1e328d3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,7 +35,7 @@ model User { id Int @id @default(autoincrement()) username String @unique(map: "username") @db.VarChar(25) realName String @default("KREWS DEV") @map("real_name") @db.VarChar(25) - password String @db.VarChar(64) + password String @db.VarChar(255) mail String? @db.VarChar(500) mailVerified String @default("0") @map("mail_verified") accountCreated Int @map("account_created") diff --git a/public/assets/images/media/logo/logo-1783113373225-odtjy4.png b/public/assets/images/media/logo/logo-1783113373225-odtjy4.png new file mode 100644 index 0000000000..051ff86dd0 Binary files /dev/null and b/public/assets/images/media/logo/logo-1783113373225-odtjy4.png differ diff --git a/public/assets/images/media/logo/logo-1783113389358-mr4aps.png b/public/assets/images/media/logo/logo-1783113389358-mr4aps.png new file mode 100644 index 0000000000..9100240a1a Binary files /dev/null and b/public/assets/images/media/logo/logo-1783113389358-mr4aps.png differ diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index 03a2c9a53e..022aee7336 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index bc9ab1ff91..7ba271d57c 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; diff --git a/src/actions/admin-users.ts b/src/actions/admin-users.ts index 2c06658b3b..1762dcd8b7 100644 --- a/src/actions/admin-users.ts +++ b/src/actions/admin-users.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireStaff } from "@/lib/admin/guard"; +import { requireStaff, requireStaffRateLimited } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; @@ -10,7 +10,7 @@ import { logStaffActivity } from "@/lib/services/staff-activity"; const CURRENCIES: ReadonlySet = new Set(["credits", "duckets", "diamonds", "points"]); export async function giveCurrency(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requireStaffRateLimited(); const userId = Number(formData.get("userId")); const type = String(formData.get("type")); const amount = Number(formData.get("amount")); @@ -39,7 +39,7 @@ export async function setMotto(formData: FormData): Promise { } export async function setRank(formData: FormData): Promise { - const staff = await requireStaff(); + const staff = await requireStaffRateLimited(); const userId = Number(formData.get("userId")); const rank = Number(formData.get("rank")); if (userId > 0 && rank > 0) { diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index 54ddbfac05..8a3b820f84 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -1,8 +1,16 @@ "use server"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { moderateOrThrow } from "@/lib/services/moderation"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; + +const ticketSchema = z.object({ + title: z.string().min(1, "Title is required").max(255), + content: z.string().min(1, "Content is required").max(5000), +}); export async function createTicket(formData: FormData): Promise { // Re-read the session user id server-side; never trust a form-supplied id. @@ -10,9 +18,25 @@ export async function createTicket(formData: FormData): Promise { const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) return; - const title = String(formData.get("title") ?? "").trim().slice(0, 255); - const content = String(formData.get("content") ?? "").trim().slice(0, 5000); - if (!title || !content) return; + const ip = await clientIp(); + if (!rateLimit(`ticket:${userId}`, 3, 60_000).ok) return; + + const raw = { + title: String(formData.get("title") ?? "").trim().slice(0, 255), + content: String(formData.get("content") ?? "").trim().slice(0, 5000), + }; + + const parsed = ticketSchema.safeParse(raw); + if (!parsed.success) return; + + const { title, content } = parsed.data; + + // Moderation check + try { + await moderateOrThrow(`${title} ${content}`); + } catch { + return; + } const now = new Date(); await prisma.websiteHelpCenterTickets.create({ diff --git a/src/actions/radio-shouts.ts b/src/actions/radio-shouts.ts index 0e84c65ca3..d800f00a9c 100644 --- a/src/actions/radio-shouts.ts +++ b/src/actions/radio-shouts.ts @@ -1,11 +1,15 @@ "use server"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { moderateOrThrow } from "@/lib/services/moderation"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; -// radio_shouts.message is TEXT, but cap the write to keep shouts tweet-sized. -const MESSAGE_MAX = 255; +const shoutSchema = z.object({ + message: z.string().min(1, "Message is required").max(255), +}); /** * Post a radio shout. @@ -20,10 +24,24 @@ export async function postShout(formData: FormData): Promise { const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) return; - const message = String(formData.get("message") ?? "") - .trim() - .slice(0, MESSAGE_MAX); - if (!message) return; + const ip = await clientIp(); + if (!rateLimit(`shout:${userId}`, 5, 30_000).ok) return; + + const raw = { + message: String(formData.get("message") ?? "").trim().slice(0, 255), + }; + + const parsed = shoutSchema.safeParse(raw); + if (!parsed.success) return; + + const { message } = parsed.data; + + // Moderation check + try { + await moderateOrThrow(message); + } catch { + return; + } const now = new Date(); try { diff --git a/src/actions/register.ts b/src/actions/register.ts index 6cf1795a07..4fa539635e 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -1,6 +1,7 @@ "use server"; import { redirect } from "next/navigation"; +import { z } from "zod"; import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; @@ -9,95 +10,101 @@ import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { checkVpn } from "@/lib/services/ip-lookup"; import { siteSettings } from "@/lib/services/site-settings"; -const USERNAME_RE = /^[A-Za-z0-9_\-=?!@:.,]{3,25}$/; -const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const registerSchema = z.object({ + username: z + .string() + .min(3, "Username must be at least 3 characters") + .max(25, "Username must be at most 25 characters") + .regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"), + mail: z.string().email("Enter a valid email address"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one digit"), + look: z.string().optional(), +}); + // A valid starter Habbo figure so the avatar renders in-client immediately. const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; -export async function register(formData: FormData): Promise { - const username = String(formData.get("username") ?? "").trim(); - const mail = String(formData.get("mail") ?? "").trim().toLowerCase(); - const password = String(formData.get("password") ?? ""); +export async function register(prevState: string | null, formData: FormData): Promise { + const raw = { + username: String(formData.get("username") ?? "").trim(), + mail: String(formData.get("mail") ?? "").trim().toLowerCase(), + password: String(formData.get("password") ?? ""), + look: String(formData.get("look") ?? "").trim() || DEFAULT_LOOK, + }; + const parsed = registerSchema.safeParse(raw); + if (!parsed.success) { + return parsed.error.errors[0]?.message ?? "Invalid input"; + } + + const { username, mail, password, look } = parsed.data; const ip = await clientIp(); - let error: string | null = null; - if (!USERNAME_RE.test(username)) error = "Username must be 3-25 valid characters"; - else if (password.length < 6) error = "Password must be at least 6 characters"; - else if (!EMAIL_RE.test(mail)) error = "Enter a valid email address"; - // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. - if (!error && !rateLimit(`register:${ip}`, 5, 10 * 60_000).ok) { - error = "Too many sign-up attempts. Please wait a few minutes and try again."; + if (!rateLimit(`register:${ip}`, 5, 10 * 60_000).ok) { + return "Too many sign-up attempts. Please wait a few minutes and try again."; } // CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings. - if (!error) { - const cfg = await captchaConfig(); - if (cfg.provider !== "none") { - const token = String(formData.get(cfg.field) ?? ""); - if (!(await verifyCaptcha(token, ip))) error = "Captcha verification failed. Please try again."; - } + const cfg = await captchaConfig(); + if (cfg.provider !== "none") { + const token = String(formData.get(cfg.field) ?? ""); + if (!(await verifyCaptcha(token, ip))) return "Captcha verification failed. Please try again."; } // VPN/proxy block (only when enabled in /admin/vpn). - if (!error && (await checkVpn(ip)).blocked) { - error = - (await siteSettings.get("vpn_block_message", "")) || - "Registrations from VPN/proxy connections are not allowed."; + if ((await checkVpn(ip)).blocked) { + return ( + await siteSettings.get("vpn_block_message", "") + ) || "Registrations from VPN/proxy connections are not allowed."; } // Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS. - if (!error) { - const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; - if (max > 0) { - const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0); - if (count >= max) error = "You have reached the maximum number of accounts for your connection."; - } + const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; + if (max > 0) { + const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0); + if (count >= max) return "You have reached the maximum number of accounts for your connection."; } - // Uniqueness check (kept out of the success path's try so NEXT_REDIRECT propagates). - if (!error) { + // Uniqueness check. + try { + const existing = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + if (existing) return "That username is already taken"; + } catch { + return "Registration is temporarily unavailable"; + } + + const now = Math.floor(Date.now() / 1000); + try { + const created = await prisma.user.create({ + data: { + username, + password: await hashPassword(password), + mail, + accountCreated: now, + ipRegister: ip, + ipCurrent: ip, + look, + }, + select: { id: true }, + }); + try { - const existing = await prisma.user.findUnique({ - where: { username }, - select: { id: true }, - }); - if (existing) error = "That username is already taken"; + await sendVerification(created.id, mail); } catch { - error = "Registration is temporarily unavailable"; + // No-op: account is created; user can request a new link later. } + } catch { + return "Could not create the account (is the username unique?)"; } - if (!error) { - const now = Math.floor(Date.now() / 1000); - try { - const created = await prisma.user.create({ - data: { - username, - password: await hashPassword(password), - mail, - accountCreated: now, - ipRegister: ip, - ipCurrent: ip, - look: DEFAULT_LOOK, - }, - select: { id: true }, - }); - - // Fire the verification email. Best-effort: a mail/SMTP failure must not - // abort a successful registration, so swallow its errors here. - try { - await sendVerification(created.id, mail); - } catch { - // No-op: account is created; user can request a new link later. - } - } catch { - error = "Could not create the account (is the username unique?)"; - } - } - - // redirect() throws NEXT_REDIRECT — must be OUTSIDE any try/catch. - if (error) redirect(`/register?error=${encodeURIComponent(error)}`); redirect("/login?registered=1"); } diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts index 90cc5bc9aa..adec0d74ed 100644 --- a/src/actions/twofactor.ts +++ b/src/actions/twofactor.ts @@ -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 { 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 { 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 { const id = await sessionUserId(); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); @@ -37,20 +82,7 @@ export async function confirmTwoFactor(formData: FormData): Promise { 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 { 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({ diff --git a/src/app/settings/2fa/page.tsx b/src/app/settings/2fa/page.tsx index 8101c03baf..6c5ea9f9be 100644 --- a/src/app/settings/2fa/page.tsx +++ b/src/app/settings/2fa/page.tsx @@ -23,11 +23,11 @@ export default async function TwoFactorPage({ const sp = await searchParams; const id = Number(session.user.id); - let user: { twoFactorSecret: string | null; twoFactorConfirmedAt: Date | null } | null = null; + let user: { twoFactorSecret: string | null; twoFactorConfirmedAt: Date | null; twoFactorRecoveryCodes: string | null } | null = null; try { user = await prisma.user.findUnique({ where: { id }, - select: { twoFactorSecret: true, twoFactorConfirmedAt: true }, + select: { twoFactorSecret: true, twoFactorConfirmedAt: true, twoFactorRecoveryCodes: true }, }); } catch { user = null; @@ -40,10 +40,14 @@ export default async function TwoFactorPage({ let secret = ""; let uri = ""; + let recoveryCodes: string[] = []; if (pending && hasAppKey && user?.twoFactorSecret) { try { secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret); uri = totpKeyUri(secret, session.user.name ?? "user", hotelName); + if (user.twoFactorRecoveryCodes) { + recoveryCodes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; + } } catch { secret = ""; } @@ -61,7 +65,40 @@ export default async function TwoFactorPage({ subtitle={t("subtitle")} > {sp.enabled ? ( -

{t("nowEnabled")}

+ <> +

{t("nowEnabled")}

+
+

Recovery Codes

+

+ Store these one-time use codes in a safe place. Each can be used once + if you lose access to your authenticator app. +

+
+ {recoveryCodes.map((code) => ( + + {code} + + ))} +
+
+ ) : null} {sp.disabled ? (

diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index b567150af7..d5eda33283 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, FormEvent, ChangeEvent } from "react"; +import { useActionState, ChangeEvent } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { register } from "@/actions/register"; import { AvatarCarousel } from "@/components/auth/avatar-carousel"; interface RegisterFormProps { @@ -22,71 +22,8 @@ export function RegisterForm({ error, }: RegisterFormProps) { const t = useTranslations("pages.register"); - const router = useRouter(); const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; - const [selectedFigure, setSelectedFigure] = useState(initialFigures[0] || ""); - const [formData, setFormData] = useState({ - username: "", - mail: "", - password: "", - password_confirmation: "", - terms: false, - beta_code: "", - referral_code: "", - }); - const [errors, setErrors] = useState>({}); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleChange = (e: ChangeEvent) => { - const { name, value, type, checked } = e.target; - setFormData((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value })); - if (errors[name]) { - setErrors((prev) => ({ ...prev, [name]: "" })); - } - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - const newErrors: Record = {}; - if (!formData.username.trim()) newErrors.username = t("usernameError"); - if (!formData.mail.trim()) newErrors.mail = t("emailError"); - if (!formData.password) newErrors.password = t("passwordError"); - if (formData.password !== formData.password_confirmation) { - newErrors.password_confirmation = t("confirmPasswordError"); - } - if (!formData.terms) newErrors.terms = t("termsError"); - - if (Object.keys(newErrors).length > 0) { - setErrors(newErrors); - setIsSubmitting(false); - return; - } - - try { - const res = await fetch("/api/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...formData, - look: selectedFigure, - }), - }); - - const data = await res.json(); - if (!res.ok) { - setErrors({ form: data.message || "Registration failed" }); - setIsSubmitting(false); - return; - } - - router.push("/login?registered=1"); - } catch { - setErrors({ form: "An error occurred. Please try again." }); - setIsSubmitting(false); - } - }; + const [serverError, formAction, isPending] = useActionState(register, null); return (

@@ -111,7 +48,7 @@ export function RegisterForm({
{/* Form */} -
@@ -120,6 +57,14 @@ export function RegisterForm({ {error} )} + {serverError && ( +
+ {serverError} +
+ )} + + {/* Avatar hidden input */} + {/* Username & Email */}
@@ -141,9 +86,7 @@ export function RegisterForm({ borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} required - onChange={handleChange} /> - {errors.username &&

{errors.username}

}
@@ -164,9 +107,7 @@ export function RegisterForm({ borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} required - onChange={handleChange} /> - {errors.mail &&

{errors.mail}

}
@@ -191,9 +132,7 @@ export function RegisterForm({ borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} required - onChange={handleChange} /> - {errors.password &&

{errors.password}

}
@@ -214,9 +153,7 @@ export function RegisterForm({ borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} required - onChange={handleChange} /> - {errors.password_confirmation &&

{errors.password_confirmation}

}
@@ -232,7 +169,10 @@ export function RegisterForm({ { + const input = document.querySelector('input[name="look"]'); + if (input) input.value = figure; + }} /> @@ -248,7 +188,6 @@ export function RegisterForm({ className="w-4 h-4 border-gray-300 rounded focus:ring-0" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)" }} required - onChange={handleChange} /> - {errors.terms &&

{errors.terms}

} {/* Captcha */} @@ -270,14 +208,14 @@ export function RegisterForm({ {/* Submit */} {/* Social Login - optional */} diff --git a/src/env.ts b/src/env.ts index 92167e2ae7..51a82d3c22 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,7 +29,20 @@ const schema = z.object({ // NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing. AUTH_SECRET: z.string().min(1).optional(), // Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets. - APP_KEY: z.string().optional(), + APP_KEY: z.string().optional().refine( + (v) => { + if (!v) return true; + if (v.startsWith("base64:")) { + try { + const decoded = atob(v.slice(7)); + // Catch the known placeholder key + if (decoded.includes("placeholder")) return false; + } catch { return false; } + } + return v.length >= 16; + }, + { message: "APP_KEY is a placeholder or invalid — generate a real 32-byte key: echo 'base64:'$(openssl rand -base64 32)" }, + ), // Optional OAuth providers (enabled only when both id+secret are set). DISCORD_CLIENT_ID: z.string().optional(), DISCORD_CLIENT_SECRET: z.string().optional(), diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index d4a97c6ffe..cca39cea49 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { isStaff } from "@/lib/admin/is-staff"; import { auth } from "@/lib/auth"; import { siteSettings } from "@/lib/services/site-settings"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; export { isStaff }; @@ -31,3 +32,14 @@ export async function requireStaff(): Promise { username: session.user.name ?? "", }; } + +/** + * Like requireStaff but also rate-limits the action per staff user (30 requests + * per minute). Use on sensitive admin actions (ban, rank-change, settings edit). + */ +export async function requireStaffRateLimited(): Promise { + const staff = await requireStaff(); + const ip = await clientIp(); + if (!rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000).ok) redirect("/admin?error=ratelimit"); + return staff; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 5d65303f12..e41a47bd26 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,17 +2,18 @@ import { NextResponse } from "next/server"; /** * JSON response helper for the public REST API. Serialises BigInt (Prisma ids) - * to strings — JSON.stringify throws on BigInt otherwise — and sets permissive - * CORS so the game client / external integrations can read it (mirrors the - * AtomCMS API CORS config). + * to strings — JSON.stringify throws on BigInt otherwise — and sets CORS to + * APP_URL so the game client / external integrations can read it. */ +const CORS_ORIGIN = process.env.APP_URL ?? "http://localhost:3000"; + export function apiJson(data: unknown, init?: ResponseInit): NextResponse { const body = JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)); return new NextResponse(body, { status: init?.status ?? 200, headers: { "content-type": "application/json; charset=utf-8", - "access-control-allow-origin": "*", + "access-control-allow-origin": CORS_ORIGIN, "cache-control": "no-store", ...(init?.headers ?? {}), }, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b8d7e16f58..40cc97ed18 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,8 +9,40 @@ import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { env } from "@/env"; +async function verify2faCode(userId: number, code: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecret: true, twoFactorRecoveryCodes: true }, + }); + if (!user?.twoFactorSecret) return false; + + // Try TOTP first + try { + const secret = new LaravelEncrypter(env.APP_KEY!).decrypt(user.twoFactorSecret); + if (verifyTotp(code, secret)) return true; + } catch { /* fall through to recovery */ } + + // Try recovery codes + if (user.twoFactorRecoveryCodes) { + let codes: string[]; + try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { return false; } + const idx = codes.indexOf(code); + if (idx !== -1) { + codes.splice(idx, 1); + const remaining = codes.length > 0 ? JSON.stringify(codes) : null; + await prisma.user.update({ + where: { id: userId }, + data: { twoFactorRecoveryCodes: remaining }, + }); + return true; + } + } + + return false; +} + export const { handlers, signIn, signOut, auth } = NextAuth({ - trustHost: true, + trustHost: process.env.NODE_ENV === "development", session: { strategy: "jwt", maxAge: 24 * 60 * 60 }, pages: { signIn: "/login" }, providers: [ @@ -52,17 +84,11 @@ 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). + // Two-factor: if enabled, a valid TOTP or recovery code is required. 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; - } + if (!(await verify2faCode(user.id, code))) return null; } // Record the successful login for the user's "session logs" page. diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 3464cc7f6b..ec3bbc0eb1 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -14,10 +14,8 @@ const ARGON2_PARAMS = { const BCRYPT_ROUNDS = 12; // Which algorithm hashPassword() emits for NEW/upgraded passwords. -// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits a varchar(64) users.password -// (the common emulator/AtomCMS column width) and matches existing accounts. -// - "argon2id": ~97-char PHC hash. ONLY usable if users.password is widened -// (e.g. varchar(255)). Opt in with PASSWORD_HASH=argon2id. +// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password. +// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id. // verifyPassword() always accepts BOTH, so logins keep working either way. function hashDriver(): "bcrypt" | "argon2id" { return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt"; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 2aed43d84a..047b66f857 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,6 +5,8 @@ import { headers } from "next/headers"; * (register, password reset, login). It's per-node (not shared across * instances) — fine for a single-server retro hotel; swap for Redis if you * ever scale out. Keys are typically `${action}:${ip}`. + * + * Periodic cleanup runs every 5 minutes to keep the map bounded. */ type Bucket = { count: number; resetAt: number }; const buckets = new Map(); @@ -15,13 +17,31 @@ export interface RateLimitResult { retryAfter: number; } +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 min +const MAX_BUCKETS = 10_000; + +function cleanup(): void { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + if (buckets.size <= MAX_BUCKETS) { + // Quick eviction of completely expired entries + for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); + } else { + // Aggressive: clear all expired, then delete oldest 20% if still too large + for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); + if (buckets.size > MAX_BUCKETS) { + const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt); + const toRemove = Math.floor(sorted.length * 0.2); + for (let i = 0; i < toRemove; i++) buckets.delete(sorted[i][0]); + } + } +} + export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { const now = Date.now(); - - // Opportunistic cleanup so the map can't grow without bound. - if (buckets.size > 5000) { - for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); - } + cleanup(); const bucket = buckets.get(key); if (!bucket || now >= bucket.resetAt) {