oa-email-code-login 实现邮箱验证码登录
这个提交包含在:
+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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户