fix: resolve auth security issues - 2FA require TOTP on disable, rate limiting, timing-safe login, token expiry check
This commit is contained in:
1 parent
4a06b30263
commit
64f50b2dde
6 files changed
+63
-9
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||||
@@ -18,6 +19,8 @@ export async function precheckLogin(
|
|||||||
const p = String(password ?? "");
|
const p = String(password ?? "");
|
||||||
if (!u || !p) return "invalid";
|
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;
|
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
|
||||||
try {
|
try {
|
||||||
user = await prisma.user.findUnique({
|
user = await prisma.user.findUnique({
|
||||||
@@ -27,7 +30,15 @@ export async function precheckLogin(
|
|||||||
} catch {
|
} catch {
|
||||||
return "invalid";
|
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, {
|
const res = await checkLogin(p, user.password, {
|
||||||
convertPasswords: env.CONVERT_PASSWORDS,
|
convertPasswords: env.CONVERT_PASSWORDS,
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
// The token is therefore deterministic per (email, secret) pair and stays valid
|
// 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).
|
// 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 {
|
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). */
|
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
|||||||
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
async function sessionUserId(): Promise<number> {
|
async function sessionUserId(): Promise<number> {
|
||||||
@@ -31,6 +32,9 @@ export async function beginTwoFactor(): Promise<void> {
|
|||||||
export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||||
const id = await sessionUserId();
|
const id = await sessionUserId();
|
||||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
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 code = String(formData.get("code") ?? "").trim();
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -53,8 +57,30 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
|||||||
redirect("/settings/2fa?enabled=1");
|
redirect("/settings/2fa?enabled=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableTwoFactor(): Promise<void> {
|
export async function disableTwoFactor(formData: FormData): Promise<void> {
|
||||||
const id = await sessionUserId();
|
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({
|
await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export default async function TwoFactorPage({
|
|||||||
{t("badCode")}
|
{t("badCode")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sp.error === "ratelimit" ? (
|
||||||
|
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
|
||||||
|
{t("rateLimit")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!hasAppKey ? (
|
{!hasAppKey ? (
|
||||||
<p style={{ margin: 0 }}>
|
<p style={{ margin: 0 }}>
|
||||||
@@ -84,7 +89,8 @@ export default async function TwoFactorPage({
|
|||||||
<p style={{ marginTop: 0 }}>
|
<p style={{ marginTop: 0 }}>
|
||||||
<strong>{t("isEnabled")}</strong> {t("onYourAccount")}
|
<strong>{t("isEnabled")}</strong> {t("onYourAccount")}
|
||||||
</p>
|
</p>
|
||||||
<form action={disableTwoFactor}>
|
<form action={disableTwoFactor} style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<input name="code" placeholder={t("codePlaceholder")} inputMode="numeric" required />
|
||||||
<button type="submit" className="btn btn-danger">
|
<button type="submit" className="btn btn-danger">
|
||||||
{t("disable")}
|
{t("disable")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+4
-3
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
|||||||
* (the Laravel Sanctum table that already exists in the emulator DB). Tokens are
|
* (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
|
* stored as the sha256 of the plaintext; the client sends the plaintext (or the
|
||||||
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
|
* 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";
|
const TOKENABLE_TYPE = "App\\Models\\User";
|
||||||
|
|
||||||
@@ -27,7 +25,10 @@ export async function bearerUserId(req: Request): Promise<number | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const row = await prisma.personalAccessTokens.findFirst({
|
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 },
|
select: { id: true, tokenableId: true },
|
||||||
});
|
});
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|||||||
+9
-1
@@ -29,7 +29,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null;
|
if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username } });
|
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).
|
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
||||||
const res = await checkLogin(password, user.password, {
|
const res = await checkLogin(password, user.password, {
|
||||||
|
|||||||
Reference in new issue
Block a user