- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
774 行
22 KiB
JavaScript
774 行
22 KiB
JavaScript
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;
|