const crypto = require("node:crypto"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { hashPassword, randomRefreshToken, sha256Hex, 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 { sendNotificationEmailSafe } = 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]; } async function authRoutes(fastify) { const loginBodySchema = z.object({ identifier: z.string().min(3), password: z.string().min(1), }); 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.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]; const pendingWecomBind = verifyToken(pendingWecomBindToken, fastify.config.jwtSecret); 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); let wecomBindingStatus = null; await fastify.prisma.$transaction(async (tx) => { await tx.employee.update({ where: { id: employee.id }, data: { login_failed_count: 0, login_failed_first_at: null, login_locked_until: null, }, }); 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: deviceFingerprint, ip_address: request.ip, user_agent: getHeaderString(request, "user-agent") ?? "", expires_at: expiresAt, }, }); if (pendingWecomBind.ok) { const payload = pendingWecomBind.payload ?? {}; const externalUserId = typeof payload.externalUserId === "string" && payload.externalUserId.trim() ? payload.externalUserId.trim() : null; const externalOpenUserId = typeof payload.externalOpenUserId === "string" && payload.externalOpenUserId.trim() ? payload.externalOpenUserId.trim() : null; const isExpired = typeof payload.exp !== "number" || payload.exp <= Date.now(); if (!externalUserId || isExpired) { wecomBindingStatus = "invalid"; } else { const conflictingBinding = await tx.wecomIdentityBinding.findFirst({ where: { external_user_id: externalUserId, NOT: { employee_id: employee.id }, }, select: { employee_id: true }, }); if (conflictingBinding) { wecomBindingStatus = "conflict"; } else { await tx.wecomIdentityBinding.upsert({ where: { employee_id: employee.id }, update: { external_user_id: externalUserId, external_open_user_id: externalOpenUserId, updated_by: employee.id, }, create: { employee_id: employee.id, external_user_id: externalUserId, external_open_user_id: externalOpenUserId, created_by: employee.id, updated_by: employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: employee.id, action: "AUTH_WECOM_BIND", targetType: "employee", targetId: employee.id, extra: { externalUserId }, }), }); wecomBindingStatus = "bound"; } } } else if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) { wecomBindingStatus = "invalid"; } 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("/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 now = new Date(); 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, }); } if (session.expires_at <= now) { throw new AppError({ code: "AUTH_005", message: "Refresh Token 无效/已撤销", statusCode: 401, }); } if (session.revoked_at) { const rotatedReplacement = await fastify.prisma.authSession.findFirst({ where: { employee_id: session.employee_id, device_fingerprint: session.device_fingerprint, revoked_at: null, expires_at: { gt: now }, created_at: { gt: session.revoked_at }, }, select: { id: true }, }); if (rotatedReplacement) { await fastify.prisma.$transaction(async (tx) => { await tx.authSession.updateMany({ where: { employee_id: session.employee_id, revoked_at: null }, data: { revoked_at: now }, }); await tx.employee.update({ where: { id: session.employee_id }, data: { token_version: { increment: 1 } }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: session.employee_id, action: "REFRESH_REUSE_DETECTED", targetType: "employee", targetId: session.employee_id, extra: {}, }), }); }); } 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, }); } const employee = await fastify.prisma.employee.findUnique({ where: { id: session.employee_id }, select: { id: true, status: true, token_version: true }, }); if (!employee || employee.status !== "active") { throw new AppError({ code: "AUTH_005", message: "Refresh Token 无效/已撤销", statusCode: 401, }); } const newRefreshToken = randomRefreshToken(); const newRefreshTokenHash = sha256Hex(newRefreshToken); const newExpiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); await fastify.prisma.$transaction(async (tx) => { const updated = await tx.authSession.updateMany({ where: { id: session.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: {}, }), }); throw new AppError({ code: "AUTH_005", message: "Refresh Token 无效/已撤销", statusCode: 401, }); } 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: newExpiresAt, }, }); }); reply.setCookie( fastify.config.refreshCookieName, newRefreshToken, refreshCookieOptions(fastify.config), ); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); const accessToken = fastify.issueAccessToken(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 updated = 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, token_version: { increment: 1 }, }, select: { id: true, token_version: true, }, }); await tx.authSession.updateMany({ where: { employee_id: employee.id, revoked_at: null }, data: { revoked_at: now, last_seen_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 updated; }); 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().email(), }); 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(), }); } const { email } = parsed.data; const employee = await fastify.prisma.employee.findFirst({ where: { email }, select: { id: true, status: true }, }); if (!employee || employee.status !== "active") { return sendData(reply, request, {}); } const code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); const codeHash = sha256Hex(code); const now = new Date(); const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000); await fastify.prisma.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 fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: employee.id, action: "AUTH_PASSWORD_RESET_REQUESTED", targetType: "employee", targetId: employee.id, extra: {}, }), }); try { await createNotifications(fastify.prisma, { recipientEmployeeIds: [employee.id], type: "auth.password_reset_requested", title: "【安全】收到密码重置请求", body: "如非本人操作,请尽快联系管理员核查账号安全。", targetType: "employee", targetId: employee.id, }); } catch (error) { fastify.log.error( { err: error, employeeId: employee.id, 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: [employee.id], subject: "【安全】密码重置验证码", body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`, }); if (fastify.config.devShowPasswordResetCode) { fastify.log.info( { employeeId: employee.id, email, code, requestId: request.id }, "DEV password reset code", ); } return sendData(reply, request, {}); }); const resetPasswordBodySchema = z.object({ email: z.string().email(), code: z.string().length(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 employee = await fastify.prisma.employee.findFirst({ where: { email }, select: { id: true, status: true }, }); if (!employee || employee.status !== "active") { return sendData(reply, request, {}); } const now = new Date(); const codeHash = sha256Hex(code); const resetCode = await fastify.prisma.passwordResetCode.findFirst({ where: { employee_id: employee.id, code_hash: codeHash, used_at: null, expires_at: { gt: now }, }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (!resetCode) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { code: "invalid_or_expired" }, }); } const newPasswordHash = await hashPassword(newPassword); const emailTasks = []; await fastify.prisma.$transaction(async (tx) => { await tx.passwordResetCode.update({ where: { id: resetCode.id }, data: { used_at: now }, }); 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, 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: "AUTH_PASSWORD_RESET_SUCCEEDED", targetType: "employee", targetId: employee.id, extra: {}, }), }); try { await createNotifications(tx, { recipientEmployeeIds: [employee.id], type: "auth.password_reset_succeeded", title: "【安全】密码已重置", body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", targetType: "employee", targetId: employee.id, }); emailTasks.push({ type: "auth.password_reset_succeeded", recipientEmployeeIds: [employee.id], subject: "【安全】密码已重置", body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", }); } catch (error) { fastify.log.error( { err: error, employeeId: employee.id, requestId: request.id }, "Failed to create password reset succeeded notifications", ); } }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); reply.clearCookie("has_session", sessionHintCookieOptions(fastify.config)); return sendData(reply, request, {}); }); } module.exports = authRoutes;