security: constant-time authorize + uniform audit summaries (#40)
Prevent user-enumeration via login-response timing and audit-log content. All failing branches now run argon2.verify against a precomputed dummy hash (discarding the result), and emit a single "Login failed" audit summary. Detailed reason stays in the server-only pino logger. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,15 @@ import { authConfig } from "./auth.config.js";
|
||||
|
||||
assertSecureRuntimeEnv();
|
||||
|
||||
// Precomputed argon2id hash of a random string we do not retain. Used to run a
|
||||
// dummy verify() when the user does not exist (or has no password hash) so the
|
||||
// code path takes the same wall-clock time as a real failed-login for a
|
||||
// known user. Without this, an attacker can enumerate valid accounts by
|
||||
// measuring how fast "email not found" returns vs. "password wrong"
|
||||
// (EAPPS 3.2.7.05 / OWASP ASVS 2.2.1).
|
||||
const DUMMY_ARGON2_HASH =
|
||||
"$argon2id$v=19$m=65536,t=3,p=4$dFRrYlpCaTMzd1lHeFMwTw$wZcMWHRxxOy2trvRfOjjKzYP/VQ2k+D01FA54zUlfUw";
|
||||
|
||||
// Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded
|
||||
// to the client via SignInResponse.code — plain Error throws become
|
||||
// CallbackRouteError and the message is never visible to the client.
|
||||
@@ -88,7 +97,16 @@ const config = {
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Always run argon2.verify — even when the user doesn't exist or is
|
||||
// deactivated — so all failing branches incur the same CPU cost. The
|
||||
// result from the dummy path is discarded; only the shape of the
|
||||
// audit log / return value changes. Summaries are kept uniform
|
||||
// ("Login failed") so audit-log contents cannot be used to
|
||||
// enumerate accounts either; the reason stays in the server-only
|
||||
// logger.warn.
|
||||
if (!user?.passwordHash) {
|
||||
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
|
||||
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
@@ -96,13 +114,14 @@ const config = {
|
||||
entityId: email.toLowerCase(),
|
||||
entityName: email,
|
||||
action: "CREATE",
|
||||
summary: "Login failed — user not found",
|
||||
summary: "Login failed",
|
||||
source: "ui",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
|
||||
logger.warn(
|
||||
{ email, userId: user.id, reason: "account_deactivated" },
|
||||
"Login blocked — account deactivated",
|
||||
@@ -114,7 +133,7 @@ const config = {
|
||||
entityName: user.email,
|
||||
action: "CREATE",
|
||||
userId: user.id,
|
||||
summary: "Login blocked — account deactivated",
|
||||
summary: "Login failed",
|
||||
source: "ui",
|
||||
});
|
||||
return null;
|
||||
@@ -123,7 +142,6 @@ const config = {
|
||||
const isValid = await verify(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
|
||||
// Audit failed login (bad password)
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
entityType: "Auth",
|
||||
@@ -131,7 +149,7 @@ const config = {
|
||||
entityName: user.email,
|
||||
action: "CREATE",
|
||||
userId: user.id,
|
||||
summary: "Login failed — invalid password",
|
||||
summary: "Login failed",
|
||||
source: "ui",
|
||||
});
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user