feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
+239
-2
@@ -76,6 +76,10 @@ async function authRoutes(fastify) {
|
||||
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) {
|
||||
@@ -93,8 +97,96 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
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: "用户名或密码错误",
|
||||
@@ -104,10 +196,18 @@ async function authRoutes(fastify) {
|
||||
|
||||
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) => {
|
||||
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,
|
||||
@@ -158,7 +258,10 @@ async function authRoutes(fastify) {
|
||||
);
|
||||
|
||||
const accessToken = fastify.issueAccessToken(employee);
|
||||
return sendData(reply, request, { accessToken });
|
||||
return sendData(reply, request, {
|
||||
accessToken,
|
||||
mustChangePassword: Boolean(employee.must_change_password),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/refresh", async (request, reply) => {
|
||||
@@ -378,6 +481,136 @@ async function authRoutes(fastify) {
|
||||
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(),
|
||||
});
|
||||
@@ -508,6 +741,10 @@ async function authRoutes(fastify) {
|
||||
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 },
|
||||
},
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户