R88 贯通企微直登设备指纹
这个提交包含在:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 逻辑;风险中低,影响企微直登会话恢复,不改变密码登录。"
|
||||
|
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户