修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)

这个提交包含在:
2026-07-17 14:24:58 +08:00
父节点 bbb5c89825
当前提交 6ddedf1e0e
共修改 31 个文件,包含 3801 行新增和 419 行删除
+5 -2
查看文件
@@ -124,7 +124,7 @@ async function buildApp(dependencies = {}) {
censor: "[REDACTED]",
},
},
trustProxy: true,
trustProxy: config.trustProxy,
genReqId: () => crypto.randomUUID(),
});
@@ -336,7 +336,10 @@ async function buildApp(dependencies = {}) {
app.log.info("Metrics endpoint enabled at /metrics");
}
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(authRoutes, {
prefix: "/api/auth",
emailService: dependencies.authEmailService,
});
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
+115 -10
查看文件
@@ -16,11 +16,110 @@ function normalizeBuildSha(value) {
return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown";
}
function parseBoundedInteger(name, value, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
const normalized = typeof value === "number" ? String(value) : String(value ?? "").trim();
if (!/^\d+$/.test(normalized)) {
throw new Error(`Invalid ${name}`);
}
const parsed = Number(normalized);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`Invalid ${name}`);
}
return parsed;
}
function assertProductionAuthCodePepper(value) {
const isPlaceholder = /^<[^>]+>$/.test(value);
if (isPlaceholder || Buffer.byteLength(value, "utf8") < 32) {
throw new Error("AUTH_CODE_PEPPER must be a non-placeholder secret of at least 32 bytes");
}
}
function getConfig() {
const nodeEnv = process.env.NODE_ENV ?? "development";
const serverRoot = path.resolve(__dirname, "..");
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
const clientDistDir = process.env.CLIENT_DIST_DIR ?? "../client/dist";
const jwtSecret = requiredEnv("JWT_SECRET");
const devShowPasswordResetCode = process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true";
if (nodeEnv === "production" && devShowPasswordResetCode) {
throw new Error("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
}
const authCodePepper = String(process.env.AUTH_CODE_PEPPER ?? "").trim();
if (nodeEnv === "production" && !authCodePepper) {
throw new Error("Missing required env: AUTH_CODE_PEPPER");
}
if (nodeEnv === "production") {
assertProductionAuthCodePepper(authCodePepper);
if (authCodePepper === jwtSecret) {
throw new Error("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
}
}
const trustProxyHopsValue =
process.env.TRUST_PROXY_HOPS ?? (nodeEnv === "production" ? requiredEnv("TRUST_PROXY_HOPS") : "0");
const trustProxyHops = parseBoundedInteger("TRUST_PROXY_HOPS", trustProxyHopsValue, {
min: nodeEnv === "production" ? 1 : 0,
max: nodeEnv === "production" ? 1 : 8,
});
const passwordResetCodeTtlSeconds = parseBoundedInteger(
"PASSWORD_RESET_CODE_TTL_SECONDS",
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS },
);
const passwordResetFailWindowSeconds = parseBoundedInteger(
"PASSWORD_RESET_FAIL_WINDOW_SECONDS",
process.env.PASSWORD_RESET_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetMaxAttempts = parseBoundedInteger(
"PASSWORD_RESET_MAX_ATTEMPTS",
process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5,
{ min: 1, max: 20 },
);
const passwordResetLockSeconds = parseBoundedInteger(
"PASSWORD_RESET_LOCK_SECONDS",
process.env.PASSWORD_RESET_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetRequestWindowSeconds = parseBoundedInteger(
"PASSWORD_RESET_REQUEST_WINDOW_SECONDS",
process.env.PASSWORD_RESET_REQUEST_WINDOW_SECONDS ?? 60 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetRequestMax = parseBoundedInteger(
"PASSWORD_RESET_REQUEST_MAX",
process.env.PASSWORD_RESET_REQUEST_MAX ?? 3,
{ min: 1, max: 20 },
);
const authCodeRequestCooldownSeconds = parseBoundedInteger(
"AUTH_CODE_REQUEST_COOLDOWN_SECONDS",
process.env.AUTH_CODE_REQUEST_COOLDOWN_SECONDS ?? ONE_MINUTE_SECONDS,
{ min: 1, max: ONE_DAY_SECONDS },
);
const emailLoginCodeTtlSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_TTL_SECONDS",
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS },
);
const emailLoginCodeMaxAttempts = parseBoundedInteger(
"EMAIL_LOGIN_CODE_MAX_ATTEMPTS",
process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5,
{ min: 1, max: 20 },
);
const emailLoginCodeFailWindowSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS",
process.env.EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const emailLoginCodeLockSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_LOCK_SECONDS",
process.env.EMAIL_LOGIN_CODE_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
return {
nodeEnv,
@@ -29,10 +128,12 @@ function getConfig() {
buildSha: normalizeBuildSha(process.env.BUILD_SHA),
metricsEnabled: process.env.METRICS_ENABLED !== "false",
trustProxy: trustProxyHops > 0 ? trustProxyHops : false,
appOrigin: process.env.APP_ORIGIN ?? "",
jwtSecret: requiredEnv("JWT_SECRET"),
jwtSecret,
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "",
authCodePepper: authCodePepper || jwtSecret,
accessTokenTtl: "15m",
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
@@ -43,15 +144,19 @@ function getConfig() {
? process.env.COOKIE_SECURE === "true"
: nodeEnv === "production",
passwordResetCodeTtlSeconds: Number(
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeTtlSeconds: Number(
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeMaxAttempts: Number(process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5),
passwordResetCodeTtlSeconds,
passwordResetFailWindowSeconds,
passwordResetMaxAttempts,
passwordResetLockSeconds,
passwordResetRequestWindowSeconds,
passwordResetRequestMax,
authCodeRequestCooldownSeconds,
emailLoginCodeTtlSeconds,
emailLoginCodeMaxAttempts,
emailLoginCodeFailWindowSeconds,
emailLoginCodeLockSeconds,
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
devShowPasswordResetCode,
loginFailWindowSeconds: Number(
process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS,
@@ -209,4 +314,4 @@ function getConfig() {
};
}
module.exports = { getConfig };
module.exports = { getConfig, parseBoundedInteger };
+696 -316
查看文件
文件差异内容过多而无法显示 加载差异
+29 -5
查看文件
@@ -46,11 +46,18 @@ const listPaginationSchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const normalizedEmailSchema = z
.string()
.trim()
.email()
.max(200)
.transform((value) => value.toLowerCase());
const createBodySchema = z.object({
employeeNo: z.string().trim().min(1).max(50).optional(),
name: z.string().min(1).max(100),
phone: z.string().min(6).max(30),
email: z.string().email().max(200),
email: normalizedEmailSchema,
departmentId: z.string().uuid().optional(),
title: z.string().min(1).max(100).optional(),
directManagerId: z.string().uuid().nullable().optional(),
@@ -63,7 +70,7 @@ const createBodySchema = z.object({
const updateBodySchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
phone: z.string().trim().min(6).max(30).optional(),
email: z.string().trim().email().max(200).optional(),
email: normalizedEmailSchema.optional(),
departmentId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(100).optional(),
directManagerId: z.string().uuid().nullable().optional(),
@@ -544,7 +551,7 @@ function buildImportRowDraft(rawRow, defaultHireDate) {
employeeNo: normalizeImportCell(rawRow.data.employeeNo),
name: normalizeImportCell(rawRow.data.name),
phone: normalizeImportCell(rawRow.data.phone),
email: normalizeImportCell(rawRow.data.email),
email: normalizeImportCell(rawRow.data.email).toLowerCase(),
departmentName: normalizeImportCell(rawRow.data.departmentName),
title: normalizeImportCell(rawRow.data.title) || "员工",
directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo),
@@ -934,7 +941,7 @@ async function createSystemUser(tx, options) {
: {}),
name: options.name,
phone: options.phone,
email: options.email,
email: options.email.trim().toLowerCase(),
department_id: options.departmentId,
title: options.title ?? "员工",
direct_manager_id: options.directManagerId ?? null,
@@ -2137,6 +2144,15 @@ async function usersRoutes(fastify, opts = {}) {
login_failed_count: 0,
login_failed_first_at: null,
login_locked_until: null,
email_login_code_failed_count: 0,
email_login_code_failed_first_at: null,
email_login_code_locked_until: null,
password_reset_failed_count: 0,
password_reset_failed_first_at: null,
password_reset_locked_until: null,
password_reset_request_count: 0,
password_reset_request_first_at: null,
password_reset_last_requested_at: null,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
@@ -2144,7 +2160,15 @@ async function usersRoutes(fastify, opts = {}) {
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
data: { revoked_at: now, last_seen_at: now },
});
await tx.passwordResetCode.updateMany({
where: { employee_id: userId, invalidated_at: null },
data: { invalidated_at: now },
});
await tx.emailLoginCode.updateMany({
where: { employee_id: userId, used_at: null },
data: { used_at: now },
});
await tx.auditLog.create({
@@ -0,0 +1,991 @@
const crypto = require("node:crypto");
const { Client } = require("pg");
const { createPrismaClient } = require("../prismaClient");
const {
generateSixDigitCode,
hashAuthCode,
randomRefreshToken,
sha256Hex,
verifyPassword,
} = require("../utils/crypto");
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
const EMPLOYEE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
class AuthConcurrencyCheckError extends Error {
constructor(code) {
super(code);
this.name = "AuthConcurrencyCheckError";
this.safeCode = code;
}
}
function fail(code) {
throw new AuthConcurrencyCheckError(code);
}
function requireValue(env, name) {
const value = String(env[name] ?? "").trim();
if (!value) fail(`${name.toLowerCase()}_required`);
return value;
}
function validateAuthRegressionEnvironment(env = process.env) {
if (env.RUN_AUTH_SECURITY_REGRESSION !== "1") fail("regression_opt_in_required");
if (env.NODE_ENV !== "test") fail("test_environment_required");
if (env.RATE_LIMIT_ENABLED !== "false") fail("rate_limit_must_be_disabled");
if (env.DEV_SHOW_PASSWORD_RESET_CODE !== "false") fail("code_logging_forbidden");
if (env.TRUST_PROXY_HOPS !== "0") fail("trust_proxy_must_be_disabled");
if (env.EMAIL_ENABLED !== "false") fail("email_provider_must_be_disabled");
if (String(env.SMTP_HOST ?? "").trim()) fail("smtp_host_forbidden");
if (String(env.SMTP_USER ?? "").trim() || String(env.SMTP_PASS ?? "").trim()) {
fail("smtp_credentials_forbidden");
}
if (env.IM_NOTIFIER_ENABLED !== "false") fail("im_notifier_must_be_disabled");
if (env.WECOM_ENABLED !== "false") fail("wecom_must_be_disabled");
if (String(env.WECOM_AGENT_SECRET ?? "").trim()) fail("wecom_secret_forbidden");
if (env.OIDC_ENABLED !== "false") fail("oidc_must_be_disabled");
if (String(env.OIDC_CLIENT_SECRET ?? "").trim()) fail("oidc_secret_forbidden");
const databaseUrl = requireValue(env, "DATABASE_URL");
let parsedDatabaseUrl;
try {
parsedDatabaseUrl = new URL(databaseUrl);
} catch {
fail("database_url_invalid");
}
if (!["postgres:", "postgresql:"].includes(parsedDatabaseUrl.protocol)) {
fail("database_protocol_invalid");
}
const pgConfig = new Client({ connectionString: databaseUrl });
const databaseHost = String(pgConfig.host ?? "").toLowerCase();
const databaseName = String(pgConfig.database ?? "");
if (!LOOPBACK_HOSTS.has(databaseHost)) fail("database_host_not_loopback");
if (!/^hyb_oa_auth_[A-Za-z0-9_]+$/.test(databaseName)) fail("database_name_not_isolated");
if (requireValue(env, "AUTH_SECURITY_DATABASE_CONFIRM") !== databaseName) {
fail("database_confirmation_mismatch");
}
const baseUrl = requireValue(env, "BASE_URL");
let parsedBaseUrl;
try {
parsedBaseUrl = new URL(baseUrl);
} catch {
fail("base_url_invalid");
}
if (parsedBaseUrl.protocol !== "http:" || !LOOPBACK_HOSTS.has(parsedBaseUrl.hostname)) {
fail("base_url_not_loopback");
}
const pepper = requireValue(env, "AUTH_CODE_PEPPER");
if (/^<[^>]+>$/.test(pepper) || Buffer.byteLength(pepper, "utf8") < 32) {
fail("auth_code_pepper_weak");
}
const jwtSecret = requireValue(env, "JWT_SECRET");
if (jwtSecret === pepper) fail("auth_code_pepper_not_independent");
requireValue(env, "FIELD_ENCRYPTION_KEY");
return {
baseUrl: parsedBaseUrl.origin,
databaseUrl,
pepper,
};
}
function validateAuthConcurrencyEnvironment(env = process.env) {
if (env.RUN_AUTH_POSTGRES_CONCURRENCY !== "1") fail("explicit_opt_in_required");
const baseContext = validateAuthRegressionEnvironment(env);
const employeeId = requireValue(env, "AUTH_SECURITY_EMPLOYEE_ID");
if (!EMPLOYEE_ID_PATTERN.test(employeeId)) fail("employee_id_invalid");
return {
...baseContext,
employeeId,
employeeEmail: requireValue(env, "AUTH_SECURITY_EMPLOYEE_EMAIL"),
currentPassword: requireValue(env, "AUTH_SECURITY_CURRENT_PASSWORD"),
};
}
function createStrongPassword(prefix) {
return `${prefix}A1!${crypto.randomBytes(12).toString("hex")}`;
}
function assertCondition(condition, code) {
if (!condition) fail(code);
}
async function postJson(baseUrl, path, payload, { headers = {}, signal } = {}) {
let response;
try {
response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify(payload),
signal,
});
} catch {
fail("http_request_failed");
}
let error = null;
if (response.status !== 200) {
try {
const body = await response.json();
error = {
code: body?.error?.code ?? null,
detailsCode: body?.error?.details?.code ?? null,
};
} catch {
error = null;
}
}
return {
status: response.status,
error,
setCookie: Boolean(response.headers.get("set-cookie")),
};
}
function extractRefreshCookie(response) {
const setCookie = String(response.headers.get("set-cookie") ?? "");
const match = setCookie.match(/(?:^|,\s*)refresh_token=([^;,\s]+)/);
return match ? `refresh_token=${match[1]}` : null;
}
async function loginForRace({ baseUrl, email, password, fingerprint, signal }) {
let response;
try {
response = await fetch(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": fingerprint,
},
body: JSON.stringify({ identifier: email, password }),
signal,
});
} catch {
fail("login_http_request_failed");
}
let accessToken = null;
let error = null;
try {
const body = await response.json();
if (response.status === 200) {
accessToken = typeof body?.data?.accessToken === "string" ? body.data.accessToken : null;
} else {
error = {
code: body?.error?.code ?? null,
detailsCode: body?.error?.details?.code ?? null,
};
}
} catch {
error = null;
}
return {
status: response.status,
accessToken,
refreshCookie: extractRefreshCookie(response),
error,
};
}
async function getWithAccessToken(baseUrl, path, accessToken) {
let response;
try {
response = await fetch(`${baseUrl}${path}`, {
headers: { authorization: `Bearer ${accessToken}` },
});
} catch {
fail("access_token_probe_failed");
}
let errorCode = null;
if (response.status !== 200) {
try {
const body = await response.json();
errorCode = body?.error?.code ?? null;
} catch {
errorCode = null;
}
}
return { status: response.status, errorCode };
}
async function preparePasswordResetChallenge({
prisma,
employeeId,
pepper,
includeSession,
}) {
const now = new Date();
const expiresAt = new Date(now.getTime() + 10 * 60 * 1000);
const code = generateSixDigitCode();
const emailCode = generateSixDigitCode();
const codeHash = hashAuthCode({
employeeId,
code,
purpose: "password_reset",
pepper,
});
const emailCodeHash = hashAuthCode({
employeeId,
code: emailCode,
purpose: "email_login",
pepper,
});
const refreshToken = includeSession ? randomRefreshToken() : null;
const created = await prisma.$transaction(async (tx) => {
await tx.employee.update({
where: { id: employeeId },
data: {
password_reset_failed_count: 0,
password_reset_failed_first_at: null,
password_reset_locked_until: null,
password_reset_request_count: 0,
password_reset_request_first_at: null,
password_reset_last_requested_at: null,
email_login_code_failed_count: 0,
email_login_code_failed_first_at: null,
email_login_code_locked_until: null,
email_login_code_last_requested_at: null,
},
});
await tx.passwordResetCode.updateMany({
where: { employee_id: employeeId, invalidated_at: null },
data: { invalidated_at: now },
});
await tx.emailLoginCode.updateMany({
where: { employee_id: employeeId, used_at: null },
data: { used_at: now },
});
const resetCode = await tx.passwordResetCode.create({
data: {
employee_id: employeeId,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: "127.0.0.1",
created_user_agent: "auth-concurrency-check",
},
select: { id: true },
});
const loginCode = await tx.emailLoginCode.create({
data: {
employee_id: employeeId,
code_hash: emailCodeHash,
expires_at: expiresAt,
created_ip: "127.0.0.1",
created_user_agent: "auth-concurrency-check",
},
select: { id: true },
});
let refreshSessionId = null;
if (refreshToken) {
const refreshSession = await tx.authSession.create({
data: {
employee_id: employeeId,
refresh_token_hash: sha256Hex(refreshToken),
device_fingerprint: "auth-concurrency-check",
ip_address: "127.0.0.1",
user_agent: "auth-concurrency-check",
expires_at: new Date(now.getTime() + 60 * 60 * 1000),
},
select: { id: true },
});
refreshSessionId = refreshSession.id;
}
return { resetCodeId: resetCode.id, emailCodeId: loginCode.id, refreshSessionId };
});
return { ...created, code, refreshToken };
}
async function getEmployeeSecurityState(prisma, employeeId) {
return prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
email: true,
status: true,
password_hash: true,
token_version: true,
password_reset_failed_count: true,
password_reset_locked_until: true,
},
});
}
async function countAudit(prisma, employeeId, action) {
return prisma.auditLog.count({
where: { actor_id: employeeId, action },
});
}
async function runConcurrentForgotPasswordRequests({ prisma, context }) {
const preparedAt = new Date();
await prisma.$transaction(async (tx) => {
await tx.employee.update({
where: { id: context.employeeId },
data: {
password_reset_failed_count: 0,
password_reset_failed_first_at: null,
password_reset_locked_until: null,
password_reset_request_count: 0,
password_reset_request_first_at: null,
password_reset_last_requested_at: null,
},
});
await tx.passwordResetCode.updateMany({
where: { employee_id: context.employeeId, invalidated_at: null },
data: { invalidated_at: preparedAt },
});
});
const requestedAuditBefore = await countAudit(
prisma,
context.employeeId,
"AUTH_PASSWORD_RESET_REQUESTED",
);
const responses = await Promise.all(
Array.from({ length: 20 }, () =>
postJson(context.baseUrl, "/api/auth/forgot-password", {
email: context.employeeEmail,
}),
),
);
assertCondition(
responses.every((response) => response.status === 200 && !response.setCookie),
"forgot_concurrent_response_invalid",
);
const [employee, activeCodes, requestedAuditAfter] = await Promise.all([
prisma.employee.findUnique({
where: { id: context.employeeId },
select: {
password_reset_failed_count: true,
password_reset_locked_until: true,
password_reset_request_count: true,
password_reset_request_first_at: true,
password_reset_last_requested_at: true,
},
}),
prisma.passwordResetCode.count({
where: {
employee_id: context.employeeId,
used_at: null,
invalidated_at: null,
expires_at: { gt: new Date() },
},
}),
countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_REQUESTED"),
]);
assertCondition(activeCodes === 1, "forgot_concurrent_active_code_count_invalid");
assertCondition(employee?.password_reset_request_count === 1, "forgot_request_count_invalid");
assertCondition(
employee?.password_reset_request_first_at instanceof Date &&
employee?.password_reset_last_requested_at instanceof Date,
"forgot_request_timestamps_missing",
);
assertCondition(employee?.password_reset_failed_count === 0, "forgot_failure_count_changed");
assertCondition(employee?.password_reset_locked_until === null, "forgot_account_locked");
assertCondition(
requestedAuditAfter === requestedAuditBefore + 1,
"forgot_request_audit_count_invalid",
);
}
async function runSameCodeConcurrency({ prisma, context }) {
const before = await getEmployeeSecurityState(prisma, context.employeeId);
assertCondition(before?.email === context.employeeEmail, "employee_identity_mismatch");
assertCondition(before.status === "active", "employee_not_active");
assertCondition(
await verifyPassword(context.currentPassword, before.password_hash),
"current_password_mismatch",
);
const challenge = await preparePasswordResetChallenge({
prisma,
employeeId: context.employeeId,
pepper: context.pepper,
includeSession: true,
});
const successAuditBefore = await countAudit(
prisma,
context.employeeId,
"AUTH_PASSWORD_RESET_SUCCEEDED",
);
const newPassword = createStrongPassword("Concurrent");
const responses = await Promise.all(
Array.from({ length: 20 }, () =>
postJson(context.baseUrl, "/api/auth/reset-password", {
email: context.employeeEmail,
code: challenge.code,
newPassword,
}),
),
);
const successes = responses.filter((response) => response.status === 200);
const failures = responses.filter((response) => response.status === 400);
if (successes.length !== 1) {
if (
responses.every(
(response) =>
response.status === 400 &&
response.error?.code === "VALID_001" &&
response.error?.detailsCode === "invalid_or_expired",
)
) {
const [rejectedCode, rejectedEmployee, activeCodeCount] = await Promise.all([
prisma.passwordResetCode.findUnique({
where: { id: challenge.resetCodeId },
select: { attempts_count: true, used_at: true, invalidated_at: true },
}),
prisma.employee.findUnique({
where: { id: context.employeeId },
select: {
password_reset_failed_count: true,
password_reset_locked_until: true,
},
}),
prisma.passwordResetCode.count({
where: {
employee_id: context.employeeId,
used_at: null,
invalidated_at: null,
expires_at: { gt: new Date() },
},
}),
]);
if (Number(rejectedCode?.attempts_count ?? 0) > 0) {
fail("concurrent_code_hash_mismatch");
}
if (rejectedEmployee?.password_reset_locked_until instanceof Date) {
fail("concurrent_account_prelocked");
}
if (activeCodeCount === 0) {
fail("concurrent_code_not_active");
}
fail("concurrent_active_code_not_selected");
}
if (
responses.every(
(response) =>
response.status === 400 &&
response.error?.code === "VALID_001" &&
response.error?.detailsCode === null,
)
) {
fail("concurrent_all_payloads_rejected");
}
fail("concurrent_success_count_invalid");
}
assertCondition(failures.length === 19, "concurrent_failure_count_invalid");
assertCondition(
failures.every(
(response) =>
response.error?.code === "VALID_001" &&
response.error?.detailsCode === "invalid_or_expired" &&
!response.setCookie,
),
"concurrent_failure_contract_invalid",
);
const after = await getEmployeeSecurityState(prisma, context.employeeId);
assertCondition(after.token_version === before.token_version + 1, "token_version_increment_invalid");
assertCondition(await verifyPassword(newPassword, after.password_hash), "new_password_not_active");
assertCondition(
!(await verifyPassword(context.currentPassword, after.password_hash)),
"old_password_still_active",
);
assertCondition(after.password_reset_failed_count === 0, "concurrent_loser_failure_count_changed");
assertCondition(after.password_reset_locked_until === null, "concurrent_losers_locked_account");
const [resetCode, emailCode, activeResetCodes, activeSessions, successAuditAfter] =
await Promise.all([
prisma.passwordResetCode.findUnique({
where: { id: challenge.resetCodeId },
select: { used_at: true, invalidated_at: true },
}),
prisma.emailLoginCode.findUnique({
where: { id: challenge.emailCodeId },
select: { used_at: true },
}),
prisma.passwordResetCode.count({
where: {
employee_id: context.employeeId,
used_at: null,
invalidated_at: null,
expires_at: { gt: new Date() },
},
}),
prisma.authSession.count({
where: {
employee_id: context.employeeId,
revoked_at: null,
expires_at: { gt: new Date() },
},
}),
countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"),
]);
assertCondition(resetCode?.used_at instanceof Date, "reset_code_not_consumed");
assertCondition(resetCode?.invalidated_at instanceof Date, "reset_code_not_invalidated");
assertCondition(emailCode?.used_at instanceof Date, "email_code_not_invalidated");
assertCondition(activeResetCodes === 0, "active_reset_code_remains");
assertCondition(activeSessions === 0, "active_session_remains");
assertCondition(successAuditAfter === successAuditBefore + 1, "success_audit_count_invalid");
return newPassword;
}
async function waitForBlockedConnections(observer, excludedPids, expectedCount, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await observer.query(
`SELECT COUNT(*)::int AS count
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> $1
AND pid <> $2
AND cardinality(pg_blocking_pids(pid)) > 0`,
excludedPids,
);
if (Number(result.rows[0]?.count ?? 0) >= expectedCount) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
fail("blocked_connection_order_timeout");
}
async function withTimeout(promise, timeoutMs, code) {
let timeout;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new AuthConcurrencyCheckError(code)), timeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
}
async function runLoginResetRace({ prisma, context, currentPassword }) {
const before = await getEmployeeSecurityState(prisma, context.employeeId);
assertCondition(
await verifyPassword(currentPassword, before.password_hash),
"race_current_password_mismatch",
);
const challenge = await preparePasswordResetChallenge({
prisma,
employeeId: context.employeeId,
pepper: context.pepper,
includeSession: false,
});
const successAuditBefore = await countAudit(
prisma,
context.employeeId,
"AUTH_PASSWORD_RESET_SUCCEEDED",
);
const loginAuditBefore = await countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS");
const racePassword = createStrongPassword("RaceReset");
const holder = new Client({ connectionString: context.databaseUrl });
const observer = new Client({ connectionString: context.databaseUrl });
const resetAbort = new AbortController();
const loginAbort = new AbortController();
let holderInTransaction = false;
let resetPromise = null;
let loginPromise = null;
try {
await holder.connect();
await observer.connect();
const holderPid = holder.processID;
const observerPid = observer.processID;
await holder.query("BEGIN");
holderInTransaction = true;
await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [
context.employeeId,
]);
resetPromise = postJson(
context.baseUrl,
"/api/auth/reset-password",
{
email: context.employeeEmail,
code: challenge.code,
newPassword: racePassword,
},
{ signal: resetAbort.signal },
);
await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000);
loginPromise = postJson(
context.baseUrl,
"/api/auth/login",
{
identifier: context.employeeEmail,
password: currentPassword,
},
{
headers: { "x-device-fingerprint": "auth-reset-race" },
signal: loginAbort.signal,
},
);
await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000);
await holder.query("COMMIT");
holderInTransaction = false;
const [resetResponse, loginResponse] = await Promise.all([
withTimeout(resetPromise, 30_000, "race_reset_timeout"),
withTimeout(loginPromise, 30_000, "race_login_timeout"),
]);
assertCondition(resetResponse.status === 200, "race_reset_failed");
assertCondition(
loginResponse.status === 401 &&
loginResponse.error?.code === "AUTH_001" &&
!loginResponse.setCookie,
"stale_login_was_accepted",
);
} finally {
resetAbort.abort();
loginAbort.abort();
if (holderInTransaction) {
await holder.query("ROLLBACK").catch(() => undefined);
}
await Promise.allSettled([resetPromise, loginPromise].filter(Boolean));
await holder.end().catch(() => undefined);
await observer.end().catch(() => undefined);
}
const after = await getEmployeeSecurityState(prisma, context.employeeId);
const [activeSessions, successAuditAfter, loginAuditAfter] = await Promise.all([
prisma.authSession.count({
where: {
employee_id: context.employeeId,
revoked_at: null,
expires_at: { gt: new Date() },
},
}),
countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"),
countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"),
]);
assertCondition(after.token_version === before.token_version + 1, "race_token_version_invalid");
assertCondition(await verifyPassword(racePassword, after.password_hash), "race_password_not_active");
assertCondition(
!(await verifyPassword(currentPassword, after.password_hash)),
"race_old_password_still_active",
);
assertCondition(activeSessions === 0, "race_active_session_remains");
assertCondition(successAuditAfter === successAuditBefore + 1, "race_success_audit_invalid");
assertCondition(loginAuditAfter === loginAuditBefore, "race_login_success_audit_created");
return racePassword;
}
async function runLoginFirstResetRace({ prisma, context, currentPassword }) {
const before = await getEmployeeSecurityState(prisma, context.employeeId);
assertCondition(
await verifyPassword(currentPassword, before.password_hash),
"login_first_current_password_mismatch",
);
const challenge = await preparePasswordResetChallenge({
prisma,
employeeId: context.employeeId,
pepper: context.pepper,
includeSession: false,
});
const successAuditBefore = await countAudit(
prisma,
context.employeeId,
"AUTH_PASSWORD_RESET_SUCCEEDED",
);
const loginAuditBefore = await countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS");
const nextPassword = createStrongPassword("LoginFirstReset");
const fingerprint = "auth-login-first-reset";
const holder = new Client({ connectionString: context.databaseUrl });
const observer = new Client({ connectionString: context.databaseUrl });
const loginAbort = new AbortController();
const resetAbort = new AbortController();
let holderInTransaction = false;
let loginPromise = null;
let resetPromise = null;
let loginResponse = null;
let resetResponse = null;
try {
await holder.connect();
await observer.connect();
const holderPid = holder.processID;
const observerPid = observer.processID;
await holder.query("BEGIN");
holderInTransaction = true;
await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [
context.employeeId,
]);
loginPromise = loginForRace({
baseUrl: context.baseUrl,
email: context.employeeEmail,
password: currentPassword,
fingerprint,
signal: loginAbort.signal,
});
await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000);
resetPromise = postJson(
context.baseUrl,
"/api/auth/reset-password",
{
email: context.employeeEmail,
code: challenge.code,
newPassword: nextPassword,
},
{ signal: resetAbort.signal },
);
await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000);
await holder.query("COMMIT");
holderInTransaction = false;
[loginResponse, resetResponse] = await Promise.all([
withTimeout(loginPromise, 30_000, "login_first_login_timeout"),
withTimeout(resetPromise, 30_000, "login_first_reset_timeout"),
]);
assertCondition(
loginResponse.status === 200 &&
typeof loginResponse.accessToken === "string" &&
typeof loginResponse.refreshCookie === "string",
"login_first_login_failed",
);
assertCondition(resetResponse.status === 200, "login_first_reset_failed");
} finally {
loginAbort.abort();
resetAbort.abort();
if (holderInTransaction) {
await holder.query("ROLLBACK").catch(() => undefined);
}
await Promise.allSettled([loginPromise, resetPromise].filter(Boolean));
await holder.end().catch(() => undefined);
await observer.end().catch(() => undefined);
}
const [accessProbe, refreshProbe] = await Promise.all([
getWithAccessToken(context.baseUrl, "/api/me", loginResponse.accessToken),
postJson(
context.baseUrl,
"/api/auth/refresh",
{},
{
headers: {
cookie: loginResponse.refreshCookie,
origin: context.baseUrl,
"x-device-fingerprint": fingerprint,
},
},
),
]);
assertCondition(
accessProbe.status === 401 && accessProbe.errorCode === "AUTH_004",
"login_first_old_access_token_active",
);
assertCondition(
refreshProbe.status === 401 && refreshProbe.error?.code === "AUTH_005",
"login_first_old_refresh_token_active",
);
const after = await getEmployeeSecurityState(prisma, context.employeeId);
const [activeSessions, successAuditAfter, loginAuditAfter] = await Promise.all([
prisma.authSession.count({
where: {
employee_id: context.employeeId,
revoked_at: null,
expires_at: { gt: new Date() },
},
}),
countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"),
countAudit(prisma, context.employeeId, "AUTH_LOGIN_SUCCESS"),
]);
assertCondition(
after.token_version === before.token_version + 1,
"login_first_token_version_invalid",
);
assertCondition(await verifyPassword(nextPassword, after.password_hash), "login_first_password_not_active");
assertCondition(activeSessions === 0, "login_first_session_not_revoked");
assertCondition(successAuditAfter === successAuditBefore + 1, "login_first_reset_audit_invalid");
assertCondition(loginAuditAfter === loginAuditBefore + 1, "login_first_login_audit_invalid");
return nextPassword;
}
async function runRefreshResetRace({ prisma, context, currentPassword }) {
const before = await getEmployeeSecurityState(prisma, context.employeeId);
assertCondition(
await verifyPassword(currentPassword, before.password_hash),
"refresh_race_current_password_mismatch",
);
const challenge = await preparePasswordResetChallenge({
prisma,
employeeId: context.employeeId,
pepper: context.pepper,
includeSession: true,
});
assertCondition(
typeof challenge.refreshToken === "string" && typeof challenge.refreshSessionId === "string",
"refresh_race_session_missing",
);
const successAuditBefore = await countAudit(
prisma,
context.employeeId,
"AUTH_PASSWORD_RESET_SUCCEEDED",
);
const nextPassword = createStrongPassword("RefreshReset");
const holder = new Client({ connectionString: context.databaseUrl });
const observer = new Client({ connectionString: context.databaseUrl });
const resetAbort = new AbortController();
const refreshAbort = new AbortController();
let holderInTransaction = false;
let resetPromise = null;
let refreshPromise = null;
try {
await holder.connect();
await observer.connect();
const holderPid = holder.processID;
const observerPid = observer.processID;
await holder.query("BEGIN");
holderInTransaction = true;
await holder.query('SELECT id FROM "employees" WHERE id = $1::uuid FOR UPDATE', [
context.employeeId,
]);
resetPromise = postJson(
context.baseUrl,
"/api/auth/reset-password",
{
email: context.employeeEmail,
code: challenge.code,
newPassword: nextPassword,
},
{ signal: resetAbort.signal },
);
await waitForBlockedConnections(observer, [holderPid, observerPid], 1, 15_000);
refreshPromise = postJson(
context.baseUrl,
"/api/auth/refresh",
{},
{
headers: {
cookie: `refresh_token=${challenge.refreshToken}`,
origin: context.baseUrl,
"x-device-fingerprint": "auth-concurrency-check",
},
signal: refreshAbort.signal,
},
);
await waitForBlockedConnections(observer, [holderPid, observerPid], 2, 20_000);
await holder.query("COMMIT");
holderInTransaction = false;
const [resetResponse, refreshResponse] = await Promise.all([
withTimeout(resetPromise, 30_000, "refresh_race_reset_timeout"),
withTimeout(refreshPromise, 30_000, "refresh_race_refresh_timeout"),
]);
assertCondition(resetResponse.status === 200, "refresh_race_reset_failed");
assertCondition(
refreshResponse.status === 401 && refreshResponse.error?.code === "AUTH_005",
"refresh_rotated_after_reset",
);
} finally {
resetAbort.abort();
refreshAbort.abort();
if (holderInTransaction) {
await holder.query("ROLLBACK").catch(() => undefined);
}
await Promise.allSettled([resetPromise, refreshPromise].filter(Boolean));
await holder.end().catch(() => undefined);
await observer.end().catch(() => undefined);
}
const after = await getEmployeeSecurityState(prisma, context.employeeId);
const [session, activeSessions, successAuditAfter] = await Promise.all([
prisma.authSession.findUnique({
where: { id: challenge.refreshSessionId },
select: { revoked_at: true },
}),
prisma.authSession.count({
where: {
employee_id: context.employeeId,
revoked_at: null,
expires_at: { gt: new Date() },
},
}),
countAudit(prisma, context.employeeId, "AUTH_PASSWORD_RESET_SUCCEEDED"),
]);
assertCondition(after.token_version === before.token_version + 1, "refresh_race_token_version_invalid");
assertCondition(await verifyPassword(nextPassword, after.password_hash), "refresh_race_password_not_active");
assertCondition(session?.revoked_at instanceof Date, "refresh_race_original_session_active");
assertCondition(activeSessions === 0, "refresh_race_active_session_remains");
assertCondition(successAuditAfter === successAuditBefore + 1, "refresh_race_audit_invalid");
return nextPassword;
}
async function runAuthPostgresConcurrencyCheck(env = process.env) {
const context = validateAuthConcurrencyEnvironment(env);
const { prisma, pool } = createPrismaClient();
try {
console.log("[AUTH-CONCURRENCY] forgot-password-20: start");
await runConcurrentForgotPasswordRequests({ prisma, context });
console.log("[AUTH-CONCURRENCY] forgot-password-20: pass");
console.log("[AUTH-CONCURRENCY] same-code-20: start");
let currentPassword = await runSameCodeConcurrency({ prisma, context });
console.log("[AUTH-CONCURRENCY] same-code-20: pass");
console.log("[AUTH-CONCURRENCY] reset-first-login-race: start");
currentPassword = await runLoginResetRace({ prisma, context, currentPassword });
console.log("[AUTH-CONCURRENCY] reset-first-login-race: pass");
console.log("[AUTH-CONCURRENCY] login-first-reset-race: start");
currentPassword = await runLoginFirstResetRace({ prisma, context, currentPassword });
console.log("[AUTH-CONCURRENCY] login-first-reset-race: pass");
console.log("[AUTH-CONCURRENCY] refresh-reset-race: start");
await runRefreshResetRace({ prisma, context, currentPassword });
console.log("[AUTH-CONCURRENCY] refresh-reset-race: pass");
} finally {
await prisma.$disconnect().catch(() => undefined);
await pool.end().catch(() => undefined);
}
}
if (require.main === module) {
runAuthPostgresConcurrencyCheck().catch((error) => {
const code =
error instanceof AuthConcurrencyCheckError
? error.safeCode
: "auth_concurrency_check_failed";
console.error(`[AUTH-CONCURRENCY][FAIL] ${code}`);
process.exitCode = 1;
});
}
module.exports = {
AuthConcurrencyCheckError,
runAuthPostgresConcurrencyCheck,
validateAuthConcurrencyEnvironment,
validateAuthRegressionEnvironment,
};
@@ -0,0 +1,50 @@
require("dotenv/config");
const {
validateAuthRegressionEnvironment,
} = require("./check-auth-postgres-concurrency");
try {
validateAuthRegressionEnvironment(process.env);
} catch {
console.error("[AUTH-TEST-SERVER][FAIL] environment_guard_rejected");
process.exit(1);
}
const { buildApp } = require("../app");
const disabledExternalEmailService = Object.freeze({
resolveEmailDeliveryStatus() {
return { ready: true, reasons: [] };
},
async sendNotificationEmailSafe() {
return { email: { skipped: true, sent: 0, failed: 0 } };
},
});
async function main() {
const app = await buildApp({ authEmailService: disabledExternalEmailService });
const { host, port } = app.config;
await app.listen({ host, port });
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
try {
await app.close();
process.exit(0);
} catch {
console.error("[AUTH-TEST-SERVER][FAIL] shutdown_failed");
process.exit(1);
}
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main().catch(() => {
console.error("[AUTH-TEST-SERVER][FAIL] startup_failed");
process.exit(1);
});
+29 -1
查看文件
@@ -5,6 +5,32 @@ function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function generateSixDigitCode() {
return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
}
function hashAuthCode({ employeeId, code, purpose, pepper }) {
return crypto
.createHmac("sha256", pepper)
.update(`${purpose}:${employeeId}:${code}`)
.digest("hex");
}
function timingSafeEqualHex(left, right) {
if (
typeof left !== "string" ||
typeof right !== "string" ||
!/^[0-9a-f]+$/i.test(left) ||
!/^[0-9a-f]+$/i.test(right)
) {
return false;
}
const leftBuffer = Buffer.from(left, "hex");
const rightBuffer = Buffer.from(right, "hex");
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function randomRefreshToken() {
return crypto.randomBytes(32).toString("base64url");
}
@@ -19,9 +45,11 @@ async function verifyPassword(password, passwordHash) {
}
module.exports = {
generateSixDigitCode,
hashPassword,
hashAuthCode,
randomRefreshToken,
sha256Hex,
timingSafeEqualHex,
verifyPassword,
};