diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index 5cfaf374..60718f62 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -2,6 +2,7 @@ import { checkLogin } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { env } from "@/env"; export type PrecheckResult = "ok" | "invalid" | "twofactor"; @@ -18,6 +19,8 @@ export async function precheckLogin( const p = String(password ?? ""); if (!u || !p) return "invalid"; + if (!rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000).ok) return "invalid"; + let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null; try { user = await prisma.user.findUnique({ @@ -27,7 +30,15 @@ export async function precheckLogin( } catch { return "invalid"; } - if (!user) return "invalid"; + if (!user) { + // Prevent timing-based enumeration: always run a dummy hash check. + await checkLogin( + p, + "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", + { convertPasswords: false }, + ); + return "invalid"; + } const res = await checkLogin(p, user.password, { convertPasswords: env.CONVERT_PASSWORDS, diff --git a/src/actions/email-verify.ts b/src/actions/email-verify.ts index 2c7537a2..5d972a71 100644 --- a/src/actions/email-verify.ts +++ b/src/actions/email-verify.ts @@ -14,9 +14,11 @@ import { siteSettings } from "@/lib/services/site-settings"; // The token is therefore deterministic per (email, secret) pair and stays valid // until the account's mail_verified flips to '1' (after which /verify no-ops). -/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */ +/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */ function verifySecret(): string { - return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify"; + const secret = env.APP_KEY || env.AUTH_SECRET; + if (!secret) throw new Error("APP_KEY or AUTH_SECRET must be set for email verification"); + return secret; } /** Compute the verification token for an email (lowercased + trimmed). */ diff --git a/src/actions/twofactor.ts b/src/actions/twofactor.ts index 8e81e759..90cc5bc9 100644 --- a/src/actions/twofactor.ts +++ b/src/actions/twofactor.ts @@ -6,6 +6,7 @@ import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { env } from "@/env"; async function sessionUserId(): Promise { @@ -31,6 +32,9 @@ export async function beginTwoFactor(): Promise { export async function confirmTwoFactor(formData: FormData): Promise { const id = await sessionUserId(); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); + + if (!rateLimit(`2fa-confirm:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit"); + const code = String(formData.get("code") ?? "").trim(); const user = await prisma.user.findUnique({ @@ -53,8 +57,30 @@ export async function confirmTwoFactor(formData: FormData): Promise { redirect("/settings/2fa?enabled=1"); } -export async function disableTwoFactor(): Promise { +export async function disableTwoFactor(formData: FormData): Promise { const id = await sessionUserId(); + if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); + + if (!rateLimit(`2fa-disable:${id}`, 5, 30_000).ok) redirect("/settings/2fa?error=ratelimit"); + + 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; + } + } + if (!ok) redirect("/settings/2fa?error=badcode"); + await prisma.user.update({ where: { id }, data: { diff --git a/src/app/settings/2fa/page.tsx b/src/app/settings/2fa/page.tsx index dcd63f68..8101c03b 100644 --- a/src/app/settings/2fa/page.tsx +++ b/src/app/settings/2fa/page.tsx @@ -73,6 +73,11 @@ export default async function TwoFactorPage({ {t("badCode")}

) : null} + {sp.error === "ratelimit" ? ( +

+ {t("rateLimit")} +

+ ) : null} {!hasAppKey ? (

@@ -84,7 +89,8 @@ export default async function TwoFactorPage({

{t("isEnabled")} {t("onYourAccount")}

-
+ + diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 4649436f..f2df2aed 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma"; * (the Laravel Sanctum table that already exists in the emulator DB). Tokens are * stored as the sha256 of the plaintext; the client sends the plaintext (or the * Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`. - * - * NOTE: the live amx_test table has NO expires_at column — never read/write it. */ const TOKENABLE_TYPE = "App\\Models\\User"; @@ -27,7 +25,10 @@ export async function bearerUserId(req: Request): Promise { try { const row = await prisma.personalAccessTokens.findFirst({ - where: { token: hashToken(raw) }, + where: { + token: hashToken(raw), + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, select: { id: true, tokenableId: true }, }); if (!row) return null; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fb7dcb45..b8d7e16f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -29,7 +29,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null; const user = await prisma.user.findUnique({ where: { username } }); - if (!user) return null; + if (!user) { + // Prevent timing-based enumeration: always run a dummy hash check. + await checkLogin( + password, + "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", + { convertPasswords: false }, + ); + return null; + } // Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade). const res = await checkLogin(password, user.password, {