const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const redisCache = require('../config/redis'); const { getResolvedUserWithPageAccess } = require('./pageAccess'); const { signAccessToken } = require('./socialAuth'); const DESKTOP_SESSION_TTL_SECONDS = Math.min( Math.max(Number.parseInt(process.env.DESKTOP_SESSION_TTL_SECONDS || '1800', 10), 300), 7200 ); const DESKTOP_SSO_TICKET_TTL_SECONDS = Math.min( Math.max(Number.parseInt(process.env.DESKTOP_SSO_TICKET_TTL_SECONDS || '60', 10), 15), 120 ); const CONTROL_CENTER_DEFAULT_PATH = '/dashboard?embed=desktop'; const DESKTOP_TOKEN_TYPE = 'desktop-session'; const DESKTOP_TOKEN_ISSUER = 'pddg-control-center'; const DESKTOP_TOKEN_AUDIENCE = 'pdd-auto-sale'; const localTickets = new Map(); const localRevokedSessions = new Map(); class DesktopSessionError extends Error { constructor(message, status = 400, code = 'DESKTOP_SESSION_ERROR') { super(message); this.name = 'DesktopSessionError'; this.status = status; this.code = code; } } function hashValue(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); } function ticketCacheKey(ticket) { return `desktop-sso:${hashValue(ticket)}`; } function revokedSessionCacheKey(sessionJti) { return `desktop-session:revoked:${hashValue(sessionJti)}`; } function normalizeClientId(value) { const clientId = String(value || '').trim().toLowerCase(); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(clientId)) { throw new DesktopSessionError('桌面客户端设备标识无效', 400, 'DESKTOP_CLIENT_ID_INVALID'); } return clientId; } function normalizeText(value, maxLength) { return String(value || '').trim().slice(0, maxLength); } function normalizeDesktopMetadata(payload = {}) { return { clientId: normalizeClientId(payload.clientId), clientVersion: normalizeText(payload.clientVersion, 40), platform: normalizeText(payload.platform, 30), architecture: normalizeText(payload.architecture, 30), deviceName: normalizeText(payload.deviceName, 100), }; } function buildDesktopCapabilities(user) { const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []); const isAdmin = user?.role === 'admin'; const canPlan = isAdmin || allowed.has('planning_upload'); const canUseMarket = isAdmin || canPlan; return { controlCenter: true, autoListing: canPlan, presale: canPlan, pricing: canPlan, alibaba1688: canUseMarket, shopManagement: canPlan, execution: canPlan, logs: true, marketingEvents: isAdmin || allowed.has('marketing_events'), promotion: isAdmin || allowed.has('promotion_module'), productTest: isAdmin || allowed.has('new_product_test'), }; } function publicUser(user) { return { id: user.id, username: user.username, name: user.name, email: user.email, role: user.role, picture: user.picture || user.avatar_url || '', departmentId: user.departmentId || user.department_id || null, departmentName: user.departmentName || '', authSource: user.auth_source || user.authSource || 'local', allowedPageKeys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], }; } function buildBootstrap(user, metadata) { return { user: publicUser(user), client: metadata, capabilities: buildDesktopCapabilities(user), controlCenter: { defaultPath: CONTROL_CENTER_DEFAULT_PATH, heartbeatIntervalMs: 15000, silentReauth: true, }, }; } async function resolveActiveUser(userId, resolveUser = getResolvedUserWithPageAccess) { const user = await resolveUser(userId); if (!user) { throw new DesktopSessionError('登录用户不存在', 401, 'DESKTOP_USER_NOT_FOUND'); } if (user.status && user.status !== 'active') { throw new DesktopSessionError('当前账号已停用', 403, 'DESKTOP_USER_DISABLED'); } return user; } async function createDesktopSession({ userId, payload = {} }, options = {}) { const metadata = normalizeDesktopMetadata(payload); const user = await resolveActiveUser(userId, options.resolveUser); const bootstrap = buildBootstrap(user, metadata); const sessionJti = crypto.randomUUID(); const token = jwt.sign( { sub: user.id, tokenType: DESKTOP_TOKEN_TYPE, clientId: metadata.clientId, capabilities: bootstrap.capabilities, }, process.env.CUBEJS_SECRET, { expiresIn: DESKTOP_SESSION_TTL_SECONDS, jwtid: sessionJti, issuer: DESKTOP_TOKEN_ISSUER, audience: DESKTOP_TOKEN_AUDIENCE, } ); return { desktopSessionToken: token, expiresIn: DESKTOP_SESSION_TTL_SECONDS, bootstrap, }; } async function getDesktopBootstrap({ claims, clientId }, options = {}) { const normalizedClientId = await assertDesktopSession(claims, clientId, options); const user = await resolveActiveUser(claims.sub, options.resolveUser); return buildBootstrap(user, { clientId: normalizedClientId, clientVersion: '', platform: '', architecture: '', deviceName: '', }); } function cleanupLocalState(now = Date.now()) { for (const [key, record] of localTickets) { if (record.expiresAt <= now) localTickets.delete(key); } for (const [key, expiresAt] of localRevokedSessions) { if (expiresAt <= now) localRevokedSessions.delete(key); } } async function isSessionRevoked(sessionJti, options = {}) { cleanupLocalState(); const digest = hashValue(sessionJti); if (localRevokedSessions.has(digest)) return true; const cache = options.cache || redisCache; return Boolean(await cache.get(revokedSessionCacheKey(sessionJti))); } async function assertDesktopSession(claims, clientId, options = {}) { if (!claims || claims.tokenType !== DESKTOP_TOKEN_TYPE || !claims.jti) { throw new DesktopSessionError('桌面会话无效', 401, 'DESKTOP_SESSION_INVALID'); } const normalizedClientId = normalizeClientId(clientId || claims.clientId); if (claims.clientId !== normalizedClientId) { throw new DesktopSessionError('桌面会话与当前设备不匹配', 403, 'DESKTOP_SESSION_CLIENT_MISMATCH'); } if (await isSessionRevoked(claims.jti, options)) { throw new DesktopSessionError('桌面会话已退出', 401, 'DESKTOP_SESSION_REVOKED'); } return normalizedClientId; } function normalizeRedirectPath(value) { const fallback = CONTROL_CENTER_DEFAULT_PATH; const input = String(value || fallback).trim(); if (!input.startsWith('/') || input.startsWith('//')) return fallback; try { const parsed = new URL(input, 'https://desktop.local'); if (parsed.origin !== 'https://desktop.local') return fallback; const allowedRoots = ['/dashboard', '/planning', '/settings']; if (!allowedRoots.some((root) => parsed.pathname === root || parsed.pathname.startsWith(`${root}/`))) { return fallback; } parsed.searchParams.set('embed', 'desktop'); return `${parsed.pathname}${parsed.search}${parsed.hash}`; } catch (_error) { return fallback; } } async function createSsoTicket({ claims, clientId, redirectPath }, options = {}) { const normalizedClientId = await assertDesktopSession(claims, clientId, options); const ticket = crypto.randomBytes(32).toString('base64url'); const key = ticketCacheKey(ticket); const now = Date.now(); const record = { userId: claims.sub, clientId: normalizedClientId, sessionJti: claims.jti, redirectPath: normalizeRedirectPath(redirectPath), expiresAt: now + DESKTOP_SSO_TICKET_TTL_SECONDS * 1000, }; const cache = options.cache || redisCache; const redisBacked = await cache.setex(key, DESKTOP_SSO_TICKET_TTL_SECONDS, record); localTickets.set(key, { ...record, redisBacked: Boolean(redisBacked) }); cleanupLocalState(now); return { ticket, expiresIn: DESKTOP_SSO_TICKET_TTL_SECONDS, exchangePath: `/api/desktop-client/sso/exchange?ticket=${encodeURIComponent(ticket)}`, }; } async function takeTicket(ticket, options = {}) { const rawTicket = String(ticket || '').trim(); if (!/^[A-Za-z0-9_-]{40,100}$/.test(rawTicket)) return null; const key = ticketCacheKey(rawTicket); const localRecord = localTickets.get(key); localTickets.delete(key); const cache = options.cache || redisCache; if (localRecord?.redisBacked) { return cache.take(key); } if (localRecord) { return localRecord.expiresAt > Date.now() ? localRecord : null; } return cache.take(key); } async function exchangeSsoTicket(ticket, options = {}) { cleanupLocalState(); const record = await takeTicket(ticket, options); if (!record || Number(record.expiresAt) <= Date.now()) { throw new DesktopSessionError('登录票据无效或已过期', 401, 'DESKTOP_SSO_TICKET_INVALID'); } if (await isSessionRevoked(record.sessionJti, options)) { throw new DesktopSessionError('桌面会话已退出', 401, 'DESKTOP_SESSION_REVOKED'); } const user = await resolveActiveUser(record.userId, options.resolveUser); return { accessToken: signAccessToken(user), user: publicUser(user), redirectPath: normalizeRedirectPath(record.redirectPath), }; } async function revokeDesktopSession(claims, options = {}) { await assertDesktopSession(claims, claims?.clientId, options); const remainingSeconds = Math.max(Number(claims.exp || 0) - Math.floor(Date.now() / 1000), 1); const digest = hashValue(claims.jti); localRevokedSessions.set(digest, Date.now() + remainingSeconds * 1000); const cache = options.cache || redisCache; await cache.setex(revokedSessionCacheKey(claims.jti), remainingSeconds, true); return { revoked: true }; } function resetDesktopSessionStateForTests() { localTickets.clear(); localRevokedSessions.clear(); } module.exports = { CONTROL_CENTER_DEFAULT_PATH, DESKTOP_SESSION_TTL_SECONDS, DESKTOP_SSO_TICKET_TTL_SECONDS, DesktopSessionError, assertDesktopSession, buildDesktopCapabilities, createDesktopSession, createSsoTicket, exchangeSsoTicket, getDesktopBootstrap, normalizeDesktopMetadata, normalizeRedirectPath, resetDesktopSessionStateForTests, revokeDesktopSession, };