oa-email-code-login 实现邮箱验证码登录

这个提交包含在:
2026-05-27 06:49:58 +08:00
父节点 afa03c0b71
当前提交 7f96cc0379
共修改 21 个文件,包含 1027 行新增和 45 行删除
+4
查看文件
@@ -40,6 +40,10 @@ function getConfig() {
passwordResetCodeTtlSeconds: Number(
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeTtlSeconds: Number(
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeMaxAttempts: Number(process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5),
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
+258 -30
查看文件
@@ -228,11 +228,69 @@ function parseAccessTokenTtlSeconds(value) {
return amount * secondsByUnit[unit];
}
function generateSixDigitCode() {
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
}
function hashEmailLoginCode(employeeId, code, secret) {
return crypto.createHmac("sha256", secret).update(`${employeeId}:${code}`).digest("hex");
}
function emailLoginCodeError() {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
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) {
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";
@@ -409,36 +467,13 @@ async function authRoutes(fastify) {
},
});
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 createRefreshSession(tx, {
employeeId: employee.id,
refreshTokenHash,
deviceFingerprint,
request,
now,
expiresAt,
});
if (pendingWecomBind.ok) {
@@ -528,6 +563,199 @@ async function authRoutes(fastify) {
});
});
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(),
});
}
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 = generateSixDigitCode();
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
await fastify.prisma.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 fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
type: "auth.email_login_code_requested",
recipientEmployeeIds: [employee.id],
subject: "【安全】OA 登录验证码",
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
});
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 employee = await fastify.prisma.employee.findFirst({
where: { email },
select: {
id: true,
status: true,
token_version: true,
must_change_password: true,
},
});
if (!employee || employee.status !== "active") {
throw emailLoginCodeError();
}
const now = new Date();
const loginCode = await fastify.prisma.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,
},
});
if (!loginCode || loginCode.attempts_count >= fastify.config.emailLoginCodeMaxAttempts) {
throw emailLoginCodeError();
}
const expectedCodeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
if (loginCode.code_hash !== expectedCodeHash) {
const nextAttempts = loginCode.attempts_count + 1;
await fastify.prisma.$transaction(async (tx) => {
await tx.emailLoginCode.update({
where: { id: loginCode.id },
data: { attempts_count: { increment: 1 } },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_EMAIL_LOGIN_FAILED",
targetType: "employee",
targetId: employee.id,
extra: { attemptsCount: nextAttempts },
}),
});
});
throw emailLoginCodeError();
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const used = await tx.emailLoginCode.updateMany({
where: { id: loginCode.id, used_at: null },
data: { used_at: now },
});
if (used.count !== 1) {
throw emailLoginCodeError();
}
await tx.employee.update({
where: { id: employee.id },
data: {
login_failed_count: 0,
login_failed_first_at: null,
login_locked_until: null,
},
});
await createRefreshSession(tx, {
employeeId: employee.id,
refreshTokenHash,
deviceFingerprint,
request,
now,
expiresAt,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_EMAIL_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));
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, {
accessToken,
expiresIn: accessTokenExpiresIn,
mustChangePassword: Boolean(employee.must_change_password),
});
});
fastify.post("/refresh", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken !== "string" || !refreshToken) {