做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+14 -1
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes";
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
// 静态子路由:需要在 Main 框架内显示的页面
const staticMainChildren = [
@@ -146,6 +147,13 @@ const staticRoutes = [
component: () => import("@/views/login/forget.vue"),
meta: { requiresAuth: false }
},
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
{
path: "/auth/callback",
name: "AuthCallback",
component: () => import("@/views/auth/callback.vue"),
meta: { requiresAuth: false }
},
{
path: "/home",
name: "Home",
@@ -304,7 +312,7 @@ function findFirstValidRoute(routes) {
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem("token");
const publicPaths = ["/login", "/register", "/forget"];
const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
if (publicPaths.includes(to.path)) {
if (token) {
@@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => {
}
if (!token) {
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
if (isSSOEnabled()) {
await redirectToAuthorize();
return;
}
next({ path: "/login", query: { redirect: to.path } });
return;
}
+5
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { getCurrentUser } from '@/api/login'
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
// 用户信息类型
const defaultUser = {
@@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
Object.assign(user, defaultUser)
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效)
if (isSSOEnabled()) {
logoutSSO()
}
}
// 检查认证状态
+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');
+97
View File
@@ -0,0 +1,97 @@
<template>
<div class="auth-callback">
<div class="box">
<div v-if="error" class="error">
<p class="title">登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="retry">重新登录</button>
</div>
<div v-else class="loading">
<div class="spinner"></div>
<p>正在完成登录,请稍候…</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { handleAuthorizeCallback, redirectToAuthorize } from "@/utils/authClient";
const error = ref("");
async function finish() {
// hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出
const raw = window.location.href;
const queryStr = raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : "";
const query = Object.fromEntries(new URLSearchParams(queryStr));
try {
await handleAuthorizeCallback(query);
// 登录成功:清掉 URL 上的 code/state,进入首页
window.location.href = "#/home";
window.location.reload();
} catch (e) {
error.value = e?.message || "登录失败";
}
}
function retry() {
redirectToAuthorize();
}
onMounted(finish);
</script>
<style scoped>
.auth-callback {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.box {
background: #fff;
border-radius: 12px;
padding: 40px 36px;
width: 360px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 36px;
height: 36px;
margin: 0 auto 16px;
border: 3px solid #e4e7ee;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.title {
font-size: 16px;
font-weight: 600;
color: #cf1322;
margin-bottom: 8px;
}
.msg {
font-size: 13px;
color: #5a6072;
margin-bottom: 20px;
}
.btn {
height: 40px;
padding: 0 24px;
border: none;
border-radius: 8px;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 14px;
}
</style>
+4 -5
View File
@@ -196,17 +196,17 @@ const performLoginRequest = async () => {
if (res && res.code === 200) {
// 登录成功:处理"记住我"
// 安全:只记住账号与租户名,绝不明文存储密码
if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginTenantName", tenant_name.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true");
} else {
localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false");
}
localStorage.removeItem("loginPassword");
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
@@ -327,17 +327,17 @@ const startGeetest4 = async () => {
});
if (loginRes && loginRes.code === 200) {
// 安全:只记住账号与租户名,绝不明文存储密码
if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginTenantName", tenant_name.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true");
} else {
localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false");
}
localStorage.removeItem("loginPassword");
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
const { useTabsStore } = await import("@/stores");
@@ -471,7 +471,6 @@ onMounted(async () => {
if (savedRemember === "true") {
tenant_name.value = localStorage.getItem("loginTenantName") || "";
account.value = localStorage.getItem("loginAccount") || "";
password.value = localStorage.getItem("loginPassword") || "";
rememberMe.value = true;
}