修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)
这个提交包含在:
@@ -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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户