/** * 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE * * 本项目已停用本地账号密码登录(/backend/login、/backend/register 等入口均已下线), * 登录、登出、令牌续期一律走认证中心。 * * 环境变量(见 .env.development / .env.production): * VITE_AUTH_BASE=https://api.yunzer.cn/auth 认证中心地址,缺省回退到 ${VITE_API_BASE_URL}/auth * VITE_AUTH_CLIENT_ID=yz-backend * VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/auth-callback.html * VITE_AUTH_POST_LOGOUT_URI=https://back.yunzer.cn/#/ * * 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js), * 因此业务代码无需改动即可带上 Authorization 头。 */ // 未显式配置时回退到「接口地址 + /auth」,避免跳转到空地址或前端自身域名 const API_BASE = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); const AUTH_BASE = (import.meta.env.VITE_AUTH_BASE || (API_BASE ? `${API_BASE}/auth` : "")).replace( /\/+$/, "" ); const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend"; // 缺省回落到静态中转页(不带 #);该地址必须已在认证中心白名单登记 const REDIRECT_URI = import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/auth-callback.html`; const POST_LOGOUT_URI = import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/`; const ACCESS_KEY = "token"; // 与现有代码保持一致 const REFRESH_KEY = "auth_refresh_token"; const SID_KEY = "auth_sid"; /** * 是否启用统一认证。 * 本地登录已停用,此处恒为 true;保留函数是为了兼容既有调用点, * 后续清理时可直接删除所有分支判断。 */ export function isSSOEnabled() { return true; } /** 认证中心地址是否可用(排查配置缺失用) */ export function hasAuthBase() { return !!AUTH_BASE; } // 认证「交接页」:正在用 code / ticket 兑换令牌。此时 localStorage 里还是 // 上一次的旧 token,页面启动期的请求一旦 401,自动恢复逻辑会把刚换到的新 // token 清掉并重新跳认证中心,形成「登录成功又回登录页」的循环。 const HANDOFF_PATHS = ["/auth/callback", "/app-login", "/mp-login"]; const REDIRECT_LOG_KEY = "auth_authorize_redirects"; const LOOP_WINDOW_MS = 15000; const LOOP_MAX = 3; export function isAuthHandoff() { const hash = window.location.hash || ""; const hashPath = hash.startsWith("#") ? hash.slice(1).split("?")[0] : ""; if (HANDOFF_PATHS.includes(hashPath)) return true; if (window.location.pathname === "/auth-callback.html") return true; // 兼容中转页把 code/ticket 放在 hash 之前的场景;只看 query,避免业务页面 // 自身的 ?token= 等参数被误判成交接页。 return /[?&](code|ticket)=/.test(window.location.search || ""); } // 短时间内反复发起授权说明出现了重定向环:停下并给出可诊断的错误, // 而不是让用户一直被弹回登录界面。 function guardAuthorizeLoop() { const now = Date.now(); let times = []; try { times = JSON.parse(sessionStorage.getItem(REDIRECT_LOG_KEY) || "[]"); } catch (e) { times = []; } times = (Array.isArray(times) ? times : []).filter((t) => now - Number(t) < LOOP_WINDOW_MS); if (times.length >= LOOP_MAX) { throw new Error( "短时间内多次跳转统一登录,已停止自动跳转以避免循环;请检查认证中心与回跳白名单配置后重试" ); } times.push(now); try { sessionStorage.setItem(REDIRECT_LOG_KEY, JSON.stringify(times)); } catch (e) { // 隐私模式下 sessionStorage 不可写,忽略即可 } } // 计数按时间窗口自然过期,不在换码成功后清零:否则「换码成功 → 401 → 又跳 // 认证中心」这类循环每次都会把计数抹掉,保护永远无法触发。 // ---------------------------------------------------------------- PKCE function randomString(len = 64) { const arr = new Uint8Array(len); crypto.getRandomValues(arr); return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join("").slice(0, len); } async function sha256(plain) { return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain)); } function base64url(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } async function generatePKCE() { const verifier = randomString(64); const challenge = base64url(await sha256(verifier)); return { verifier, challenge }; } // ---------------------------------------------------------------- 令牌存储 export function setTokens(data) { if (data?.access_token) localStorage.setItem(ACCESS_KEY, data.access_token); if (data?.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token); if (data?.sid) localStorage.setItem(SID_KEY, data.sid); } export function getRefreshToken() { return localStorage.getItem(REFRESH_KEY) || ""; } export function clearTokens() { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(SID_KEY); } export function getAccessToken() { return localStorage.getItem(ACCESS_KEY) || ""; } // ---------------------------------------------------------------- 主流程 /** * 跳转认证中心登录(带 PKCE 与 state)。 * * 入口取认证中心登录页而不是直接打 authorize: * GET {AUTH_BASE}/login?client_id=xxx&redirect= * 认证中心侧约定: * - 未登录 → 展示登录页,登录成功后 302 回 redirect 指向的 authorize; * - 已登录 → 立即 302 回 authorize,由 authorize 识别会话后签发 code; * 最终仍由 authorize 校验 redirect_uri 白名单并携带 code 回跳本端。 */ export async function redirectToAuthorize() { if (!AUTH_BASE) { throw new Error("统一认证地址未配置:请检查 VITE_AUTH_BASE 或 VITE_API_BASE_URL"); } guardAuthorizeLoop(); const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem("pkce_verifier", verifier); const state = randomString(24); sessionStorage.setItem("oidc_state", state); const authorizeURL = `${AUTH_BASE}/authorize?${new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: "code", scope: "openid profile tenant", state, code_challenge: challenge, code_challenge_method: "S256", }).toString()}`; // redirect 参数由认证中心按 base64.RawURLEncoding 解析(无 = 填充) const redirectParam = base64url(new TextEncoder().encode(authorizeURL)); window.location.href = `${AUTH_BASE}/login?client_id=${encodeURIComponent(CLIENT_ID)}` + `&redirect=${redirectParam}`; } /** 处理认证中心回跳:用 code 换令牌 */ export async function handleAuthorizeCallback(query = {}) { // 交接页加载期间,启动期请求是用旧 token 发出的。先清掉旧登录态, // 避免并发 401 续期拿旧 refresh_token 触发「重放吊销」把新会话一起废掉, // 也避免旧 userInfo 被误当成新会话的用户信息。 clearTokens(); localStorage.removeItem("userInfo"); const code = query.code; const state = query.state; const savedState = sessionStorage.getItem("oidc_state"); const verifier = sessionStorage.getItem("pkce_verifier"); if (!code) throw new Error("缺少授权码"); if (savedState && state !== savedState) throw new Error("state 校验失败"); if (!verifier) throw new Error("PKCE 校验信息丢失,请重新登录"); const body = new URLSearchParams({ grant_type: "authorization_code", code, client_id: CLIENT_ID, code_verifier: verifier, redirect_uri: REDIRECT_URI, }); const res = await fetch(`${AUTH_BASE}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.access_token) { throw new Error(data.error_description || data.error || "换取令牌失败"); } sessionStorage.removeItem("pkce_verifier"); sessionStorage.removeItem("oidc_state"); setTokens(data); return data; } // 刷新中的令牌请求(single-flight):并发 401 时共用同一次刷新。 // 认证中心的 refresh_token 是一次性轮换的,同一个 refresh_token 被使用两次会被 // 判定为重放攻击并吊销整个会话(见 services/auth/token.go 的 RefreshTokens), // 因此这里必须保证「任一时刻只有一个刷新请求在飞」。 let refreshingPromise = null; /** 静默刷新访问令牌(带并发去重) */ export async function refreshAccessToken() { if (refreshingPromise) return refreshingPromise; refreshingPromise = doRefreshAccessToken().finally(() => { refreshingPromise = null; }); return refreshingPromise; } async function doRefreshAccessToken() { const refresh = getRefreshToken(); if (!refresh) throw new Error("无刷新令牌"); const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID, }); const res = await fetch(`${AUTH_BASE}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.access_token) { throw new Error(data.error_description || data.error || "刷新令牌失败"); } setTokens(data); return data.access_token; } /** 单点登出:吊销令牌后回认证中心登出页 */ export async function logoutSSO() { if (!AUTH_BASE) return; const token = getAccessToken(); try { await fetch(`${AUTH_BASE}/revoke`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, refresh_token: getRefreshToken() }), }); } catch (e) { // 吊销失败也要继续登出,避免卡在本端 } clearTokens(); const params = new URLSearchParams({ client_id: CLIENT_ID, post_logout_redirect_uri: POST_LOGOUT_URI, }); window.location.href = `${AUTH_BASE}/logout?${params.toString()}`; } /** * 取「当前登录的那一家企业」的名称。 * * userinfo 的 tenants 是该账号可进入的全部企业,只能用于做企业切换列表; * 顶部展示必须是当前会话选中的企业(tid),不能把全部企业名拼接。 * 取值顺序:后端 tenant_name → 按 tid 在 tenants 中匹配 → 兜底第一家。 */ export function resolveTenantName(data) { if (!data) return ''; if (data.tenant_name) return data.tenant_name; const list = data.tenants || []; const hit = list.find((t) => String(t.tid) === String(data.tid)); if (hit) return hit.tenant_name || ''; return list.length ? (list[0].tenant_name || '') : ''; } /** * 确保本地存在完整的用户信息(含 id / group_id)。 * * 场景:统一认证上线或 ID 全量迁移后,浏览器里可能残留旧会话—— * 只有 token 没有 userInfo,或 userInfo 缺少 id,菜单等接口会报 *「用户ID不存在」。这里主动补全;若令牌已失效则清空登录态返回 null, * 由调用方跳转登录。 */ export async function ensureUserInfo() { let info = null; try { const raw = localStorage.getItem('userInfo'); info = raw ? JSON.parse(raw) : null; } catch (e) { info = null; } if (info && info.id) { return info; } try { const data = await fetchUserInfo(); const normalized = { id: data.id || data.sub || '', account: data.account || data.mobile || '', name: data.name || data.nickname || '', group_id: data.group_id || '', tid: data.tid || '', tenant_name: resolveTenantName(data), avatar: data.avatar || '', type: 'backend' }; localStorage.setItem('userInfo', JSON.stringify(normalized)); return normalized; } catch (e) { // 只有确认令牌失效(401)才清空登录态。userinfo 接口网络抖动 / 404 / 5xx // 属于临时故障,一律清 token 会把刚登录成功的用户反复送回认证中心。 if (e?.status === 401 || e?.isAuthError) { clearTokens(); localStorage.removeItem('userInfo'); return null; } console.warn('[auth] 获取用户信息失败,保留当前登录态:', e); return info && info.id ? info : null; } } /** 拉取当前登录用户(认证中心视角) */ export async function fetchUserInfo() { let res = await fetch(`${AUTH_BASE}/userinfo`, { headers: { Authorization: `Bearer ${getAccessToken()}` }, }); if (res.status === 401) { // 访问令牌可能刚过期:先静默续期再重试一次, // 否则调用方(路由守卫的 ensureUserInfo)会把用户直接踢回认证中心登录 try { const newToken = await refreshAccessToken(); res = await fetch(`${AUTH_BASE}/userinfo`, { headers: { Authorization: `Bearer ${newToken}` }, }); } catch (e) { // 续期失败:保持原响应,交给调用方按「未登录」处理 } } if (!res.ok) { const err = new Error(`获取用户信息失败(${res.status})`); err.status = res.status; throw err; } return res.json(); } /** 切换企业(一人多企业场景) */ export async function switchTenant(tid) { const res = await fetch(`${AUTH_BASE}/switch-tenant`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}`, }, body: JSON.stringify({ tid, client_id: CLIENT_ID }), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.code !== 200) { const msg = data.msg || "切换企业失败"; // 会话被吊销/过期:本端已无法继续,清掉登录态回认证中心重新登录, // 否则用户会卡在「点了没反应」的状态 if (res.status === 401 || msg.includes("会话") || msg.includes("重新登录")) { clearTokens(); localStorage.removeItem("userInfo"); redirectToAuthorize().catch((err) => console.error("跳转统一认证失败:", err)); } throw new Error(msg); } if (data.data?.tokens) setTokens(data.data.tokens); // 切换后同步本地缓存的企业信息,避免顶部仍显示切换前的企业名 try { const info = await fetchUserInfo(); const raw = localStorage.getItem('userInfo'); const merged = raw ? JSON.parse(raw) : {}; merged.tid = info.tid || tid; merged.tenant_name = resolveTenantName(info); localStorage.setItem('userInfo', JSON.stringify(merged)); } catch (e) { // 同步失败不阻断切换结果 } return data.data; }