diff --git a/client/src/auth/deviceFingerprint.ts b/client/src/auth/deviceFingerprint.ts index 6228ab20..2026f464 100644 --- a/client/src/auth/deviceFingerprint.ts +++ b/client/src/auth/deviceFingerprint.ts @@ -1,4 +1,28 @@ -const DEVICE_FINGERPRINT_KEY = "hyb_oa_device_fingerprint"; +export const DEVICE_FINGERPRINT_KEY = "hyb_oa_device_fingerprint"; + +function readCookieFingerprint() { + if (typeof document === "undefined") { + return null; + } + + const prefix = `${DEVICE_FINGERPRINT_KEY}=`; + const item = document.cookie + .split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(prefix)); + + if (!item) { + return null; + } + + const rawValue = item.slice(prefix.length); + try { + const decoded = decodeURIComponent(rawValue); + return decoded.trim() ? decoded : null; + } catch { + return rawValue.trim() ? rawValue : null; + } +} function fallbackFingerprint() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; @@ -17,6 +41,12 @@ export function getDeviceFingerprint() { return "server-render"; } + const cookieFingerprint = readCookieFingerprint(); + if (cookieFingerprint) { + window.localStorage.setItem(DEVICE_FINGERPRINT_KEY, cookieFingerprint); + return cookieFingerprint; + } + const existing = window.localStorage.getItem(DEVICE_FINGERPRINT_KEY); if (existing) { return existing; diff --git a/client/src/lib/wecomEntry.ts b/client/src/lib/wecomEntry.ts index 61d9e632..9519d04a 100644 --- a/client/src/lib/wecomEntry.ts +++ b/client/src/lib/wecomEntry.ts @@ -140,9 +140,12 @@ export function resolveWecomBindNotice(value: string | null): WecomNotice | null return null; } -export function buildWecomLoginUrl(loginUrl: string, returnTo: string) { +export function buildWecomLoginUrl(loginUrl: string, returnTo: string, deviceFingerprint?: string) { const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1"; const url = new URL(loginUrl, baseOrigin); url.searchParams.set("returnTo", returnTo); + if (deviceFingerprint) { + url.searchParams.set("deviceFingerprint", deviceFingerprint); + } return url.origin === baseOrigin ? `${url.pathname}${url.search}` : url.toString(); } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 7cc9bdb0..c6ee9505 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -5,6 +5,7 @@ import { fetchSsoProviders, fetchWecomConfig } from "../api/auth"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; +import { getDeviceFingerprint } from "../auth/deviceFingerprint"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; @@ -99,7 +100,7 @@ export function LoginPage() { if (!wecomEntry?.loginUrl) { return; } - window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo)); + window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint())); } function renderPasswordPanel(testId: string, primary: boolean) { diff --git a/issues/2026-05-05_14-12-50-r88-wecom-device-fingerprint.csv b/issues/2026-05-05_14-12-50-r88-wecom-device-fingerprint.csv new file mode 100644 index 00000000..58e695a5 --- /dev/null +++ b/issues/2026-05-05_14-12-50-r88-wecom-device-fingerprint.csv @@ -0,0 +1,2 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +R88,企微直登设备指纹贯通,"修复企业微信 OAuth 创建 session 使用 wecom 用户号作为 device_fingerprint,导致前端 refresh 使用本机设备指纹时 401 的问题。","已绑定企微员工从企业微信应用进入后,OAuth 回调创建的 auth_session.device_fingerprint 与前端 /api/auth/refresh 发送的 x-device-fingerprint 一致;如果入口直接命中后端 OAuth URL,后端会下发前端可同步的设备指纹 cookie。","业务流程:企业微信应用进入 OA→OAuth login 携带或生成设备指纹→callback 创建 session→/portal 前端启动→/api/auth/refresh 成功恢复登录态。验证:cd server && npm test -- tests/unit/routes/wecomAuth.test.js;cd server && node --check src/routes/wecomAuth.js;cd client && npm run typecheck && npm run build;部署后检查线上企微回调后 /api/auth/refresh 不再返回 401。",shell|vitest|tsc|vite,DONE,DONE,DONE,server/src/routes/wecomAuth.js|server/tests/unit/routes/wecomAuth.test.js|client/src/auth/deviceFingerprint.ts|client/src/lib/wecomEntry.ts|client/src/pages/LoginPage.tsx,"[]","回滚路径:撤销 wecom OAuth state/device fingerprint 参数和同步 cookie 逻辑;风险中低,影响企微直登会话恢复,不改变密码登录。" diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 38a2bd04..a58649ad 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -9,12 +9,14 @@ const { sendData } = require("../utils/reply"); const BIND_COOKIE_NAME = "wecom_bind"; const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; +const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint"; const DATE_WINDOW_MS = 10 * 60 * 1000; const DEFAULT_RETURN_TO = "/portal"; const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]); const QUERY_SCHEMA = z.object({ returnTo: z.string().optional(), mode: z.enum(["login", "bind"]).optional(), + deviceFingerprint: z.string().trim().min(1).max(128).optional(), }); const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional(); @@ -58,6 +60,16 @@ function pendingLoginBindCookieOptions(config) { }; } +function deviceFingerprintCookieOptions(config) { + return { + httpOnly: false, + sameSite: "lax", + secure: config.cookieSecure, + path: "/", + maxAge: config.refreshTtlSeconds, + }; +} + function resolvePrimaryAppOrigin(config, request) { const configured = typeof config.appOrigin === "string" ? config.appOrigin : ""; const first = configured @@ -274,6 +286,13 @@ function buildWecomAuthorizeUrl(config, state) { return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`; } +function resolveOAuthDeviceFingerprint(value) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + return `wecom:${crypto.randomUUID()}`; +} + async function createAuthenticatedSession({ prisma, request, config, employeeId, tokenVersion, deviceFingerprint, auditAction, extra = {} }) { const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); @@ -349,11 +368,13 @@ async function wecomAuthRoutes(fastify) { assertWecomEntryReady(fastify.config); } const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request); + const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint); const state = signPayload({ exp: Date.now() + DATE_WINDOW_MS, nonce: crypto.randomUUID(), returnTo, mode, + deviceFingerprint, }, fastify.config.jwtSecret); return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state)); }); @@ -516,7 +537,7 @@ async function wecomAuthRoutes(fastify) { return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`); } - const deviceFingerprint = `wecom:${profile.externalUserId}`; + const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint); const session = await createAuthenticatedSession({ prisma: fastify.prisma, request, @@ -530,6 +551,7 @@ async function wecomAuthRoutes(fastify) { reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config)); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); + reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}`); }); } diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index 2a67693d..921e497d 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -159,6 +159,26 @@ describe("wecom auth routes R27", () => { expect(verified.ok).toBe(true); expect(verified.payload.returnTo).toBe("/portal"); expect(verified.payload.mode).toBe("login"); + expect(String(verified.payload.deviceFingerprint ?? "")).toMatch(/^wecom:/); + + await app.close(); + }); + + it("keeps frontend device fingerprint in wecom login state", async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: "GET", + url: "/api/auth/wecom/login?returnTo=%2Fportal&deviceFingerprint=browser-device-1", + }); + + expect(response.statusCode).toBe(302); + const state = new URL(response.headers.location).searchParams.get("state"); + const verified = verifyToken(state, JWT_SECRET); + expect(verified.ok).toBe(true); + expect(verified.payload.returnTo).toBe("/portal"); + expect(verified.payload.mode).toBe("login"); + expect(verified.payload.deviceFingerprint).toBe("browser-device-1"); await app.close(); }); @@ -252,6 +272,7 @@ describe("wecom auth routes R27", () => { nonce: "nonce-login-success", returnTo: "/portal", mode: "login", + deviceFingerprint: "browser-device-1", }, JWT_SECRET); const response = await app.inject({ @@ -264,7 +285,13 @@ describe("wecom auth routes R27", () => { const setCookieHeader = String(response.headers["set-cookie"] ?? ""); expect(setCookieHeader).toContain("refresh_token="); expect(setCookieHeader).toContain("has_session=1"); - expect(authSession.create).toHaveBeenCalled(); + expect(setCookieHeader).toContain("hyb_oa_device_fingerprint=browser-device-1"); + expect(authSession.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + employee_id: EMPLOYEE_ID, + device_fingerprint: "browser-device-1", + }), + }); expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ actor_id: EMPLOYEE_ID,