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