diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 492e09d4..c5b8e687 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -11,6 +11,9 @@ const { createNotifications } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); const { generators, getOidcClient } = require("../services/oidc"); +const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind"; +const PENDING_WECOM_BIND_WINDOW_MS = 10 * 60 * 1000; + function getHeaderString(request, name) { const value = request.headers[name]; return typeof value === "string" && value.trim() ? value.trim() : null; @@ -118,6 +121,16 @@ function refreshCookieOptions(config) { }; } +function pendingWecomBindCookieOptions(config) { + return { + httpOnly: true, + sameSite: "lax", + secure: config.cookieSecure, + path: "/api/auth", + maxAge: PENDING_WECOM_BIND_WINDOW_MS / 1000, + }; +} + function sessionHintCookieOptions(config) { return { httpOnly: false, @@ -272,6 +285,8 @@ async function authRoutes(fastify) { } const now = new Date(); + const pendingWecomBindToken = request.cookies?.[PENDING_WECOM_BIND_COOKIE_NAME]; + const pendingWecomBind = verifyToken(pendingWecomBindToken, fastify.config.jwtSecret); if (employee.login_locked_until && employee.login_locked_until > now) { throw new AppError({ code: "AUTH_002", @@ -371,6 +386,7 @@ async function authRoutes(fastify) { const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); + let wecomBindingStatus = null; await fastify.prisma.$transaction(async (tx) => { await tx.employee.update({ @@ -414,6 +430,63 @@ async function authRoutes(fastify) { }, }); + if (pendingWecomBind.ok) { + const payload = pendingWecomBind.payload ?? {}; + const externalUserId = + typeof payload.externalUserId === "string" && payload.externalUserId.trim() + ? payload.externalUserId.trim() + : null; + const externalOpenUserId = + typeof payload.externalOpenUserId === "string" && payload.externalOpenUserId.trim() + ? payload.externalOpenUserId.trim() + : null; + const isExpired = typeof payload.exp !== "number" || payload.exp <= Date.now(); + + if (!externalUserId || isExpired) { + wecomBindingStatus = "invalid"; + } else { + const conflictingBinding = await tx.wecomIdentityBinding.findFirst({ + where: { + external_user_id: externalUserId, + NOT: { employee_id: employee.id }, + }, + select: { employee_id: true }, + }); + + if (conflictingBinding) { + wecomBindingStatus = "conflict"; + } else { + await tx.wecomIdentityBinding.upsert({ + where: { employee_id: employee.id }, + update: { + external_user_id: externalUserId, + external_open_user_id: externalOpenUserId, + updated_by: employee.id, + }, + create: { + employee_id: employee.id, + external_user_id: externalUserId, + external_open_user_id: externalOpenUserId, + created_by: employee.id, + updated_by: employee.id, + }, + }); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_WECOM_BIND", + targetType: "employee", + targetId: employee.id, + extra: { externalUserId }, + }), + }); + wecomBindingStatus = "bound"; + } + } + } else if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) { + wecomBindingStatus = "invalid"; + } + await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: employee.id, @@ -431,12 +504,16 @@ async function authRoutes(fastify) { refreshCookieOptions(fastify.config), ); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); + if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) { + reply.clearCookie(PENDING_WECOM_BIND_COOKIE_NAME, pendingWecomBindCookieOptions(fastify.config)); + } const accessToken = fastify.issueAccessToken(employee); return sendData(reply, request, { accessToken, expiresIn: accessTokenExpiresIn, mustChangePassword: Boolean(employee.must_change_password), + wecomBindingStatus, }); }); diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index fc3cb8ae..86390380 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -8,6 +8,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); const BIND_COOKIE_NAME = "wecom_bind"; +const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; const DATE_WINDOW_MS = 10 * 60 * 1000; const DEFAULT_RETURN_TO = "/portal"; const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]); @@ -37,6 +38,16 @@ function bindCookieOptions(config) { }; } +function pendingLoginBindCookieOptions(config) { + return { + httpOnly: true, + sameSite: "lax", + secure: config.cookieSecure, + path: "/api/auth", + maxAge: DATE_WINDOW_MS / 1000, + }; +} + function resolvePrimaryAppOrigin(config, request) { const configured = typeof config.appOrigin === "string" ? config.appOrigin : ""; const first = configured @@ -481,6 +492,17 @@ async function wecomAuthRoutes(fastify) { select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } }, }); if (!binding?.employee || binding.employee.status !== "active") { + const pendingBindToken = signPayload({ + exp: Date.now() + DATE_WINDOW_MS, + externalUserId: profile.externalUserId, + externalOpenUserId: profile.externalOpenUserId, + returnTo, + }, fastify.config.jwtSecret); + reply.setCookie( + PENDING_LOGIN_BIND_COOKIE_NAME, + pendingBindToken, + pendingLoginBindCookieOptions(fastify.config), + ); return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`); } diff --git a/server/tests/unit/routes/auth.login.test.js b/server/tests/unit/routes/auth.login.test.js new file mode 100644 index 00000000..5e28868a --- /dev/null +++ b/server/tests/unit/routes/auth.login.test.js @@ -0,0 +1,164 @@ +const Fastify = require("fastify"); +const fastifyCookie = require("@fastify/cookie"); + +const authRoutes = require("../../../src/routes/auth"); +const { AppError } = require("../../../src/utils/AppError"); +const { hashPassword } = require("../../../src/utils/crypto"); +const { signPayload } = require("../../../src/utils/signedUrl"); + +const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; +const JWT_SECRET = "test-auth-secret"; + +async function buildApp(passwordHash) { + const app = Fastify(); + await app.register(fastifyCookie); + + const employeeFindFirst = vi.fn().mockResolvedValue({ + id: EMPLOYEE_ID, + status: "active", + password_hash: passwordHash, + token_version: 3, + must_change_password: false, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + }); + const employeeUpdate = vi.fn().mockResolvedValue({}); + const authSessionFindMany = vi.fn().mockResolvedValue([]); + const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 }); + const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" }); + const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); + const wecomFindFirst = vi.fn().mockResolvedValue(null); + const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" }); + + const tx = { + employee: { + update: employeeUpdate, + }, + authSession: { + findMany: authSessionFindMany, + updateMany: authSessionUpdateMany, + create: authSessionCreate, + }, + auditLog: { + create: auditCreate, + }, + wecomIdentityBinding: { + findFirst: wecomFindFirst, + upsert: wecomUpsert, + }, + }; + + app.decorate("config", { + accessTokenTtl: "15m", + refreshTtlSeconds: 7 * 24 * 60 * 60, + refreshCookieName: "refresh_token", + cookieSecure: false, + loginFailWindowSeconds: 300, + loginLockSeconds: 900, + loginFailMaxAttempts: 5, + rateLimitEnabled: false, + jwtSecret: JWT_SECRET, + appOrigin: "http://127.0.0.1:3000", + wecomAllowedReturnHosts: "127.0.0.1:3000", + }); + app.decorate("prisma", { + employee: { + findFirst: employeeFindFirst, + }, + $transaction: vi.fn(async (callback) => callback(tx)), + }); + app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); + app.decorate("authenticate", async () => undefined); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { code: error.code, message: error.message, details: error.details ?? {} }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} }, + }); + }); + + await app.register(authRoutes, { prefix: "/api/auth" }); + + return { + app, + employeeFindFirst, + employeeUpdate, + authSessionFindMany, + authSessionUpdateMany, + authSessionCreate, + auditCreate, + wecomFindFirst, + wecomUpsert, + }; +} + +describe("auth login route", () => { + it("auto-binds pending wecom identity after password login", async () => { + const passwordHash = await hashPassword("AdminPassword1234"); + const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash); + const pendingBindToken = signPayload({ + exp: Date.now() + 60_000, + externalUserId: "wecom-user-1", + externalOpenUserId: "openid-1", + returnTo: "/portal", + }, JWT_SECRET); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/login", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "pw-e2e-admin", + cookie: `wecom_login_bind=${pendingBindToken}`, + }, + payload: { + identifier: "admin@hyinda.com", + password: "AdminPassword1234", + }, + }); + + expect(response.statusCode).toBe(200); + expect(wecomFindFirst).toHaveBeenCalledWith({ + where: { + external_user_id: "wecom-user-1", + NOT: { employee_id: EMPLOYEE_ID }, + }, + select: { employee_id: true }, + }); + expect(wecomUpsert).toHaveBeenCalledWith({ + where: { employee_id: EMPLOYEE_ID }, + update: { + external_user_id: "wecom-user-1", + external_open_user_id: "openid-1", + updated_by: EMPLOYEE_ID, + }, + create: { + employee_id: EMPLOYEE_ID, + external_user_id: "wecom-user-1", + external_open_user_id: "openid-1", + created_by: EMPLOYEE_ID, + updated_by: EMPLOYEE_ID, + }, + }); + expect(response.json().data).toEqual( + expect.objectContaining({ + accessToken: "access-token", + mustChangePassword: false, + wecomBindingStatus: "bound", + }), + ); + expect(response.headers["set-cookie"]).toEqual( + expect.arrayContaining([ + expect.stringContaining("wecom_login_bind="), + ]), + ); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index 8d5518bd..ebd4e562 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -185,6 +185,7 @@ describe("wecom auth routes R27", () => { expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); + expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); await app.close(); }); @@ -215,6 +216,7 @@ describe("wecom auth routes R27", () => { expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); + expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({ where: { external_user_id: "wecom-unbound-lowercase" }, select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },