- Custom not-found (404) + error / global-error boundaries, styled with
the public design system; raw errors logged, never shown to users.
- In-process rate limiter (src/lib/rate-limit.ts) wired into the abuse-
prone flows: login (10/5min/IP), register (5/10min/IP), password-reset
request (3/15min/IP), keyed by the proxy-forwarded client IP.
- SEO/metadata: root generateMetadata sets a `%s · {hotel}` title
template from the live hotel_name; dynamic generateMetadata on
news/[slug] (article title + excerpt) and u/[username] (name + motto);
static titles on 12 primary public pages.
- env.ts: added the vars introduced since (PASSWORD_HASH, OPENAI_API_KEY,
DISCORD_WEBHOOK_URL, ALERT_EMAIL, PAYPAL_*) so env stays authoritative.
Verified on the prod server: /missing → 404 card, news title renders
"News · Habbo". tsc 0, vitest 49/49, next build 0.
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
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 { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
import { env } from "@/env";
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
trustHost: true,
|
|
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
|
pages: { signIn: "/login" },
|
|
providers: [
|
|
Credentials({
|
|
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();
|
|
const password = String(credentials?.password ?? "");
|
|
if (!username || !password) return null;
|
|
|
|
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
|
if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null;
|
|
|
|
const user = await prisma.user.findUnique({ where: { username } });
|
|
if (!user) return null;
|
|
|
|
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
|
const res = await checkLogin(password, user.password, {
|
|
convertPasswords: env.CONVERT_PASSWORDS,
|
|
});
|
|
if (!res.valid) return null;
|
|
|
|
if (res.upgradedHash) {
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { password: res.upgradedHash },
|
|
});
|
|
}
|
|
|
|
// 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 };
|
|
},
|
|
}),
|
|
// OAuth providers — enabled only when both id + secret are configured.
|
|
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
|
? [Discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET })]
|
|
: []),
|
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
? [Google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET })]
|
|
: []),
|
|
],
|
|
callbacks: {
|
|
async signIn({ user, account }) {
|
|
if (account?.provider === "credentials") return true;
|
|
// OAuth: only allow if a hotel account with this email already exists.
|
|
const email = user.email;
|
|
if (!email) return "/login?error=NoEmail";
|
|
try {
|
|
const dbUser = await prisma.user.findFirst({
|
|
where: { mail: email },
|
|
select: { id: true },
|
|
});
|
|
return dbUser ? true : "/login?error=NoAccount";
|
|
} catch {
|
|
return "/login?error=Unavailable";
|
|
}
|
|
},
|
|
async jwt({ token, user, account }) {
|
|
if (user && account?.provider === "credentials") {
|
|
token.rank = (user as { rank?: number }).rank;
|
|
} else if (user?.email) {
|
|
// OAuth: bind the session to the 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;
|
|
},
|
|
session({ session, token }) {
|
|
if (token.sub && session.user) session.user.id = token.sub;
|
|
if (typeof token.rank === "number" && session.user) session.user.rank = token.rank;
|
|
return session;
|
|
},
|
|
},
|
|
});
|