Files
yunzerwebsiteallinone/backend/src/utils/authClient.js
T

402 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一认证中心(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;
}