402 lines
15 KiB
JavaScript
402 lines
15 KiB
JavaScript
/**
|
||
* 统一认证中心(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=<base64url(authorize URL)>
|
||
* 认证中心侧约定:
|
||
* - 未登录 → 展示登录页,登录成功后 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;
|
||
}
|