修复认证验证码投递与登录入口体验 05-27-oa-auth-delivery-wecom-entry-fix

这个提交包含在:
2026-05-27 10:14:49 +08:00
父节点 347d337c0c
当前提交 511efa0b2d
共修改 17 个文件,包含 746 行新增和 63 行删除
+80 -7
查看文件
@@ -8,7 +8,7 @@ const { validatePassword } = require("../utils/passwordPolicy");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { sendData } = require("../utils/reply");
const { createNotifications } = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email");
const { generators, getOidcClient } = require("../services/oidc");
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
@@ -245,6 +245,53 @@ function emailLoginCodeError() {
});
}
function emailDeliveryUnavailableError() {
return new AppError({
code: "SYS_001",
message: "邮件服务暂未启用",
statusCode: 503,
details: { email: "delivery_not_configured" },
});
}
function assertEmailDeliveryReady(config) {
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: {
@@ -303,6 +350,22 @@ async function authRoutes(fastify) {
: 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) {
@@ -574,6 +637,8 @@ async function authRoutes(fastify) {
});
}
assertEmailDeliveryReady(fastify.config);
const { email } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { email },
@@ -588,6 +653,7 @@ async function authRoutes(fastify) {
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" });
await fastify.prisma.emailLoginCode.create({
data: {
@@ -616,8 +682,8 @@ async function authRoutes(fastify) {
requestId: request.id,
type: "auth.email_login_code_requested",
recipientEmployeeIds: [employee.id],
subject: "【安全】OA 登录验证码",
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
subject: emailMessage.subject,
body: emailMessage.body,
});
return sendData(reply, request, {});
@@ -1355,7 +1421,7 @@ async function authRoutes(fastify) {
});
const forgotPasswordBodySchema = z.object({
email: z.string().email(),
email: z.string().trim().email().transform((value) => value.toLowerCase()),
});
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
@@ -1369,6 +1435,8 @@ async function authRoutes(fastify) {
});
}
assertEmailDeliveryReady(fastify.config);
const { email } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { email },
@@ -1383,6 +1451,7 @@ async function authRoutes(fastify) {
const codeHash = sha256Hex(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" });
await fastify.prisma.passwordResetCode.create({
data: {
@@ -1427,8 +1496,8 @@ async function authRoutes(fastify) {
requestId: request.id,
type: "auth.password_reset_requested",
recipientEmployeeIds: [employee.id],
subject: "【安全】密码重置验证码",
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
subject: emailMessage.subject,
body: emailMessage.body,
});
if (fastify.config.devShowPasswordResetCode) {
@@ -1442,7 +1511,7 @@ async function authRoutes(fastify) {
});
const resetPasswordBodySchema = z.object({
email: z.string().email(),
email: z.string().trim().email().transform((value) => value.toLowerCase()),
code: z.string().length(6),
newPassword: z.string().min(12),
});
@@ -1577,3 +1646,7 @@ async function authRoutes(fastify) {
}
module.exports = authRoutes;
module.exports._test = {
buildAuthVerificationEmail,
formatChinaDateTime,
};
+11 -7
查看文件
@@ -1,6 +1,7 @@
require('dotenv/config');
const { getConfig } = require('../config');
const { resolveEmailDeliveryStatus } = require('../services/email');
function assertCondition(condition, message) {
if (!condition) {
@@ -42,17 +43,20 @@ function evaluateWecom(config) {
}
function evaluateEmail(config) {
if (!config.emailEnabled) {
const status = resolveEmailDeliveryStatus(config);
if (status.reasons.includes('email_disabled')) {
return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
}
const missing = [];
if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM');
if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST');
if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT');
if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS');
const reasonLabels = {
email_from_missing: 'EMAIL_FROM',
smtp_host_missing: 'SMTP_HOST',
smtp_port_invalid: 'SMTP_PORT',
smtp_auth_incomplete: 'SMTP_USER/SMTP_PASS',
};
const missing = status.reasons.map((reason) => reasonLabels[reason] ?? reason);
assertCondition(missing.length === 0, `SMTP 配置不完整:${formatList(missing)}`);
assertCondition(status.ready, `SMTP 配置不完整:${formatList(missing)}`);
return {
state: 'ready',
+32
查看文件
@@ -51,6 +51,37 @@ function getTransporter(config) {
return cachedTransporter;
}
function resolveEmailDeliveryStatus(config) {
const reasons = [];
const authUser = String(config.smtpUser ?? '').trim();
const authPass = String(config.smtpPass ?? '').trim();
const smtpPort = Number(config.smtpPort);
if (!config.emailEnabled) {
reasons.push('email_disabled');
}
if (!String(config.emailFrom ?? '').trim()) {
reasons.push('email_from_missing');
}
if (!String(config.smtpHost ?? '').trim()) {
reasons.push('smtp_host_missing');
}
if (!Number.isFinite(smtpPort) || smtpPort <= 0) {
reasons.push('smtp_port_invalid');
}
if (authUser && !authPass) {
reasons.push('smtp_auth_incomplete');
}
if (!authUser && authPass) {
reasons.push('smtp_auth_incomplete');
}
return {
ready: reasons.length === 0,
reasons,
};
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
@@ -168,6 +199,7 @@ async function sendNotificationEmail({
}
module.exports = {
resolveEmailDeliveryStatus,
sendNotificationEmailSafe,
sendNotificationEmail,
};