文件
hyb-oa/server/src/routes/auth.js
T
wangsli 9b12451b85
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
实现企微首次免密自动绑定(07-28-all-account-login-readiness)
2026-07-28 11:18:50 +08:00

1975 行
58 KiB
JavaScript

const crypto = require("node:crypto");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const {
generateSixDigitCode,
hashAuthCode,
hashPassword,
randomRefreshToken,
sha256Hex,
timingSafeEqualHex,
verifyPassword,
} = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { sendData } = require("../utils/reply");
const { createNotifications } = require("../services/notifications");
const defaultEmailService = require("../services/email");
const { generators, getOidcClient } = require("../services/oidc");
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
const PENDING_WECOM_BIND_WINDOW_MS = 10 * 60 * 1000;
function getHeaderString(request, name) {
const value = request.headers[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function getRequestOrigin(request) {
const origin = getHeaderString(request, "origin");
if (origin) return origin;
const referer = getHeaderString(request, "referer");
if (!referer) return null;
try {
return new URL(referer).origin;
} catch {
return null;
}
}
function normalizeOriginValue(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
try {
return new URL(trimmed).origin.replace(/\/+$/, "");
} catch {
return null;
}
}
function parseAllowedOriginContext(config, request) {
const allowedOrigins = new Set();
const allowedHosts = new Set();
for (const item of String(config.appOrigin || "").split(",").map((value) => value.trim()).filter(Boolean)) {
const normalized = normalizeOriginValue(item);
if (!normalized) {
continue;
}
allowedOrigins.add(normalized);
try {
allowedHosts.add(new URL(normalized).host);
} catch {
// ignore invalid app origin host
}
}
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((value) => value.trim()).filter(Boolean)) {
allowedHosts.add(item);
}
if (!allowedOrigins.size) {
const fallbackOrigin = normalizeOriginValue(resolvePrimaryAppOrigin(config, request));
if (fallbackOrigin) {
allowedOrigins.add(fallbackOrigin);
try {
allowedHosts.add(new URL(fallbackOrigin).host);
} catch {
// ignore
}
}
}
return { allowedOrigins, allowedHosts };
}
function isRequestOriginAllowed(config, request) {
const requestOrigin = getRequestOrigin(request);
if (!requestOrigin) {
return { allowed: true, requestOrigin: null, expectedOrigins: [] };
}
const normalizedRequestOrigin = normalizeOriginValue(requestOrigin);
if (!normalizedRequestOrigin) {
return { allowed: false, requestOrigin, expectedOrigins: [] };
}
const { allowedOrigins, allowedHosts } = parseAllowedOriginContext(config, request);
if (allowedOrigins.has(normalizedRequestOrigin)) {
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
}
try {
const host = new URL(normalizedRequestOrigin).host;
if (allowedHosts.has(host)) {
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
}
} catch {
// ignore invalid host
}
return { allowed: false, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
}
function refreshCookieOptions(config) {
return {
httpOnly: true,
sameSite: "strict",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: config.refreshTtlSeconds,
};
}
function pendingWecomBindCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: PENDING_WECOM_BIND_WINDOW_MS / 1000,
};
}
function sessionHintCookieOptions(config) {
return {
httpOnly: false,
sameSite: "strict",
secure: config.cookieSecure,
path: "/",
maxAge: config.refreshTtlSeconds,
};
}
function oidcCookieOptions(config) {
return {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/api/auth/oidc",
maxAge: 10 * 60,
};
}
function resolvePrimaryAppOrigin(config, request) {
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
const first = configured
.split(",")
.map((s) => s.trim())
.filter(Boolean)[0];
if (first) return first;
const host = request.headers.host ?? "127.0.0.1:3000";
const protocol = request.protocol ?? "http";
return `${protocol}://${host}`;
}
function sanitizeReturnTo(value) {
if (typeof value !== "string") {
return "/";
}
const trimmed = value.trim();
if (!trimmed || !trimmed.startsWith("/")) {
return "/";
}
// Block protocol-relative URLs.
if (trimmed.startsWith("//")) {
return "/";
}
return trimmed;
}
function isWecomWebViewRequest(request) {
const userAgent = String(request.headers["user-agent"] ?? "").toLowerCase();
return userAgent.includes("wxwork");
}
function buildWecomLoginRedirect(origin, targetPath) {
const params = new URLSearchParams({ returnTo: targetPath });
const path = `/api/auth/wecom/login?${params.toString()}`;
return origin ? `${origin}${path}` : path;
}
function parseAccessTokenTtlSeconds(value) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value !== "string") {
throw new Error("Invalid access token TTL");
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error("Invalid access token TTL");
}
if (/^\d+$/.test(trimmed)) {
return Number(trimmed);
}
const match = trimmed.match(/^(\d+)\s*([smhd])$/i);
if (!match) {
throw new Error("Invalid access token TTL");
}
const amount = Number(match[1]);
const unit = match[2].toLowerCase();
const secondsByUnit = {
s: 1,
m: 60,
h: 60 * 60,
d: 24 * 60 * 60,
};
return amount * secondsByUnit[unit];
}
const AUTH_CODE_PURPOSE = Object.freeze({
emailLogin: "email_login",
passwordReset: "password_reset",
});
function hashEmployeeAuthCode(config, employeeId, code, purpose) {
return hashAuthCode({
employeeId,
code,
purpose,
pepper: config.authCodePepper,
});
}
function emailLoginCodeError() {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
function passwordResetCodeError() {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
function isFutureDate(value, now) {
return value instanceof Date && value.getTime() > now.getTime();
}
function isWithinWindow(value, now, windowMs) {
return value instanceof Date && now.getTime() - value.getTime() <= windowMs;
}
function nextFailureState({ currentCount, firstAt, now, windowMs, maxAttempts, lockMs }) {
let nextFirstAt = firstAt instanceof Date ? firstAt : now;
const parsedCount = Number(currentCount ?? 0);
let nextCount = Number.isInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
if (!isWithinWindow(nextFirstAt, now, windowMs)) {
nextFirstAt = now;
nextCount = 0;
}
nextCount += 1;
const locked = nextCount >= maxAttempts;
return {
count: nextCount,
firstAt: nextFirstAt,
locked,
lockedUntil: locked ? new Date(now.getTime() + lockMs) : null,
};
}
function nextRequestState({ currentCount, firstAt, lastRequestedAt, now, cooldownMs, windowMs, maxRequests }) {
if (isWithinWindow(lastRequestedAt, now, cooldownMs)) {
return { allowed: false };
}
let nextFirstAt = firstAt instanceof Date ? firstAt : now;
const parsedCount = Number(currentCount ?? 0);
let nextCount = Number.isInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
if (!isWithinWindow(nextFirstAt, now, windowMs)) {
nextFirstAt = now;
nextCount = 0;
}
if (nextCount >= maxRequests) {
return { allowed: false };
}
return {
allowed: true,
count: nextCount + 1,
firstAt: nextFirstAt,
};
}
async function lockAuthEmployeeByEmail(tx, email) {
const rows = await tx.$queryRaw`
SELECT
id,
status,
token_version,
must_change_password,
email_login_code_failed_count,
email_login_code_failed_first_at,
email_login_code_locked_until,
email_login_code_last_requested_at,
password_reset_failed_count,
password_reset_failed_first_at,
password_reset_locked_until,
password_reset_request_count,
password_reset_request_first_at,
password_reset_last_requested_at
FROM "employees"
WHERE LOWER(email) = ${email}
ORDER BY id
LIMIT 2
FOR UPDATE
`;
return Array.isArray(rows) && rows.length === 1 ? rows[0] : null;
}
async function lockAuthEmployeeById(tx, employeeId) {
const rows = await tx.$queryRaw`
SELECT
id,
status,
token_version
FROM "employees"
WHERE id = ${employeeId}::uuid
FOR UPDATE
`;
return Array.isArray(rows) && rows.length === 1 ? rows[0] : null;
}
async function recordEmailLoginCodeFailure(tx, { employee, loginCode, request, config, now }) {
const failure = nextFailureState({
currentCount: employee.email_login_code_failed_count,
firstAt: employee.email_login_code_failed_first_at,
now,
windowMs: config.emailLoginCodeFailWindowSeconds * 1000,
maxAttempts: config.emailLoginCodeMaxAttempts,
lockMs: config.emailLoginCodeLockSeconds * 1000,
});
await tx.employee.update({
where: { id: employee.id },
data: {
email_login_code_failed_count: failure.count,
email_login_code_failed_first_at: failure.firstAt,
email_login_code_locked_until: failure.lockedUntil,
},
});
if (loginCode) {
await tx.emailLoginCode.updateMany({
where: { id: loginCode.id, used_at: null },
data: {
attempts_count: { increment: 1 },
...(failure.locked ? { used_at: now } : {}),
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: failure.locked ? "AUTH_EMAIL_LOGIN_LOCKED" : "AUTH_EMAIL_LOGIN_FAILED",
targetType: "employee",
targetId: employee.id,
extra: {
attemptsCount: failure.count,
locked: failure.locked,
},
}),
});
return { ok: false };
}
async function recordPasswordResetFailure(tx, { employee, resetCode, request, config, now }) {
const failure = nextFailureState({
currentCount: employee.password_reset_failed_count,
firstAt: employee.password_reset_failed_first_at,
now,
windowMs: config.passwordResetFailWindowSeconds * 1000,
maxAttempts: config.passwordResetMaxAttempts,
lockMs: config.passwordResetLockSeconds * 1000,
});
await tx.employee.update({
where: { id: employee.id },
data: {
password_reset_failed_count: failure.count,
password_reset_failed_first_at: failure.firstAt,
password_reset_locked_until: failure.lockedUntil,
},
});
if (resetCode) {
await tx.passwordResetCode.updateMany({
where: {
id: resetCode.id,
used_at: null,
invalidated_at: null,
},
data: {
attempts_count: { increment: 1 },
...(failure.locked ? { invalidated_at: now } : {}),
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: failure.locked ? "AUTH_PASSWORD_RESET_LOCKED" : "AUTH_PASSWORD_RESET_FAILED",
targetType: "employee",
targetId: employee.id,
extra: {
attemptsCount: failure.count,
locked: failure.locked,
},
}),
});
return { ok: false };
}
function emailDeliveryUnavailableError() {
return new AppError({
code: "SYS_001",
message: "邮件服务暂未启用",
statusCode: 503,
details: { email: "delivery_not_configured" },
});
}
function assertEmailDeliveryReady(config, resolveEmailDeliveryStatus) {
const status = resolveEmailDeliveryStatus(config);
if (!status.ready) {
throw emailDeliveryUnavailableError();
}
return status;
}
function getPart(parts, type) {
return parts.find((part) => part.type === type)?.value ?? "";
}
function formatChinaDateTime(date) {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
return `${getPart(parts, "year")}年${getPart(parts, "month")}月${getPart(parts, "day")}日 ${getPart(parts, "hour")}:${getPart(parts, "minute")}(北京时间)`;
}
function buildAuthVerificationEmail({ code, expiresAt, purpose }) {
const subject =
purpose === "password_reset"
? "【海音达OA】密码重置验证码"
: "【海音达OA】登录验证码";
return {
subject,
body: `验证码:${code}\n有效期至:${formatChinaDateTime(expiresAt)}\n如非本人操作,请忽略并联系管理员。`,
};
}
async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employeeId,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({
where: { id: { in: idsToRevoke } },
data: { revoked_at: now },
});
}
await tx.authSession.create({
data: {
employee_id: employeeId,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
}
async function authRoutes(fastify, options = {}) {
const emailService = options.emailService ?? defaultEmailService;
const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = emailService;
const loginBodySchema = z.object({
identifier: z.string().min(3),
password: z.string().min(1),
});
const emailLoginCodeRequestBodySchema = z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
});
const emailLoginVerifyBodySchema = z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
code: z.string().regex(/^\d{6}$/),
});
const oidcCookieName = "oidc_login";
const authRateLimit =
fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function"
? fastify.rateLimit({
max: fastify.config.rateLimitLoginMax,
timeWindow: fastify.config.rateLimitLoginWindow,
})
: undefined;
const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl);
fastify.get("/capabilities", async (request, reply) => {
const emailDelivery = resolveEmailDeliveryStatus(fastify.config);
return sendData(reply, request, {
emailDelivery: {
ready: emailDelivery.ready,
statusReasons: emailDelivery.reasons,
},
emailLoginCode: {
enabled: emailDelivery.ready,
},
passwordReset: {
enabled: emailDelivery.ready,
},
});
});
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { identifier, password } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { OR: [{ phone: identifier }, { email: identifier }] },
select: {
id: true,
status: true,
password_hash: true,
token_version: true,
must_change_password: true,
login_failed_count: true,
login_failed_first_at: true,
login_locked_until: true,
},
});
if (!employee) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
if (employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const now = new Date();
const pendingWecomBindToken = request.cookies?.[PENDING_WECOM_BIND_COOKIE_NAME];
if (employee.login_locked_until && employee.login_locked_until > now) {
throw new AppError({
code: "AUTH_002",
message: "账号已锁定,请稍后重试",
statusCode: 423,
details: {
lockedUntil: employee.login_locked_until.toISOString(),
},
});
}
const ok = await verifyPassword(password, employee.password_hash);
if (!ok) {
const windowMs = fastify.config.loginFailWindowSeconds * 1000;
const lockMs = fastify.config.loginLockSeconds * 1000;
const maxAttempts = fastify.config.loginFailMaxAttempts;
const outcome = await fastify.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw`
SELECT id, login_failed_count, login_failed_first_at
FROM "employees"
WHERE id = ${employee.id}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const current = rows[0];
const firstAt = current.login_failed_first_at;
let nextFirstAt = firstAt;
let nextCount = Number(current.login_failed_count ?? 0);
if (!nextFirstAt || now.getTime() - nextFirstAt.getTime() > windowMs) {
nextFirstAt = now;
nextCount = 0;
}
nextCount += 1;
let lockedUntil = null;
let locked = false;
if (nextCount >= maxAttempts) {
locked = true;
lockedUntil = new Date(now.getTime() + lockMs);
}
await tx.employee.update({
where: { id: employee.id },
data: {
login_failed_count: nextCount,
login_failed_first_at: nextFirstAt,
login_locked_until: lockedUntil,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: locked ? "AUTH_LOGIN_LOCKED" : "AUTH_LOGIN_FAILED",
targetType: "employee",
targetId: employee.id,
extra: {
failedCount: nextCount,
windowStart: nextFirstAt.toISOString(),
lockedUntil: lockedUntil ? lockedUntil.toISOString() : null,
},
}),
});
return { locked, lockedUntil };
});
if (outcome.locked) {
throw new AppError({
code: "AUTH_002",
message: "账号已锁定,请稍后重试",
statusCode: 423,
details: {
lockedUntil: outcome.lockedUntil ? outcome.lockedUntil.toISOString() : null,
},
});
}
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
const wecomBindingStatus = null;
await fastify.prisma.$transaction(async (tx) => {
const guardedEmployee = await tx.employee.updateMany({
where: {
id: employee.id,
status: "active",
password_hash: employee.password_hash,
token_version: employee.token_version,
OR: [
{ login_locked_until: null },
{ login_locked_until: { lte: now } },
],
},
data: {
login_failed_count: 0,
login_failed_first_at: null,
login_locked_until: null,
},
});
if (guardedEmployee.count !== 1) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
await createRefreshSession(tx, {
employeeId: employee.id,
refreshTokenHash,
deviceFingerprint,
request,
now,
expiresAt,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) {
reply.clearCookie(PENDING_WECOM_BIND_COOKIE_NAME, pendingWecomBindCookieOptions(fastify.config));
}
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
mustChangePassword: Boolean(employee.must_change_password),
wecomBindingStatus,
});
});
fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
const { email } = parsed.data;
const issuedCode = await fastify.prisma.$transaction(async (tx) => {
const employee = await lockAuthEmployeeByEmail(tx, email);
const now = new Date();
if (
!employee ||
employee.status !== "active" ||
isFutureDate(employee.email_login_code_locked_until, now) ||
isWithinWindow(
employee.email_login_code_last_requested_at,
now,
fastify.config.authCodeRequestCooldownSeconds * 1000,
)
) {
return null;
}
const code = generateSixDigitCode();
const codeHash = hashEmployeeAuthCode(
fastify.config,
employee.id,
code,
AUTH_CODE_PURPOSE.emailLogin,
);
const expiresAt = new Date(
now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000,
);
await tx.emailLoginCode.updateMany({
where: { employee_id: employee.id, used_at: null },
data: { used_at: now },
});
await tx.emailLoginCode.create({
data: {
employee_id: employee.id,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: request.ip,
created_user_agent: getHeaderString(request, "user-agent"),
},
});
await tx.employee.update({
where: { id: employee.id },
data: { email_login_code_last_requested_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return { employeeId: employee.id, code, expiresAt };
});
if (issuedCode) {
const emailMessage = buildAuthVerificationEmail({
code: issuedCode.code,
expiresAt: issuedCode.expiresAt,
purpose: "login",
});
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: "auth.email_login_code_requested",
recipientEmployeeIds: [issuedCode.employeeId],
subject: emailMessage.subject,
body: emailMessage.body,
});
}
return sendData(reply, request, {});
});
fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = emailLoginVerifyBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { email, code } = parsed.data;
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const outcome = await fastify.prisma.$transaction(async (tx) => {
const employee = await lockAuthEmployeeByEmail(tx, email);
const now = new Date();
if (
!employee ||
employee.status !== "active" ||
isFutureDate(employee.email_login_code_locked_until, now)
) {
return { ok: false };
}
const loginCode = await tx.emailLoginCode.findFirst({
where: {
employee_id: employee.id,
used_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "desc" },
select: {
id: true,
code_hash: true,
attempts_count: true,
},
});
const expectedCodeHash = hashEmployeeAuthCode(
fastify.config,
employee.id,
code,
AUTH_CODE_PURPOSE.emailLogin,
);
if (!loginCode) {
return { ok: false };
}
if (
loginCode.attempts_count >= fastify.config.emailLoginCodeMaxAttempts ||
!timingSafeEqualHex(loginCode.code_hash, expectedCodeHash)
) {
return recordEmailLoginCodeFailure(tx, {
employee,
loginCode,
request,
config: fastify.config,
now,
});
}
const used = await tx.emailLoginCode.updateMany({
where: {
id: loginCode.id,
employee_id: employee.id,
code_hash: loginCode.code_hash,
used_at: null,
expires_at: { gt: now },
},
data: { used_at: now },
});
if (used.count !== 1) {
return { ok: false };
}
await tx.employee.update({
where: { id: employee.id },
data: {
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,
},
});
await createRefreshSession(tx, {
employeeId: employee.id,
refreshTokenHash,
deviceFingerprint,
request,
now,
expiresAt: new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000),
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_EMAIL_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return { ok: true, employee };
});
if (!outcome.ok) {
throw emailLoginCodeError();
}
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
const accessToken = fastify.issueAccessToken(outcome.employee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
mustChangePassword: Boolean(outcome.employee.must_change_password),
});
});
fastify.post("/refresh", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken !== "string" || !refreshToken) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const refreshTokenHash = sha256Hex(refreshToken);
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: {
id: true,
employee_id: true,
device_fingerprint: true,
expires_at: true,
revoked_at: true,
},
});
if (!session) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.device_fingerprint !== deviceFingerprint) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const originCheck = isRequestOriginAllowed(fastify.config, request);
if (!originCheck.allowed) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
targetType: "employee",
targetId: session.employee_id,
extra: { expectedOrigins: originCheck.expectedOrigins, requestOrigin: originCheck.requestOrigin },
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const newRefreshToken = randomRefreshToken();
const newRefreshTokenHash = sha256Hex(newRefreshToken);
const outcome = await fastify.prisma.$transaction(async (tx) => {
const employee = await lockAuthEmployeeById(tx, session.employee_id);
if (!employee || employee.status !== "active") {
return { ok: false };
}
const now = new Date();
const currentSession = await tx.authSession.findUnique({
where: { id: session.id },
select: {
id: true,
employee_id: true,
device_fingerprint: true,
expires_at: true,
revoked_at: true,
},
});
if (
!currentSession ||
currentSession.employee_id !== employee.id ||
currentSession.device_fingerprint !== deviceFingerprint ||
currentSession.expires_at <= now
) {
return { ok: false };
}
if (currentSession.revoked_at) {
const rotatedReplacement = await tx.authSession.findFirst({
where: {
employee_id: employee.id,
device_fingerprint: currentSession.device_fingerprint,
revoked_at: null,
expires_at: { gt: now },
created_at: { gt: currentSession.revoked_at },
},
select: { id: true },
});
if (rotatedReplacement) {
await tx.employee.update({
where: { id: employee.id },
data: { token_version: { increment: 1 } },
});
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
}
return { ok: false };
}
const updated = await tx.authSession.updateMany({
where: {
id: currentSession.id,
revoked_at: null,
expires_at: { gt: now },
},
data: { revoked_at: now, last_seen_at: now },
});
if (updated.count !== 1) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return { ok: false };
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: newRefreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000),
},
});
return { ok: true, employee };
});
if (!outcome.ok) {
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
reply.setCookie(
fastify.config.refreshCookieName,
newRefreshToken,
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
const accessToken = fastify.issueAccessToken(outcome.employee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
});
});
fastify.get("/sso/providers", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
return reply.send({ requestId: request.id, data: [] });
}
return reply.send({
requestId: request.id,
data: [
{
id: "oidc",
label: process.env.OIDC_PROVIDER_LABEL || "企业统一登录",
enabled: true,
loginUrl: "/api/auth/oidc/login",
},
],
});
});
fastify.get("/deeplink", async (request, reply) => {
const token = String(request.query?.token ?? "").trim();
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "AUTH_001",
message: "无效深链",
statusCode: 401,
details: { token: verified.reason },
});
}
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { targetPath: "invalid_target_path" },
});
}
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
if (isWecomWebViewRequest(request)) {
return reply.redirect(buildWecomLoginRedirect(origin, targetPath));
}
return reply.redirect(origin ? `${origin}${targetPath}` : targetPath);
});
fastify.get("/oidc/login", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const returnTo = sanitizeReturnTo(request.query?.returnTo);
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const deviceFingerprint =
getHeaderString(request, "x-device-fingerprint") ?? `oidc:${crypto.randomUUID()}`;
const expiresAt = Date.now() + 10 * 60 * 1000;
const token = signPayload(
{
exp: expiresAt,
state,
nonce,
codeVerifier,
returnTo,
deviceFingerprint,
},
fastify.config.jwtSecret,
);
reply.setCookie(oidcCookieName, token, oidcCookieOptions(fastify.config));
const { client, config } = await getOidcClient(fastify.config);
const authorizationUrl = client.authorizationUrl({
scope: config.scopes,
redirect_uri: config.redirectUri,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authorizationUrl);
});
fastify.get("/oidc/callback", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const cookieToken = request.cookies?.[oidcCookieName];
const verified = verifyToken(cookieToken, fastify.config.jwtSecret);
if (!verified.ok) {
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const ctx = verified.payload ?? {};
const queryState = request.query?.state;
if (
typeof ctx.exp !== "number" ||
ctx.exp <= Date.now() ||
typeof ctx.state !== "string" ||
typeof ctx.nonce !== "string" ||
typeof ctx.codeVerifier !== "string" ||
typeof queryState !== "string" ||
queryState !== ctx.state
) {
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
let claims = null;
try {
const { client, config } = await getOidcClient(fastify.config);
const params = client.callbackParams(request.raw);
const tokenSet = await client.callback(config.redirectUri, params, {
state: ctx.state,
nonce: ctx.nonce,
code_verifier: ctx.codeVerifier,
});
claims = tokenSet?.claims?.() ?? null;
} catch (error) {
fastify.log.error({ err: error, requestId: request.id }, "OIDC callback failed");
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const email =
typeof claims?.email === "string" && claims.email.trim()
? claims.email.trim()
: typeof claims?.preferred_username === "string" && claims.preferred_username.trim()
? claims.preferred_username.trim()
: "";
if (!email) {
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const employee = await fastify.prisma.employee.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
select: { id: true, status: true, token_version: true },
});
if (!employee || employee.status !== "active") {
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employee.id,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({
where: { id: { in: idsToRevoke } },
data: { revoked_at: now },
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: refreshTokenHash,
device_fingerprint: ctx.deviceFingerprint || `oidc:${crypto.randomUUID()}`,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_OIDC_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: { email },
}),
});
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
const origin = resolvePrimaryAppOrigin(fastify.config, request);
const returnTo = sanitizeReturnTo(ctx.returnTo);
return reply.redirect(`${origin}${returnTo}`);
});
fastify.post("/logout", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken === "string" && refreshToken) {
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: { id: true, employee_id: true, revoked_at: true },
});
await fastify.prisma.authSession.updateMany({
where: { refresh_token_hash: refreshTokenHash, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
if (session?.employee_id) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_LOGOUT",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
}
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config));
return sendData(reply, request, {});
});
const changePasswordBodySchema = z.object({
oldPassword: z.string().min(1),
newPassword: z.string().min(1),
});
fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => {
const parsed = changePasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { oldPassword, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
status: true,
password_hash: true,
token_version: true,
},
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const ok = await verifyPassword(oldPassword, employee.password_hash);
if (!ok) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const newPasswordHash = await hashPassword(newPassword);
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
const updatedEmployee = await fastify.prisma.$transaction(async (tx) => {
const guardedEmployee = await tx.employee.updateMany({
where: {
id: employee.id,
status: "active",
password_hash: employee.password_hash,
token_version: employee.token_version,
},
data: {
password_hash: newPasswordHash,
must_change_password: false,
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 },
},
});
if (guardedEmployee.count !== 1) {
throw new AppError({
code: "AUTH_005",
message: "会话已失效,请重新登录",
statusCode: 401,
});
}
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
await tx.passwordResetCode.updateMany({
where: { employee_id: employee.id, invalidated_at: null },
data: { invalidated_at: now },
});
await tx.emailLoginCode.updateMany({
where: { employee_id: employee.id, used_at: null },
data: { used_at: now },
});
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_CHANGED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return {
id: employee.id,
token_version: employee.token_version + 1,
};
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
const accessToken = fastify.issueAccessToken(updatedEmployee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
mustChangePassword: false,
});
});
const forgotPasswordBodySchema = z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
});
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
const { email } = parsed.data;
const issuedCode = await fastify.prisma.$transaction(async (tx) => {
const employee = await lockAuthEmployeeByEmail(tx, email);
const now = new Date();
if (
!employee ||
employee.status !== "active" ||
isFutureDate(employee.password_reset_locked_until, now)
) {
return null;
}
const requestState = nextRequestState({
currentCount: employee.password_reset_request_count,
firstAt: employee.password_reset_request_first_at,
lastRequestedAt: employee.password_reset_last_requested_at,
now,
cooldownMs: fastify.config.authCodeRequestCooldownSeconds * 1000,
windowMs: fastify.config.passwordResetRequestWindowSeconds * 1000,
maxRequests: fastify.config.passwordResetRequestMax,
});
if (!requestState.allowed) {
return null;
}
const code = generateSixDigitCode();
const codeHash = hashEmployeeAuthCode(
fastify.config,
employee.id,
code,
AUTH_CODE_PURPOSE.passwordReset,
);
const expiresAt = new Date(
now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000,
);
await tx.passwordResetCode.updateMany({
where: {
employee_id: employee.id,
used_at: null,
invalidated_at: null,
},
data: { invalidated_at: now },
});
await tx.passwordResetCode.create({
data: {
employee_id: employee.id,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: request.ip,
created_user_agent: getHeaderString(request, "user-agent"),
},
});
await tx.employee.update({
where: { id: employee.id },
data: {
password_reset_request_count: requestState.count,
password_reset_request_first_at: requestState.firstAt,
password_reset_last_requested_at: now,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return { employeeId: employee.id, code, expiresAt };
});
if (issuedCode) {
const emailMessage = buildAuthVerificationEmail({
code: issuedCode.code,
expiresAt: issuedCode.expiresAt,
purpose: "password_reset",
});
try {
await createNotifications(fastify.prisma, {
recipientEmployeeIds: [issuedCode.employeeId],
type: "auth.password_reset_requested",
title: "【安全】收到密码重置请求",
body: "如非本人操作,请尽快联系管理员核查账号安全。",
targetType: "employee",
targetId: issuedCode.employeeId,
});
} catch {
fastify.log.error(
{
errorCode: "notification_create_failed",
employeeId: issuedCode.employeeId,
requestId: request.id,
},
"Failed to create password reset requested notifications",
);
}
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: "auth.password_reset_requested",
recipientEmployeeIds: [issuedCode.employeeId],
subject: emailMessage.subject,
body: emailMessage.body,
});
if (fastify.config.devShowPasswordResetCode) {
fastify.log.info(
{ employeeId: issuedCode.employeeId, code: issuedCode.code, requestId: request.id },
"DEV password reset code",
);
}
}
return sendData(reply, request, {});
});
const resetPasswordBodySchema = z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
code: z.string().regex(/^\d{6}$/),
newPassword: z.string().min(12),
});
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
}
const outcome = await fastify.prisma.$transaction(async (tx) => {
const employee = await lockAuthEmployeeByEmail(tx, email);
const now = new Date();
if (
!employee ||
employee.status !== "active" ||
isFutureDate(employee.password_reset_locked_until, now)
) {
return { ok: false };
}
const resetCode = await tx.passwordResetCode.findFirst({
where: {
employee_id: employee.id,
used_at: null,
invalidated_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "desc" },
select: {
id: true,
code_hash: true,
attempts_count: true,
},
});
const expectedCodeHash = hashEmployeeAuthCode(
fastify.config,
employee.id,
code,
AUTH_CODE_PURPOSE.passwordReset,
);
if (!resetCode) {
return { ok: false };
}
if (
resetCode.attempts_count >= fastify.config.passwordResetMaxAttempts ||
!timingSafeEqualHex(resetCode.code_hash, expectedCodeHash)
) {
return recordPasswordResetFailure(tx, {
employee,
resetCode,
request,
config: fastify.config,
now,
});
}
const newPasswordHash = await hashPassword(newPassword);
const consumed = await tx.passwordResetCode.updateMany({
where: {
id: resetCode.id,
employee_id: employee.id,
code_hash: resetCode.code_hash,
used_at: null,
invalidated_at: null,
expires_at: { gt: now },
},
data: { used_at: now },
});
if (consumed.count !== 1) {
return { ok: false };
}
await tx.employee.update({
where: { id: employee.id },
data: {
password_hash: newPasswordHash,
must_change_password: false,
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 },
},
});
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
await tx.passwordResetCode.updateMany({
where: { employee_id: employee.id, invalidated_at: null },
data: { invalidated_at: now },
});
await tx.emailLoginCode.updateMany({
where: { employee_id: employee.id, used_at: null },
data: { used_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_SUCCEEDED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
return { ok: true, employeeId: employee.id };
});
if (!outcome.ok) {
throw passwordResetCodeError();
}
try {
await createNotifications(fastify.prisma, {
recipientEmployeeIds: [outcome.employeeId],
type: "auth.password_reset_succeeded",
title: "【安全】密码已重置",
body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。",
targetType: "employee",
targetId: outcome.employeeId,
});
} catch {
fastify.log.error(
{
errorCode: "notification_create_failed",
employeeId: outcome.employeeId,
requestId: request.id,
},
"Failed to create password reset succeeded notifications",
);
}
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: "auth.password_reset_succeeded",
recipientEmployeeIds: [outcome.employeeId],
subject: "【安全】密码已重置",
body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。",
});
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config));
return sendData(reply, request, {});
});
}
module.exports = authRoutes;
module.exports._test = {
buildAuthVerificationEmail,
formatChinaDateTime,
};