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