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