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

这个提交包含在:
2026-05-27 06:49:58 +08:00
父节点 afa03c0b71
当前提交 7f96cc0379
共修改 21 个文件,包含 1027 行新增和 45 行删除
@@ -0,0 +1,24 @@
CREATE TABLE "email_login_codes" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"code_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"attempts_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_ip" TEXT,
"created_user_agent" TEXT,
CONSTRAINT "email_login_codes_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "email_login_codes_employee_id_created_at_idx"
ON "email_login_codes"("employee_id", "created_at");
CREATE INDEX "email_login_codes_expires_at_idx"
ON "email_login_codes"("expires_at");
ALTER TABLE "email_login_codes"
ADD CONSTRAINT "email_login_codes_employee_id_fkey"
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
+19
查看文件
@@ -680,6 +680,7 @@ model Employee {
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
password_reset_codes PasswordResetCode[]
email_login_codes EmailLoginCode[]
lifecycle_events EmployeeLifecycleEvent[]
checklist_tasks EmployeeChecklistTask[]
presence_records EmployeePresence[]
@@ -731,6 +732,24 @@ model PasswordResetCode {
@@map("password_reset_codes")
}
model EmailLoginCode {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
code_hash String
expires_at DateTime
used_at DateTime?
attempts_count Int @default(0)
created_at DateTime @default(now())
created_ip String?
created_user_agent String?
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
@@index([employee_id, created_at])
@@index([expires_at])
@@map("email_login_codes")
}
model EmployeeRole {
employee_id String @db.Uuid
role_code RoleCode
+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) {
+163
查看文件
@@ -1,3 +1,4 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const fastifyCookie = require("@fastify/cookie");
@@ -9,6 +10,10 @@ const { signPayload } = require("../../../src/utils/signedUrl");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const JWT_SECRET = "test-auth-secret";
function hashEmailLoginCode(code) {
return crypto.createHmac("sha256", JWT_SECRET).update(`${EMPLOYEE_ID}:${code}`).digest("hex");
}
async function buildApp(passwordHash) {
const app = Fastify();
await app.register(fastifyCookie);
@@ -28,6 +33,10 @@ async function buildApp(passwordHash) {
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const wecomFindFirst = vi.fn().mockResolvedValue(null);
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
@@ -40,6 +49,10 @@ async function buildApp(passwordHash) {
updateMany: authSessionUpdateMany,
create: authSessionCreate,
},
emailLoginCode: {
update: emailLoginCodeUpdate,
updateMany: emailLoginCodeUpdateMany,
},
auditLog: {
create: auditCreate,
},
@@ -57,6 +70,8 @@ async function buildApp(passwordHash) {
loginFailWindowSeconds: 300,
loginLockSeconds: 900,
loginFailMaxAttempts: 5,
emailLoginCodeTtlSeconds: 600,
emailLoginCodeMaxAttempts: 5,
rateLimitEnabled: false,
jwtSecret: JWT_SECRET,
appOrigin: "http://127.0.0.1:3000",
@@ -66,6 +81,13 @@ async function buildApp(passwordHash) {
employee: {
findFirst: employeeFindFirst,
},
emailLoginCode: {
create: emailLoginCodeCreate,
findFirst: emailLoginCodeFindFirst,
},
auditLog: {
create: auditCreate,
},
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
@@ -93,6 +115,10 @@ async function buildApp(passwordHash) {
authSessionUpdateMany,
authSessionCreate,
auditCreate,
emailLoginCodeCreate,
emailLoginCodeFindFirst,
emailLoginCodeUpdate,
emailLoginCodeUpdateMany,
wecomFindFirst,
wecomUpsert,
};
@@ -245,4 +271,141 @@ describe("auth login route", () => {
await app.close();
});
it("requests an email login code without exposing account existence", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456);
const { app, emailLoginCodeCreate, auditCreate } = await buildApp(passwordHash);
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/code",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({});
expect(emailLoginCodeCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
code_hash: hashEmailLoginCode("123456"),
}),
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_EMAIL_LOGIN_CODE_REQUESTED",
actor_id: EMPLOYEE_ID,
}),
});
randomSpy.mockRestore();
await app.close();
});
it("does not reveal missing accounts when requesting an email login code", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
employeeFindFirst.mockResolvedValueOnce(null);
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/code",
headers: { "content-type": "application/json" },
payload: { email: "[email protected]" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({});
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
await app.close();
});
it("logs in with a valid email code and creates a refresh session", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdateMany, authSessionCreate, auditCreate } = await buildApp(passwordHash);
emailLoginCodeFindFirst.mockResolvedValueOnce({
id: "email-code-1",
code_hash: hashEmailLoginCode("654321"),
attempts_count: 0,
});
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/verify",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "email-code-device",
},
payload: { email: "[email protected]", code: "654321" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
accessToken: "access-token",
mustChangePassword: false,
}),
);
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null },
data: { used_at: expect.any(Date) },
});
expect(authSessionCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
employee_id: EMPLOYEE_ID,
device_fingerprint: "email-code-device",
}),
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_EMAIL_LOGIN_SUCCESS",
actor_id: EMPLOYEE_ID,
}),
});
expect(response.headers["set-cookie"]).toEqual(
expect.arrayContaining([
expect.stringContaining("refresh_token="),
expect.stringContaining("has_session=1"),
]),
);
await app.close();
});
it("rejects an invalid email login code and records the failed attempt", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app, emailLoginCodeFindFirst, emailLoginCodeUpdate, auditCreate } = await buildApp(passwordHash);
emailLoginCodeFindFirst.mockResolvedValueOnce({
id: "email-code-1",
code_hash: hashEmailLoginCode("654321"),
attempts_count: 0,
});
const response = await app.inject({
method: "POST",
url: "/api/auth/email-login/verify",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "email-code-device",
},
payload: { email: "[email protected]", code: "000000" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(emailLoginCodeUpdate).toHaveBeenCalledWith({
where: { id: "email-code-1" },
data: { attempts_count: { increment: 1 } },
});
expect(auditCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "AUTH_EMAIL_LOGIN_FAILED",
actor_id: EMPLOYEE_ID,
}),
});
await app.close();
});
});