R88 贯通企微直登设备指纹

这个提交包含在:
2026-05-05 14:18:29 +08:00
父节点 ee0cfb00fe
当前提交 449489d2e0
共修改 6 个文件,包含 90 行新增和 5 行删除
+23 -1
查看文件
@@ -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}`);
});
}
+28 -1
查看文件
@@ -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,