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, };