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 { sendData } = require("../utils/reply"); 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 refreshCookieOptions(config) { return { httpOnly: true, sameSite: "strict", secure: config.cookieSecure, path: "/api/auth", maxAge: config.refreshTtlSeconds, }; } async function authRoutes(fastify) { const loginBodySchema = z.object({ identifier: z.string().min(3), password: z.string().min(1), }); const authRateLimit = fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function" ? fastify.rateLimit({ max: fastify.config.rateLimitLoginMax, timeWindow: fastify.config.rateLimitLoginWindow, }) : undefined; 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(); 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); 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, }, }); 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), ); const accessToken = fastify.issueAccessToken(employee); return sendData(reply, request, { accessToken, mustChangePassword: Boolean(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 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 expectedOrigin = fastify.config.appOrigin; const requestOrigin = getRequestOrigin(request); if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: session.employee_id, action: "AUTH_REFRESH_ORIGIN_MISMATCH", targetType: "employee", targetId: session.employee_id, extra: { expectedOrigin, 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)); 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), ); const accessToken = fastify.issueAccessToken(employee); return sendData(reply, request, { accessToken }); }); 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)); 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), ); const accessToken = fastify.issueAccessToken(updatedEmployee); return sendData(reply, request, { accessToken, 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: {}, }), }); 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); 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: {}, }), }); }); reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); return sendData(reply, request, {}); }); } module.exports = authRoutes;