修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)
这个提交包含在:
+14
-1
@@ -5,13 +5,26 @@ DATABASE_URL="<local-postgresql-url>"
|
||||
# 仅需主动轮换已有开发账号时开启;轮换后账号必须首次登录改密。
|
||||
# SEED_DEV_RESET_PASSWORDS="0"
|
||||
JWT_SECRET="<generate-a-local-secret>"
|
||||
AUTH_CODE_PEPPER="<generate-a-separate-random-secret>"
|
||||
# ===== 敏感字段加密(F25)=====
|
||||
# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。
|
||||
# 生成方式示例:openssl rand -base64 32
|
||||
# FIELD_ENCRYPTION_KEY=""
|
||||
# APP_ORIGIN="https://oa.hyinda.com"
|
||||
# COOKIE_SECURE="true"
|
||||
# DEV_SHOW_PASSWORD_RESET_CODE="true"
|
||||
# TRUST_PROXY_HOPS="0" # 本机开发默认不信任 X-Forwarded-For;受控单跳反代使用 1
|
||||
# PASSWORD_RESET_CODE_TTL_SECONDS="600"
|
||||
# PASSWORD_RESET_FAIL_WINDOW_SECONDS="1800"
|
||||
# PASSWORD_RESET_MAX_ATTEMPTS="5"
|
||||
# PASSWORD_RESET_LOCK_SECONDS="1800"
|
||||
# PASSWORD_RESET_REQUEST_WINDOW_SECONDS="3600"
|
||||
# PASSWORD_RESET_REQUEST_MAX="3"
|
||||
# AUTH_CODE_REQUEST_COOLDOWN_SECONDS="60"
|
||||
# EMAIL_LOGIN_CODE_TTL_SECONDS="600"
|
||||
# EMAIL_LOGIN_CODE_MAX_ATTEMPTS="5"
|
||||
# EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS="1800"
|
||||
# EMAIL_LOGIN_CODE_LOCK_SECONDS="1800"
|
||||
# DEV_SHOW_PASSWORD_RESET_CODE="false"
|
||||
APP_TIMEZONE="Asia/Shanghai"
|
||||
STORAGE_DIR="generated/storage"
|
||||
# STORAGE_DRIVER="local" # local|s3(MinIO/S3 兼容)
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
ALTER TABLE "employees"
|
||||
ADD COLUMN "email_login_code_failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "email_login_code_failed_first_at" TIMESTAMP(3),
|
||||
ADD COLUMN "email_login_code_locked_until" TIMESTAMP(3),
|
||||
ADD COLUMN "email_login_code_last_requested_at" TIMESTAMP(3),
|
||||
ADD COLUMN "password_reset_failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "password_reset_failed_first_at" TIMESTAMP(3),
|
||||
ADD COLUMN "password_reset_locked_until" TIMESTAMP(3),
|
||||
ADD COLUMN "password_reset_request_count" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "password_reset_request_first_at" TIMESTAMP(3),
|
||||
ADD COLUMN "password_reset_last_requested_at" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "password_reset_codes"
|
||||
ADD COLUMN "invalidated_at" TIMESTAMP(3),
|
||||
ADD COLUMN "attempts_count" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "password_reset_codes_employee_id_invalidated_at_created_at_idx"
|
||||
ON "password_reset_codes"("employee_id", "invalidated_at", "created_at");
|
||||
|
||||
-- Existing codes were generated and consumed under the weaker legacy contract.
|
||||
-- Invalidate them at rollout instead of carrying unsafe compatibility forward.
|
||||
UPDATE "password_reset_codes"
|
||||
SET "invalidated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "used_at" IS NULL AND "invalidated_at" IS NULL;
|
||||
|
||||
UPDATE "email_login_codes"
|
||||
SET "used_at" = CURRENT_TIMESTAMP
|
||||
WHERE "used_at" IS NULL;
|
||||
@@ -605,6 +605,16 @@ model Employee {
|
||||
login_failed_count Int @default(0)
|
||||
login_failed_first_at DateTime?
|
||||
login_locked_until DateTime?
|
||||
email_login_code_failed_count Int @default(0)
|
||||
email_login_code_failed_first_at DateTime?
|
||||
email_login_code_locked_until DateTime?
|
||||
email_login_code_last_requested_at DateTime?
|
||||
password_reset_failed_count Int @default(0)
|
||||
password_reset_failed_first_at DateTime?
|
||||
password_reset_locked_until DateTime?
|
||||
password_reset_request_count Int @default(0)
|
||||
password_reset_request_first_at DateTime?
|
||||
password_reset_last_requested_at DateTime?
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
@@ -723,6 +733,8 @@ model PasswordResetCode {
|
||||
code_hash String
|
||||
expires_at DateTime
|
||||
used_at DateTime?
|
||||
invalidated_at DateTime?
|
||||
attempts_count Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
created_ip String?
|
||||
created_user_agent String?
|
||||
@@ -730,6 +742,7 @@ model PasswordResetCode {
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([employee_id, created_at])
|
||||
@@index([employee_id, invalidated_at, created_at])
|
||||
@@index([expires_at])
|
||||
@@map("password_reset_codes")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:[email protected]}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
|
||||
|
||||
LOGIN_FAIL_MAX_ATTEMPTS="${LOGIN_FAIL_MAX_ATTEMPTS:-${AUTH_LOGIN_FAIL_MAX_ATTEMPTS:-5}}"
|
||||
RUN_AUTH_POSTGRES_CONCURRENCY="${RUN_AUTH_POSTGRES_CONCURRENCY:-0}"
|
||||
|
||||
case "$RUN_AUTH_POSTGRES_CONCURRENCY" in
|
||||
0|1) ;;
|
||||
*)
|
||||
echo "[AUTH-REGRESSION][FAIL] invalid_concurrency_mode" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
@@ -18,6 +27,23 @@ if ! command -v jq >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[ERROR] node not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
if ! node -e '
|
||||
try {
|
||||
require(process.argv[1]).validateAuthRegressionEnvironment(process.env);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
' "$SERVER_DIR/src/scripts/check-auth-postgres-concurrency.js"; then
|
||||
echo "[AUTH-REGRESSION][FAIL] environment_guard_rejected" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
@@ -34,12 +60,8 @@ assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local body_file="${4:-}"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -52,7 +74,6 @@ assert_error_code() {
|
||||
actual_code="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual_code" != "$expected_code" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -80,7 +101,7 @@ ADMIN_TOKEN="$(extract_json_field "$admin_login_body" '.data.accessToken')"
|
||||
|
||||
echo "[2/8] Weak password should be rejected"
|
||||
weak_employee_no="W$(date +%s)$RANDOM"
|
||||
weak_employee_email="weak_${weak_employee_no}@hyinda.com"
|
||||
weak_employee_email="weak_${weak_employee_no,,}@hyinda.com"
|
||||
weak_employee_phone="139${RANDOM}${RANDOM}"
|
||||
weak_create_body="$TMP_DIR/weak-create.json"
|
||||
weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \
|
||||
@@ -91,14 +112,13 @@ weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \
|
||||
assert_status "$weak_create_status" "400" "weak password rejected" "$weak_create_body"
|
||||
assert_error_code "$weak_create_body" "VALID_001" "weak password rejected"
|
||||
if ! jq -e '.error.details.password | type == "array" and (map(tostring) | join(" ") | contains("常见"))' "$weak_create_body" >/dev/null; then
|
||||
echo "[ERROR] weak password error message missing" >&2
|
||||
cat "$weak_create_body" >&2
|
||||
echo "[ERROR] weak password validation category missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[3/8] Create employee (initial password => mustChangePassword)"
|
||||
employee_no="A$(date +%s)$RANDOM"
|
||||
employee_email="auth_${employee_no}@hyinda.com"
|
||||
employee_email="auth_${employee_no,,}@hyinda.com"
|
||||
employee_phone="138${RANDOM}${RANDOM}"
|
||||
INITIAL_PASSWORD="InitA1$(date +%s%N)Xy"
|
||||
create_body="$TMP_DIR/create.json"
|
||||
@@ -115,7 +135,6 @@ login_status_1="$(login "$employee_email" "$INITIAL_PASSWORD" "${DEVICE_FINGERPR
|
||||
assert_status "$login_status_1" "200" "initial login" "$login_body_1"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_1")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true on initial login" >&2
|
||||
cat "$login_body_1" >&2
|
||||
exit 1
|
||||
fi
|
||||
ACCESS_TOKEN_1="$(extract_json_field "$login_body_1" '.data.accessToken')"
|
||||
@@ -133,7 +152,6 @@ assert_status "$change_status" "200" "change password" "$change_body"
|
||||
ACCESS_TOKEN_2="$(extract_json_field "$change_body" '.data.accessToken')"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$change_body")" != "false" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=false after change" >&2
|
||||
cat "$change_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -165,7 +183,6 @@ login_status_2="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERP
|
||||
assert_status "$login_status_2" "200" "login with reset password" "$login_body_2"
|
||||
if [[ "$(jq -r '.data.mustChangePassword' "$login_body_2")" != "true" ]]; then
|
||||
echo "[ERROR] expected mustChangePassword=true after admin reset" >&2
|
||||
cat "$login_body_2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -183,7 +200,6 @@ for i in $(seq 1 "$LOGIN_FAIL_MAX_ATTEMPTS"); do
|
||||
assert_error_code "$attempt_body" "AUTH_002" "lock attempt $i triggers lock"
|
||||
if [[ "$(jq -r '.error.details.lockedUntil // empty' "$attempt_body")" == "" ]]; then
|
||||
echo "[ERROR] expected lockedUntil in AUTH_002 details" >&2
|
||||
cat "$attempt_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
locked=1
|
||||
@@ -211,5 +227,19 @@ post_unlock_login_body="$TMP_DIR/post-unlock-login.json"
|
||||
post_unlock_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-post-unlock" "$post_unlock_login_body")"
|
||||
assert_status "$post_unlock_login_status" "200" "login after unlock" "$post_unlock_login_body"
|
||||
|
||||
echo "[8/8] Done"
|
||||
echo "[OK] Auth security regression passed"
|
||||
if [[ "$RUN_AUTH_POSTGRES_CONCURRENCY" == "1" ]]; then
|
||||
echo "[8/9] PostgreSQL auth challenge concurrency"
|
||||
AUTH_SECURITY_EMPLOYEE_ID="$EMPLOYEE_ID" \
|
||||
AUTH_SECURITY_EMPLOYEE_EMAIL="$employee_email" \
|
||||
AUTH_SECURITY_CURRENT_PASSWORD="$ONE_TIME_PASSWORD" \
|
||||
node src/scripts/check-auth-postgres-concurrency.js
|
||||
else
|
||||
echo "[8/9] PostgreSQL auth challenge concurrency [SKIP: explicit opt-in required]"
|
||||
fi
|
||||
|
||||
echo "[9/9] Done"
|
||||
if [[ "$RUN_AUTH_POSTGRES_CONCURRENCY" == "1" ]]; then
|
||||
echo "[AUTH-REGRESSION][PASS] FULL"
|
||||
else
|
||||
echo "[AUTH-REGRESSION][PARTIAL] BASE_ONLY"
|
||||
fi
|
||||
|
||||
+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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fp = require("fastify-plugin");
|
||||
|
||||
const { buildApp } = require("../../src/app");
|
||||
const { getConfig } = require("../../src/config");
|
||||
const {
|
||||
generateSixDigitCode,
|
||||
hashAuthCode,
|
||||
timingSafeEqualHex,
|
||||
} = require("../../src/utils/crypto");
|
||||
|
||||
const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
|
||||
|
||||
function stubBaseEnv(nodeEnv = "test") {
|
||||
vi.stubEnv("NODE_ENV", nodeEnv);
|
||||
vi.stubEnv("JWT_SECRET", "test-jwt-secret");
|
||||
vi.stubEnv("DATABASE_URL", "");
|
||||
vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : "");
|
||||
vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined);
|
||||
vi.stubEnv("JOBS_ENABLED", "false");
|
||||
vi.stubEnv("METRICS_ENABLED", "false");
|
||||
vi.stubEnv("SERVE_CLIENT_DIST", "false");
|
||||
vi.stubEnv("OPENAPI_DOCS_ENABLED", "false");
|
||||
}
|
||||
|
||||
function createPrismaPlugin() {
|
||||
return fp(async (app) => {
|
||||
app.decorate("prisma", {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]),
|
||||
employee: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
});
|
||||
}, { name: "test-prisma" });
|
||||
}
|
||||
|
||||
async function buildIpProbeApp() {
|
||||
const app = await buildApp({
|
||||
prismaPlugin: createPrismaPlugin(),
|
||||
startJobs: vi.fn(async () => ({ driver: "disabled" })),
|
||||
});
|
||||
app.get("/__test/ip", async (request) => ({
|
||||
ip: request.ip,
|
||||
ips: request.ips,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("auth security configuration", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses crypto.randomInt for padded six-digit codes", () => {
|
||||
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42);
|
||||
|
||||
expect(generateSixDigitCode()).toBe("000042");
|
||||
expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000);
|
||||
});
|
||||
|
||||
it("separates auth code hashes by purpose and compares them safely", () => {
|
||||
const common = {
|
||||
employeeId: "11111111-1111-4111-8111-111111111111",
|
||||
code: "123456",
|
||||
pepper: STRONG_PEPPER,
|
||||
};
|
||||
const emailHash = hashAuthCode({ ...common, purpose: "email_login" });
|
||||
const resetHash = hashAuthCode({ ...common, purpose: "password_reset" });
|
||||
|
||||
expect(emailHash).not.toBe(resetHash);
|
||||
expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true);
|
||||
expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false);
|
||||
expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when production has no auth code pepper", () => {
|
||||
stubBaseEnv("production");
|
||||
vi.stubEnv("AUTH_CODE_PEPPER", "");
|
||||
|
||||
expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<managed-auth-code-pepper>",
|
||||
"short-secret",
|
||||
])("rejects a weak production auth code pepper", (pepper) => {
|
||||
stubBaseEnv("production");
|
||||
vi.stubEnv("AUTH_CODE_PEPPER", pepper);
|
||||
|
||||
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret");
|
||||
});
|
||||
|
||||
it("rejects a production auth code pepper reused as the JWT secret", () => {
|
||||
stubBaseEnv("production");
|
||||
vi.stubEnv("JWT_SECRET", STRONG_PEPPER);
|
||||
vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER);
|
||||
|
||||
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
|
||||
});
|
||||
|
||||
it("forbids development password reset code logging in production", () => {
|
||||
stubBaseEnv("production");
|
||||
vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true");
|
||||
|
||||
expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "Missing required env: TRUST_PROXY_HOPS"],
|
||||
["", "Invalid TRUST_PROXY_HOPS"],
|
||||
["0", "Invalid TRUST_PROXY_HOPS"],
|
||||
["2", "Invalid TRUST_PROXY_HOPS"],
|
||||
["true", "Invalid TRUST_PROXY_HOPS"],
|
||||
])("requires exactly one trusted proxy hop in production", (value, error) => {
|
||||
stubBaseEnv("production");
|
||||
vi.stubEnv("TRUST_PROXY_HOPS", value);
|
||||
|
||||
expect(() => getConfig()).toThrow(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["PASSWORD_RESET_MAX_ATTEMPTS", "0"],
|
||||
["PASSWORD_RESET_LOCK_SECONDS", "-1"],
|
||||
["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"],
|
||||
["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"],
|
||||
])("rejects unsafe numeric auth configuration", (name, value) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv(name, value);
|
||||
|
||||
expect(() => getConfig()).toThrow(`Invalid ${name}`);
|
||||
});
|
||||
|
||||
it("ignores a forged X-Forwarded-For header by default", async () => {
|
||||
stubBaseEnv();
|
||||
const app = await buildIpProbeApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/__test/ip",
|
||||
headers: { "x-forwarded-for": "10.10.10.10" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ip: "127.0.0.1" });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("trusts only the nearest address supplied by one controlled proxy", async () => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("TRUST_PROXY_HOPS", "1");
|
||||
const app = await buildIpProbeApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/__test/ip",
|
||||
headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
ip: "192.168.1.20",
|
||||
ips: ["127.0.0.1", "192.168.1.20"],
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -4,21 +4,33 @@ const fastifyCookie = require("@fastify/cookie");
|
||||
|
||||
const authRoutes = require("../../../src/routes/auth");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const { hashPassword, sha256Hex } = require("../../../src/utils/crypto");
|
||||
const { hashAuthCode, hashPassword, verifyPassword } = require("../../../src/utils/crypto");
|
||||
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const JWT_SECRET = "test-auth-secret";
|
||||
const AUTH_CODE_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
|
||||
|
||||
function hashEmailLoginCode(code) {
|
||||
return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex");
|
||||
return hashAuthCode({
|
||||
employeeId: EMPLOYEE_ID,
|
||||
code,
|
||||
purpose: "email_login",
|
||||
pepper: AUTH_CODE_PEPPER,
|
||||
});
|
||||
}
|
||||
|
||||
async function buildApp(passwordHash) {
|
||||
const app = Fastify();
|
||||
await app.register(fastifyCookie);
|
||||
function hashPasswordResetCode(code) {
|
||||
return hashAuthCode({
|
||||
employeeId: EMPLOYEE_ID,
|
||||
code,
|
||||
purpose: "password_reset",
|
||||
pepper: AUTH_CODE_PEPPER,
|
||||
});
|
||||
}
|
||||
|
||||
const employeeFindFirst = vi.fn().mockResolvedValue({
|
||||
function buildEmployee(passwordHash) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
status: "active",
|
||||
password_hash: passwordHash,
|
||||
@@ -27,9 +39,33 @@ async function buildApp(passwordHash) {
|
||||
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,
|
||||
email_login_code_last_requested_at: 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,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(passwordHash) {
|
||||
const app = Fastify();
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const employee = buildEmployee(passwordHash);
|
||||
const employeeFindFirst = vi.fn().mockResolvedValue(employee);
|
||||
const employeeFindUnique = vi.fn().mockResolvedValue(employee);
|
||||
const employeeFindMany = vi.fn().mockResolvedValue([]);
|
||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||
const employeeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const queryRaw = vi.fn().mockResolvedValue([employee]);
|
||||
const authSessionFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const txAuthSessionFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const authSessionFindUnique = vi.fn().mockResolvedValue(null);
|
||||
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||
@@ -37,25 +73,36 @@ async function buildApp(passwordHash) {
|
||||
const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" });
|
||||
const passwordResetCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||
|
||||
const tx = {
|
||||
$queryRaw: queryRaw,
|
||||
employee: {
|
||||
update: employeeUpdate,
|
||||
updateMany: employeeUpdateMany,
|
||||
},
|
||||
authSession: {
|
||||
findFirst: txAuthSessionFindFirst,
|
||||
findUnique: authSessionFindUnique,
|
||||
findMany: authSessionFindMany,
|
||||
updateMany: authSessionUpdateMany,
|
||||
create: authSessionCreate,
|
||||
},
|
||||
emailLoginCode: {
|
||||
update: emailLoginCodeUpdate,
|
||||
create: emailLoginCodeCreate,
|
||||
findFirst: emailLoginCodeFindFirst,
|
||||
updateMany: emailLoginCodeUpdateMany,
|
||||
},
|
||||
passwordResetCode: {
|
||||
create: passwordResetCodeCreate,
|
||||
findFirst: passwordResetCodeFindFirst,
|
||||
updateMany: passwordResetCodeUpdateMany,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
@@ -78,7 +125,16 @@ async function buildApp(passwordHash) {
|
||||
loginFailMaxAttempts: 5,
|
||||
emailLoginCodeTtlSeconds: 600,
|
||||
emailLoginCodeMaxAttempts: 5,
|
||||
emailLoginCodeFailWindowSeconds: 1800,
|
||||
emailLoginCodeLockSeconds: 1800,
|
||||
passwordResetCodeTtlSeconds: 600,
|
||||
passwordResetFailWindowSeconds: 1800,
|
||||
passwordResetMaxAttempts: 5,
|
||||
passwordResetLockSeconds: 1800,
|
||||
passwordResetRequestWindowSeconds: 3600,
|
||||
passwordResetRequestMax: 3,
|
||||
authCodeRequestCooldownSeconds: 60,
|
||||
devShowPasswordResetCode: false,
|
||||
emailEnabled: true,
|
||||
emailFrom: "[email protected]",
|
||||
smtpHost: "smtp.hyinda.test",
|
||||
@@ -88,31 +144,32 @@ async function buildApp(passwordHash) {
|
||||
smtpPass: "",
|
||||
rateLimitEnabled: false,
|
||||
jwtSecret: JWT_SECRET,
|
||||
authCodePepper: AUTH_CODE_PEPPER,
|
||||
appOrigin: "http://127.0.0.1:3000",
|
||||
wecomAllowedReturnHosts: "127.0.0.1:3000",
|
||||
});
|
||||
const transaction = vi.fn(async (callback) => callback(tx));
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findFirst: employeeFindFirst,
|
||||
findUnique: employeeFindUnique,
|
||||
findMany: employeeFindMany,
|
||||
},
|
||||
emailLoginCode: {
|
||||
create: emailLoginCodeCreate,
|
||||
findFirst: emailLoginCodeFindFirst,
|
||||
},
|
||||
passwordResetCode: {
|
||||
create: passwordResetCodeCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
notification: {
|
||||
createMany: notificationCreateMany,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
authSession: {
|
||||
findFirst: authSessionFindFirst,
|
||||
},
|
||||
$transaction: transaction,
|
||||
});
|
||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||
app.decorate("authenticate", async () => undefined);
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = { id: EMPLOYEE_ID };
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
@@ -131,8 +188,15 @@ async function buildApp(passwordHash) {
|
||||
return {
|
||||
app,
|
||||
employeeFindFirst,
|
||||
employeeFindUnique,
|
||||
employeeFindMany,
|
||||
employeeUpdate,
|
||||
employeeUpdateMany,
|
||||
queryRaw,
|
||||
transaction,
|
||||
authSessionFindFirst,
|
||||
txAuthSessionFindFirst,
|
||||
authSessionFindUnique,
|
||||
authSessionFindMany,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
@@ -140,9 +204,10 @@ async function buildApp(passwordHash) {
|
||||
notificationCreateMany,
|
||||
emailLoginCodeCreate,
|
||||
emailLoginCodeFindFirst,
|
||||
emailLoginCodeUpdate,
|
||||
emailLoginCodeUpdateMany,
|
||||
passwordResetCodeCreate,
|
||||
passwordResetCodeFindFirst,
|
||||
passwordResetCodeUpdateMany,
|
||||
wecomFindFirst,
|
||||
wecomUpsert,
|
||||
};
|
||||
@@ -370,8 +435,8 @@ describe("auth login route", () => {
|
||||
|
||||
it("requests an email login code without exposing account existence", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456);
|
||||
const { app, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
|
||||
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(123456);
|
||||
const { app, emailLoginCodeCreate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -388,6 +453,10 @@ describe("auth login route", () => {
|
||||
code_hash: hashEmailLoginCode("123456"),
|
||||
}),
|
||||
});
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, used_at: null },
|
||||
data: { used_at: expect.any(Date) },
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
|
||||
@@ -401,7 +470,7 @@ describe("auth login route", () => {
|
||||
|
||||
it("rejects email login code requests when delivery is not configured before account lookup", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
const { app, transaction, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
app.config.emailEnabled = false;
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -413,7 +482,8 @@ describe("auth login route", () => {
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
@@ -421,8 +491,8 @@ describe("auth login route", () => {
|
||||
|
||||
it("does not reveal missing accounts when requesting an email login code", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
employeeFindFirst.mockResolvedValueOnce(null);
|
||||
const { app, queryRaw, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -465,7 +535,13 @@ describe("auth login route", () => {
|
||||
}),
|
||||
);
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: "email-code-1", used_at: null },
|
||||
where: {
|
||||
id: "email-code-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
code_hash: hashEmailLoginCode("654321"),
|
||||
used_at: null,
|
||||
expires_at: { gt: expect.any(Date) },
|
||||
},
|
||||
data: { used_at: expect.any(Date) },
|
||||
});
|
||||
expect(authSessionCreate).toHaveBeenCalledWith({
|
||||
@@ -492,7 +568,7 @@ describe("auth login route", () => {
|
||||
|
||||
it("rejects an invalid email login code and records the failed attempt", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdate, auditCreate } = await buildApp(passwordHash);
|
||||
const { app, employeeUpdate, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
|
||||
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
||||
id: "email-code-1",
|
||||
code_hash: hashEmailLoginCode("654321"),
|
||||
@@ -511,10 +587,17 @@ describe("auth login route", () => {
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(emailLoginCodeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "email-code-1" },
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: "email-code-1", used_at: null },
|
||||
data: { attempts_count: { increment: 1 } },
|
||||
});
|
||||
expect(employeeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: EMPLOYEE_ID },
|
||||
data: expect.objectContaining({
|
||||
email_login_code_failed_count: 1,
|
||||
email_login_code_locked_until: null,
|
||||
}),
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_EMAIL_LOGIN_FAILED",
|
||||
@@ -527,8 +610,14 @@ describe("auth login route", () => {
|
||||
|
||||
it("sends password reset codes with Beijing-time expiry wording", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321);
|
||||
const { app, passwordResetCodeCreate, auditCreate, notificationCreateMany } = await buildApp(passwordHash);
|
||||
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(654321);
|
||||
const {
|
||||
app,
|
||||
passwordResetCodeCreate,
|
||||
passwordResetCodeUpdateMany,
|
||||
auditCreate,
|
||||
notificationCreateMany,
|
||||
} = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -542,9 +631,17 @@ describe("auth login route", () => {
|
||||
expect(passwordResetCodeCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
code_hash: sha256Hex("654321"),
|
||||
code_hash: hashPasswordResetCode("654321"),
|
||||
}),
|
||||
});
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
employee_id: EMPLOYEE_ID,
|
||||
used_at: null,
|
||||
invalidated_at: null,
|
||||
},
|
||||
data: { invalidated_at: expect.any(Date) },
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_PASSWORD_RESET_REQUESTED",
|
||||
@@ -559,7 +656,7 @@ describe("auth login route", () => {
|
||||
|
||||
it("rejects password reset code requests when delivery is not configured before account lookup", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, passwordResetCodeCreate } = await buildApp(passwordHash);
|
||||
const { app, transaction, queryRaw, passwordResetCodeCreate } = await buildApp(passwordHash);
|
||||
app.config.smtpHost = "";
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -571,9 +668,579 @@ describe("auth login route", () => {
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", []],
|
||||
["archived", [{ ...buildEmployee("unused"), status: "archived" }]],
|
||||
[
|
||||
"locked",
|
||||
[
|
||||
{
|
||||
...buildEmployee("unused"),
|
||||
password_reset_locked_until: new Date(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
],
|
||||
])("returns the same forgot-password response without writes for a %s account", async (_case, rows) => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
queryRaw,
|
||||
employeeUpdate,
|
||||
passwordResetCodeCreate,
|
||||
passwordResetCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce(rows);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/forgot-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({});
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not create a session when password state changed after verification", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeUpdateMany, authSessionCreate } = await buildApp(passwordHash);
|
||||
employeeUpdateMany.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "stale-password-login",
|
||||
},
|
||||
payload: {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().error.code).toBe("AUTH_001");
|
||||
expect(authSessionCreate).not.toHaveBeenCalled();
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("locks and rechecks the employee before rotating a refresh session", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
employeeFindUnique,
|
||||
queryRaw,
|
||||
authSessionFindFirst,
|
||||
authSessionFindUnique,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
const activeSession = {
|
||||
id: "session-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
device_fingerprint: "refresh-device",
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
revoked_at: null,
|
||||
};
|
||||
authSessionFindFirst.mockResolvedValueOnce(activeSession);
|
||||
authSessionFindUnique.mockResolvedValueOnce(activeSession);
|
||||
authSessionUpdateMany.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/refresh",
|
||||
headers: {
|
||||
cookie: "refresh_token=old-refresh-token",
|
||||
origin: "http://127.0.0.1:3000",
|
||||
"x-device-fingerprint": "refresh-device",
|
||||
},
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(authSessionFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-1" },
|
||||
select: expect.objectContaining({ id: true, employee_id: true, revoked_at: true }),
|
||||
});
|
||||
expect(queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
authSessionFindUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(employeeFindUnique).not.toHaveBeenCalled();
|
||||
expect(authSessionCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
device_fingerprint: "refresh-device",
|
||||
}),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("suppresses email login code requests during the account cooldown", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, queryRaw, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
...buildEmployee(passwordHash),
|
||||
email_login_code_last_requested_at: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/email-login/code",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({});
|
||||
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("locks the account-level email code entry on the fifth failed attempt", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
queryRaw,
|
||||
employeeUpdate,
|
||||
emailLoginCodeFindFirst,
|
||||
emailLoginCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
...buildEmployee(passwordHash),
|
||||
email_login_code_failed_count: 4,
|
||||
email_login_code_failed_first_at: new Date(),
|
||||
},
|
||||
]);
|
||||
emailLoginCodeFindFirst.mockResolvedValueOnce({
|
||||
id: "email-code-1",
|
||||
code_hash: hashEmailLoginCode("654321"),
|
||||
attempts_count: 4,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/email-login/verify",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "email-lock-test",
|
||||
},
|
||||
payload: { email: "[email protected]", code: "000000" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(employeeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: EMPLOYEE_ID },
|
||||
data: expect.objectContaining({
|
||||
email_login_code_failed_count: 5,
|
||||
email_login_code_locked_until: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: "email-code-1", used_at: null },
|
||||
data: {
|
||||
attempts_count: { increment: 1 },
|
||||
used_at: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "AUTH_EMAIL_LOGIN_LOCKED" }),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not count a failure when no active email-login challenge exists", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeUpdate, emailLoginCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/email-login/verify",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "missing-email-challenge",
|
||||
},
|
||||
payload: { email: "[email protected]", code: "000000" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps password reset failure counts when a new request is suppressed", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, queryRaw, employeeUpdate, passwordResetCodeCreate } = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
...buildEmployee(passwordHash),
|
||||
password_reset_failed_count: 4,
|
||||
password_reset_failed_first_at: new Date(),
|
||||
password_reset_last_requested_at: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/forgot-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({});
|
||||
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("invalidates any active password reset code before creating its replacement", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(111111);
|
||||
const { app, passwordResetCodeCreate, passwordResetCodeUpdateMany } = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/forgot-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(passwordResetCodeCreate).toHaveBeenCalledTimes(1);
|
||||
expect(passwordResetCodeCreate.mock.calls[0][0].data.code_hash).toBe(
|
||||
hashPasswordResetCode("111111"),
|
||||
);
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledTimes(1);
|
||||
expect(passwordResetCodeUpdateMany.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
passwordResetCodeCreate.mock.invocationCallOrder[0],
|
||||
);
|
||||
|
||||
randomSpy.mockRestore();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("locks password recovery on the fifth failed code without rolling back the counters", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
queryRaw,
|
||||
employeeUpdate,
|
||||
passwordResetCodeFindFirst,
|
||||
passwordResetCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
...buildEmployee(passwordHash),
|
||||
password_reset_failed_count: 4,
|
||||
password_reset_failed_first_at: new Date(),
|
||||
},
|
||||
]);
|
||||
passwordResetCodeFindFirst.mockResolvedValueOnce({
|
||||
id: "password-reset-code-1",
|
||||
code_hash: hashPasswordResetCode("654321"),
|
||||
attempts_count: 4,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/reset-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: {
|
||||
email: "[email protected]",
|
||||
code: "000000",
|
||||
newPassword: "ReplacementPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(employeeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: EMPLOYEE_ID },
|
||||
data: expect.objectContaining({
|
||||
password_reset_failed_count: 5,
|
||||
password_reset_locked_until: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "password-reset-code-1",
|
||||
used_at: null,
|
||||
invalidated_at: null,
|
||||
},
|
||||
data: {
|
||||
attempts_count: { increment: 1 },
|
||||
invalidated_at: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_LOCKED" }),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not count a failure when no active password-reset challenge exists", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeUpdate, passwordResetCodeUpdateMany, auditCreate } = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/reset-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: {
|
||||
email: "[email protected]",
|
||||
code: "000000",
|
||||
newPassword: "ReplacementPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns the same reset error for missing, inactive, locked, and wrong-code accounts", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, queryRaw, passwordResetCodeFindFirst } = await buildApp(passwordHash);
|
||||
queryRaw
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ ...buildEmployee(passwordHash), status: "disabled" }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...buildEmployee(passwordHash),
|
||||
password_reset_locked_until: new Date(Date.now() + 60_000),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([buildEmployee(passwordHash)]);
|
||||
passwordResetCodeFindFirst.mockResolvedValueOnce({
|
||||
id: "password-reset-code-1",
|
||||
code_hash: hashPasswordResetCode("654321"),
|
||||
attempts_count: 0,
|
||||
});
|
||||
|
||||
const errors = [];
|
||||
for (const email of [
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
]) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/reset-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: {
|
||||
email,
|
||||
code: "000000",
|
||||
newPassword: "ReplacementPassword1234",
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
errors.push(response.json().error);
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
expect(error).toEqual(errors[0]);
|
||||
}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("resets the password once and revokes every challenge and refresh session", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
employeeUpdate,
|
||||
authSessionUpdateMany,
|
||||
emailLoginCodeUpdateMany,
|
||||
passwordResetCodeFindFirst,
|
||||
passwordResetCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
passwordResetCodeFindFirst.mockResolvedValueOnce({
|
||||
id: "password-reset-code-1",
|
||||
code_hash: hashPasswordResetCode("654321"),
|
||||
attempts_count: 0,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/reset-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: {
|
||||
email: "[email protected]",
|
||||
code: "654321",
|
||||
newPassword: "ReplacementPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const passwordUpdate = employeeUpdate.mock.calls.find(
|
||||
([argument]) => typeof argument.data.password_hash === "string",
|
||||
)[0];
|
||||
expect(await verifyPassword("ReplacementPassword1234", passwordUpdate.data.password_hash)).toBe(true);
|
||||
expect(passwordUpdate.data).toEqual(
|
||||
expect.objectContaining({
|
||||
token_version: { increment: 1 },
|
||||
password_reset_failed_count: 0,
|
||||
password_reset_request_count: 0,
|
||||
email_login_code_failed_count: 0,
|
||||
}),
|
||||
);
|
||||
expect(authSessionUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, revoked_at: null },
|
||||
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
|
||||
});
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, invalidated_at: null },
|
||||
data: { invalidated_at: expect.any(Date) },
|
||||
});
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, used_at: null },
|
||||
data: { used_at: expect.any(Date) },
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "AUTH_PASSWORD_RESET_SUCCEEDED" }),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows only one concurrent consumer of the same password reset code", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
passwordResetCodeFindFirst,
|
||||
passwordResetCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp(passwordHash);
|
||||
passwordResetCodeFindFirst.mockResolvedValue({
|
||||
id: "password-reset-code-1",
|
||||
code_hash: hashPasswordResetCode("654321"),
|
||||
attempts_count: 0,
|
||||
});
|
||||
let consumed = false;
|
||||
passwordResetCodeUpdateMany.mockImplementation(async ({ where, data }) => {
|
||||
if (where.id && data.used_at) {
|
||||
if (consumed) return { count: 0 };
|
||||
consumed = true;
|
||||
}
|
||||
return { count: 1 };
|
||||
});
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 2 }, () =>
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/reset-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: {
|
||||
email: "[email protected]",
|
||||
code: "654321",
|
||||
newPassword: "ReplacementPassword1234",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 400]);
|
||||
expect(
|
||||
auditCreate.mock.calls.filter(
|
||||
([argument]) => argument.data.action === "AUTH_PASSWORD_RESET_SUCCEEDED",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("invalidates outstanding challenges when the user changes the password", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const {
|
||||
app,
|
||||
employeeUpdateMany,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
emailLoginCodeUpdateMany,
|
||||
passwordResetCodeUpdateMany,
|
||||
} = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/change-password",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-device-fingerprint": "change-password-device",
|
||||
},
|
||||
payload: {
|
||||
oldPassword: "AdminPassword1234",
|
||||
newPassword: "ChangedPassword1234",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: EMPLOYEE_ID,
|
||||
password_hash: passwordHash,
|
||||
token_version: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(authSessionUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, revoked_at: null },
|
||||
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
|
||||
});
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, invalidated_at: null },
|
||||
data: { invalidated_at: expect.any(Date) },
|
||||
});
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: EMPLOYEE_ID, used_at: null },
|
||||
data: { used_at: expect.any(Date) },
|
||||
});
|
||||
expect(authSessionCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ device_fingerprint: "change-password-device" }),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,9 @@ async function buildApp({
|
||||
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const employeeRoleFindMany = vi.fn().mockResolvedValue([{ role_code: "employee" }]);
|
||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const passwordResetCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
|
||||
const tx = {
|
||||
department: {
|
||||
@@ -75,7 +78,13 @@ async function buildApp({
|
||||
create: auditCreate,
|
||||
},
|
||||
authSession: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: authSessionUpdateMany,
|
||||
},
|
||||
passwordResetCode: {
|
||||
updateMany: passwordResetCodeUpdateMany,
|
||||
},
|
||||
emailLoginCode: {
|
||||
updateMany: emailLoginCodeUpdateMany,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,6 +143,10 @@ async function buildApp({
|
||||
employeeUpdate,
|
||||
employeeRoleCreateMany,
|
||||
employeeRoleFindMany,
|
||||
auditCreate,
|
||||
authSessionUpdateMany,
|
||||
passwordResetCodeUpdateMany,
|
||||
emailLoginCodeUpdateMany,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,7 +160,7 @@ describe("usersRoutes write flows", () => {
|
||||
payload: {
|
||||
name: "张三",
|
||||
phone: "13800000001",
|
||||
email: "zhangsan@example.com",
|
||||
email: "ZhangSan@Example.COM",
|
||||
roles: ["employee"],
|
||||
},
|
||||
});
|
||||
@@ -155,11 +168,10 @@ describe("usersRoutes write flows", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(employeeCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.not.objectContaining({
|
||||
employee_no: expect.anything(),
|
||||
}),
|
||||
data: expect.objectContaining({ email: "[email protected]" }),
|
||||
}),
|
||||
);
|
||||
expect(employeeCreate.mock.calls[0][0].data).not.toHaveProperty("employee_no");
|
||||
expect(employeeRoleCreateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
@@ -210,4 +222,54 @@ describe("usersRoutes write flows", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("revokes sessions and outstanding challenges during an administrator password reset", async () => {
|
||||
const {
|
||||
app,
|
||||
employeeUpdate,
|
||||
authSessionUpdateMany,
|
||||
passwordResetCodeUpdateMany,
|
||||
emailLoginCodeUpdateMany,
|
||||
auditCreate,
|
||||
} = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/users/${USER_ID}/reset-password`,
|
||||
payload: { newPassword: "AdministratorReset1234" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
id: USER_ID,
|
||||
oneTimePassword: "AdministratorReset1234",
|
||||
});
|
||||
expect(employeeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: USER_ID },
|
||||
data: expect.objectContaining({
|
||||
must_change_password: true,
|
||||
token_version: { increment: 1 },
|
||||
email_login_code_failed_count: 0,
|
||||
password_reset_failed_count: 0,
|
||||
password_reset_request_count: 0,
|
||||
}),
|
||||
});
|
||||
expect(authSessionUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: USER_ID, revoked_at: null },
|
||||
data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) },
|
||||
});
|
||||
expect(passwordResetCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: USER_ID, invalidated_at: null },
|
||||
data: { invalidated_at: expect.any(Date) },
|
||||
});
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { employee_id: USER_ID, used_at: null },
|
||||
data: { used_at: expect.any(Date) },
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "SYSTEM_USER_PASSWORD_RESET" }),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
AuthConcurrencyCheckError,
|
||||
validateAuthConcurrencyEnvironment,
|
||||
validateAuthRegressionEnvironment,
|
||||
} = require("../../../src/scripts/check-auth-postgres-concurrency");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
|
||||
function validEnv() {
|
||||
return {
|
||||
RUN_AUTH_POSTGRES_CONCURRENCY: "1",
|
||||
RUN_AUTH_SECURITY_REGRESSION: "1",
|
||||
NODE_ENV: "test",
|
||||
RATE_LIMIT_ENABLED: "false",
|
||||
DEV_SHOW_PASSWORD_RESET_CODE: "false",
|
||||
TRUST_PROXY_HOPS: "0",
|
||||
EMAIL_ENABLED: "false",
|
||||
SMTP_HOST: "",
|
||||
SMTP_USER: "",
|
||||
SMTP_PASS: "",
|
||||
IM_NOTIFIER_ENABLED: "false",
|
||||
WECOM_ENABLED: "false",
|
||||
WECOM_AGENT_SECRET: "",
|
||||
OIDC_ENABLED: "false",
|
||||
OIDC_CLIENT_SECRET: "",
|
||||
DATABASE_URL:
|
||||
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test?schema=public",
|
||||
AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_test",
|
||||
BASE_URL: "http://127.0.0.1:3456",
|
||||
AUTH_CODE_PEPPER: "test-auth-code-pepper-with-at-least-32-bytes",
|
||||
JWT_SECRET: "independent-test-jwt-secret",
|
||||
FIELD_ENCRYPTION_KEY: "test-field-encryption-key",
|
||||
AUTH_SECURITY_EMPLOYEE_ID: "11111111-1111-4111-8111-111111111111",
|
||||
AUTH_SECURITY_EMPLOYEE_EMAIL: "[email protected]",
|
||||
AUTH_SECURITY_CURRENT_PASSWORD: "SyntheticPassword1234",
|
||||
};
|
||||
}
|
||||
|
||||
function expectSafeFailure(env, expectedCode) {
|
||||
try {
|
||||
validateAuthConcurrencyEnvironment(env);
|
||||
throw new Error("expected validation to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AuthConcurrencyCheckError);
|
||||
expect(error.safeCode).toBe(expectedCode);
|
||||
expect(error.message).toBe(expectedCode);
|
||||
}
|
||||
}
|
||||
|
||||
describe("auth PostgreSQL concurrency guard", () => {
|
||||
it("accepts only an explicitly confirmed isolated loopback target", () => {
|
||||
expect(validateAuthConcurrencyEnvironment(validEnv())).toEqual(
|
||||
expect.objectContaining({
|
||||
baseUrl: "http://127.0.0.1:3456",
|
||||
employeeId: "11111111-1111-4111-8111-111111111111",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not run without the explicit opt-in", () => {
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), RUN_AUTH_POSTGRES_CONCURRENCY: "0" },
|
||||
"explicit_opt_in_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates the base regression target before employee-scoped checks", () => {
|
||||
const env = validEnv();
|
||||
delete env.AUTH_SECURITY_EMPLOYEE_ID;
|
||||
delete env.AUTH_SECURITY_EMPLOYEE_EMAIL;
|
||||
delete env.AUTH_SECURITY_CURRENT_PASSWORD;
|
||||
|
||||
expect(validateAuthRegressionEnvironment(env)).toEqual(
|
||||
expect.objectContaining({ baseUrl: "http://127.0.0.1:3456" }),
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...env, RUN_AUTH_SECURITY_REGRESSION: "0" },
|
||||
"regression_opt_in_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses production and enabled rate limiting", () => {
|
||||
expectSafeFailure({ ...validEnv(), NODE_ENV: "production" }, "test_environment_required");
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), RATE_LIMIT_ENABLED: "true" },
|
||||
"rate_limit_must_be_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses inherited proxy and external notification providers", () => {
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), TRUST_PROXY_HOPS: "1" },
|
||||
"trust_proxy_must_be_disabled",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), EMAIL_ENABLED: "true" },
|
||||
"email_provider_must_be_disabled",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), SMTP_HOST: "smtp.example.test" },
|
||||
"smtp_host_forbidden",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), IM_NOTIFIER_ENABLED: "true" },
|
||||
"im_notifier_must_be_disabled",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), OIDC_ENABLED: "true" },
|
||||
"oidc_must_be_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses remote PostgreSQL authority and query-host overrides", () => {
|
||||
expectSafeFailure(
|
||||
{
|
||||
...validEnv(),
|
||||
DATABASE_URL:
|
||||
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test",
|
||||
},
|
||||
"database_host_not_loopback",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{
|
||||
...validEnv(),
|
||||
DATABASE_URL:
|
||||
"postgresql://test-user:[email protected]:5432/hyb_oa_auth_test?host=db.internal",
|
||||
},
|
||||
"database_host_not_loopback",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses non-isolated or unconfirmed database names", () => {
|
||||
expectSafeFailure(
|
||||
{
|
||||
...validEnv(),
|
||||
DATABASE_URL: "postgresql://test-user:[email protected]:5432/hyb_oa_dev",
|
||||
AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_dev",
|
||||
},
|
||||
"database_name_not_isolated",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), AUTH_SECURITY_DATABASE_CONFIRM: "hyb_oa_auth_other" },
|
||||
"database_confirmation_mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a remote API or enabled code logging", () => {
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), BASE_URL: "https://oa.example.test" },
|
||||
"base_url_not_loopback",
|
||||
);
|
||||
expectSafeFailure(
|
||||
{ ...validEnv(), DEV_SHOW_PASSWORD_RESET_CODE: "true" },
|
||||
"code_logging_forbidden",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the HTTP regression fail-closed and redacted", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "server/scripts/regression-auth-security.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const guardIndex = source.indexOf("validateAuthRegressionEnvironment");
|
||||
const firstApiWriteIndex = source.indexOf("$BASE_URL/api/auth/login");
|
||||
|
||||
expect(guardIndex).toBeGreaterThan(-1);
|
||||
expect(firstApiWriteIndex).toBeGreaterThan(guardIndex);
|
||||
expect(source).not.toMatch(/cat\s+[^\n]*body/i);
|
||||
expect(source).toContain("[AUTH-REGRESSION][PARTIAL] BASE_ONLY");
|
||||
expect(source).toContain("[AUTH-REGRESSION][PASS] FULL");
|
||||
expect(source).toContain("invalid_concurrency_mode");
|
||||
});
|
||||
|
||||
it("keeps auth-security-local isolated and cleanup failures authoritative", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "scripts/qa/run-web-gates.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const dropFunction = source.slice(
|
||||
source.indexOf("qa_dropdb()"),
|
||||
source.indexOf("qa_cleanup_server()"),
|
||||
);
|
||||
|
||||
expect(source).toContain("hyb-oa-web-gates-server-${QA_RUN_ID}.pid");
|
||||
expect(source).toContain('QA_SERVER_STARTED=0');
|
||||
expect(source).toContain('QA_SERVER_OWNED=0');
|
||||
expect(source).toContain('export TRUST_PROXY_HOPS=0');
|
||||
expect(source).toContain('export EMAIL_ENABLED=false');
|
||||
expect(source).toContain('export OIDC_ENABLED=false');
|
||||
expect(source).toContain('unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY');
|
||||
expect(source).toContain('QA_SERVER_ENTRY="src/scripts/start-auth-security-test-server.js"');
|
||||
expect(source).toContain('RUN_AUTH_POSTGRES_CONCURRENCY=1 ./scripts/regression-auth-security.sh');
|
||||
expect(source).toContain('exit "$cleanup_status"');
|
||||
expect(dropFunction).not.toContain("|| true");
|
||||
expect(dropFunction).toContain("return 1");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户