做统一认证登录

This commit is contained in:
hero920103 committed 2026-09-19 21:44:04 +08:00
1 parent 1a4471e34d
commit fa281363fb
78 files changed
+8127 -1442

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
/**
* 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE
*
* 接入方式:在 .env 中配置
* VITE_AUTH_MODE=sso 开启统一认证(不配置则走原有 /backend/login)
* VITE_AUTH_BASE=https://api.yunzer.cn/auth
* VITE_AUTH_CLIENT_ID=yz-backend
* VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/#/auth/callback
*
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
* 因此接入后业务代码无需改动即可带上 Authorization 头。
*/
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE || "";
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`;
const POST_LOGOUT_URI =
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/login`;
const ACCESS_KEY = "token"; // 与现有代码保持一致
const REFRESH_KEY = "auth_refresh_token";
const SID_KEY = "auth_sid";
/** 是否启用统一认证(默认关闭,保持原有登录方式) */
export function isSSOEnabled() {
return import.meta.env.VITE_AUTH_MODE === "sso" && !!AUTH_BASE;
}
// ---------------------------------------------------------------- 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) */
export async function redirectToAuthorize() {
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem("pkce_verifier", verifier);
const state = randomString(24);
sessionStorage.setItem("oidc_state", state);
const params = 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",
});
window.location.href = `${AUTH_BASE}/authorize?${params.toString()}`;
}
/** 处理认证中心回跳:用 code 换令牌 */
export async function handleAuthorizeCallback(query = {}) {
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;
}
/** 静默刷新访问令牌 */
export async function refreshAccessToken() {
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("刷新令牌失败");
setTokens(data);
return data.access_token;
}
/** 单点登出:吊销令牌后回认证中心登出页 */
export async function logoutSSO() {
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()}`;
}
/** 拉取当前登录用户(认证中心视角) */
export async function fetchUserInfo() {
const res = await fetch(`${AUTH_BASE}/userinfo`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("获取用户信息失败");
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) throw new Error(data.msg || "切换企业失败");
if (data.data?.tokens) setTokens(data.data.tokens);
return data.data;
}
+16 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios';
import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient';
// 获取API基础URL,添加调试信息
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
@@ -40,13 +41,27 @@ service.interceptors.response.use(
response => {
return response.data;
},
error => {
async error => {
if (error.response) {
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
// 避免页面只显示 "Request failed with status code xxx"
const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) {
case 401:
// 统一认证模式:先用 refresh_token 静默换取新令牌并重放原请求,
// 刷新失败(令牌过期/被吊销)才真正登出。
if (isSSOEnabled()) {
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
return service.request(error.config);
} catch (e) {
clearTokens();
localStorage.removeItem('userInfo');
window.location.href = '#/login';
return Promise.reject(new Error('token无效'));
}
}
console.error('未授权,请重新登录');
localStorage.removeItem('token');
localStorage.removeItem('userInfo');