fix: resolve auth security issues - 2FA require TOTP on disable, rate limiting, timing-safe login, token expiry check

This commit is contained in:
remco committed 2026-07-02 14:54:25 +02:00
1 parent 4a06b30263
commit 64f50b2dde
6 files changed
+63 -9

No files matched your search

+12 -1
View File
@@ -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,
+4 -2
View File
@@ -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). */
+27 -1
View File
@@ -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<number> {
@@ -31,6 +32,9 @@ export async function beginTwoFactor(): Promise<void> {
export async function confirmTwoFactor(formData: FormData): Promise<void> {
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<void> {
redirect("/settings/2fa?enabled=1");
}
export async function disableTwoFactor(): Promise<void> {
export async function disableTwoFactor(formData: FormData): Promise<void> {
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: {