修复本地开发不走统一登陆

This commit is contained in:
2026-09-29 11:10:42 +08:00
parent c49e8d0d3f
commit 1685b22e1e
12 changed files with 161 additions and 139 deletions
+1 -1
View File
@@ -394,7 +394,7 @@ const handleCommand = async (command) => {
const tabsStore = useTabsStore();
tabsStore.resetTabs();
router.push('/login');
// clearToken 内部已跳认证中心登出页,本地登录页已下线
}
};
+6
View File
@@ -26,6 +26,12 @@ declare module '@/stores/auth' {
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
// 统一认证中心(本地登录已停用)
readonly VITE_AUTH_BASE?: string;
readonly VITE_AUTH_CLIENT_ID?: string;
readonly VITE_AUTH_REDIRECT_URI?: string;
readonly VITE_AUTH_POST_LOGOUT_URI?: string;
readonly VITE_AUTH_MODE?: string;
// 添加其他环境变量...
}
+5 -2
View File
@@ -14,6 +14,7 @@ import router from './router'
import { loadAndAddDynamicRoutes } from './router'
import { createPinia } from 'pinia'
import { useAuthStore } from './stores/auth'
import { redirectToAuthorize } from '@/utils/authClient'
// import { initTheme } from './utils/theme'
// 导入全局组件
import UmoEditor from '@/views/components/UmoEditor.vue';
@@ -53,8 +54,10 @@ if (authStore.isLoggedIn) {
// 检查是否因为 token 无效而导致路由加载失败
const token = localStorage.getItem('token');
if (!token) {
authStore.clearToken();
window.location.href = '#/login';
// 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
redirectToAuthorize().catch((err) => {
console.error('跳转统一认证失败:', err);
});
}
});
}
+42 -45
View File
@@ -1,6 +1,10 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes";
import { isSSOEnabled, redirectToAuthorize, ensureUserInfo } from "@/utils/authClient";
import { redirectToAuthorize, ensureUserInfo } from "@/utils/authClient";
// 本地登录已停用的历史路径:命中后统一改送认证中心,避免书签/后端回跳落到 404
const LEGACY_LOCAL_LOGIN_PATHS = ["/login", "/register", "/forget"];
// 静态子路由:需要在 Main 框架内显示的页面
const staticMainChildren = [
@@ -127,26 +131,9 @@ const staticMainChildren = [
}
];
// 静态路由:登录页独立、home 导航门户独立、404 页面独立
// 静态路由:home 导航门户独立、404 页面独立
// 注意:本地账号密码登录入口(/login、/register、/forget)已下线,登录一律走统一认证中心
const staticRoutes = [
{
path: "/login",
name: "Login",
component: () => import("@/views/login/index.vue"),
meta: { requiresAuth: false }
},
{
path: "/register",
name: "Register",
component: () => import("@/views/login/register.vue"),
meta: { requiresAuth: false }
},
{
path: "/forget",
name: "ForgetPassword",
component: () => import("@/views/login/forget.vue"),
meta: { requiresAuth: false }
},
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
{
path: "/auth/callback",
@@ -310,50 +297,60 @@ function findFirstValidRoute(routes) {
return null;
}
/** 跳转统一认证中心;地址未配置时给出提示,避免白屏 */
async function gotoAuthorize() {
try {
await redirectToAuthorize();
} catch (e) {
console.error(e);
window.alert(e?.message || "统一认证跳转失败,请检查认证中心配置");
}
}
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem("token");
const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
if (publicPaths.includes(to.path)) {
// 认证中心回跳页:始终放行,由页面自身完成 code 换令牌
if (to.path === "/auth/callback") {
next();
return;
}
// 已下线的本地登录入口:已登录回首页,未登录送认证中心
if (LEGACY_LOCAL_LOGIN_PATHS.includes(to.path)) {
if (token) {
if (!dynamicRoutesAdded) {
await loadAndAddDynamicRoutes();
}
next({ path: "/home" });
} else {
next();
await gotoAuthorize();
}
return;
}
if (!token) {
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
if (isSSOEnabled()) {
await redirectToAuthorize();
return;
}
next({ path: "/login", query: { redirect: to.path } });
return;
}
// 统一认证模式:补全用户信息(旧会话可能只有 token 没有 userInfo,
// 或迁移后 userInfo 缺失 id,会导致菜单等接口报「用户ID不存在」)。
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
if (isSSOEnabled()) {
const info = await ensureUserInfo();
if (!info) {
await redirectToAuthorize();
return;
}
// 未登录:统一走认证中心(本地登录已停用)
if (!token) {
await gotoAuthorize();
return;
}
// 补全用户信息(旧会话可能只有 token 没有 userInfo,或迁移后 userInfo 缺失 id,
// 会导致菜单等接口报「用户ID不存在」)。
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
const info = await ensureUserInfo();
if (!info) {
await gotoAuthorize();
return;
}
if (!dynamicRoutesAdded) {
await loadAndAddDynamicRoutes();
// 路由加载后重新导航,确保路由匹配正确
next({ path: to.path, replace: true });
return;
}
next();
});
+4 -5
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { getCurrentUser } from '@/api/login'
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
import { logoutSSO } from '@/utils/authClient'
// 用户信息类型
const defaultUser = {
@@ -94,10 +94,9 @@ export const useAuthStore = defineStore('auth', () => {
Object.assign(user, defaultUser)
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效)
if (isSSOEnabled()) {
logoutSSO()
}
// 统一认证:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效,
// 调用方不要再 router.push('/login'),本地登录页已下线)
logoutSSO()
}
// 检查认证状态
+47 -12
View File
@@ -1,30 +1,47 @@
/**
* 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE
*
* 接入方式:在 .env 中配置
* VITE_AUTH_MODE=sso 开启统一认证(不配置则走原有 /backend/login)
* VITE_AUTH_BASE=https://api.yunzer.cn/auth
* 本项目已停用本地账号密码登录(/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
* VITE_AUTH_POST_LOGOUT_URI=https://back.yunzer.cn/#/
*
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
* 因此接入后业务代码无需改动即可带上 Authorization 头。
* 因此业务代码无需改动即可带上 Authorization 头。
*/
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE || "";
// 未显式配置时回退到「接口地址 + /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`;
const POST_LOGOUT_URI =
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/login`;
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 import.meta.env.VITE_AUTH_MODE === "sso" && !!AUTH_BASE;
return true;
}
/** 认证中心地址是否可用(排查配置缺失用) */
export function hasAuthBase() {
return !!AUTH_BASE;
}
// ---------------------------------------------------------------- PKCE
@@ -76,14 +93,26 @@ export function getAccessToken() {
// ---------------------------------------------------------------- 主流程
/** 跳转认证中心登录(带 PKCE 与 state) */
/**
* 跳转认证中心登录(带 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");
}
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem("pkce_verifier", verifier);
const state = randomString(24);
sessionStorage.setItem("oidc_state", state);
const params = new URLSearchParams({
const authorizeURL = `${AUTH_BASE}/authorize?${new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
@@ -91,8 +120,13 @@ export async function redirectToAuthorize() {
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${AUTH_BASE}/authorize?${params.toString()}`;
}).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 换令牌 */
@@ -153,6 +187,7 @@ export async function refreshAccessToken() {
/** 单点登出:吊销令牌后回认证中心登出页 */
export async function logoutSSO() {
if (!AUTH_BASE) return;
const token = getAccessToken();
try {
await fetch(`${AUTH_BASE}/revoke`, {
+18 -24
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { isSSOEnabled, refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient';
import { refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient';
// 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环
let ssoRedirecting = false;
@@ -51,31 +51,25 @@ service.interceptors.response.use(
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');
if (!ssoRedirecting) {
ssoRedirecting = true;
redirectToAuthorize();
}
return Promise.reject(new Error('token无效,请重新登录'));
// 统一认证:先用 refresh_token 静默换取新令牌并重放原请求,
// 刷新失败(令牌过期/被吊销/旧本地登录遗留 token)才真正登出。
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
return service.request(error.config);
} catch (e) {
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
clearTokens();
localStorage.removeItem('userInfo');
if (!ssoRedirecting) {
ssoRedirecting = true;
redirectToAuthorize().catch((err) => {
console.error('跳转统一认证失败:', err);
ssoRedirecting = false;
});
}
return Promise.reject(new Error('token无效,请重新登录'));
}
console.error('未授权,请重新登录');
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
if (window.location.hash !== '#/login') {
window.location.href = '#/login';
}
return Promise.reject(new Error('token无效'));
case 404:
console.error('请求的资源不存在');
break;
+1 -1
View File
@@ -409,7 +409,7 @@ async function handleLogout() {
localStorage.removeItem("tenant");
sessionStorage.removeItem("tenant");
menuStore.resetMenus();
router.push("/login");
// clearToken 内部已跳认证中心登出页,本地登录页已下线
}
// 加载模块列表
+1 -3
View File
@@ -60,7 +60,6 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { getUserInfo } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
@@ -76,7 +75,6 @@ import BindPhoneDialog from "./components/BindPhoneDialog.vue";
import BindEmailDialog from "./components/BindEmailDialog.vue";
import WechatBindCard from "./components/WechatBindCard.vue";
const router = useRouter();
const authStore = useAuthStore();
const userInfo = ref(null);
@@ -179,8 +177,8 @@ const handleLogout = () => {
}
}
// clearToken 内部会吊销令牌并跳认证中心登出页,此处不再跳本地登录页
authStore.clearToken();
router.push("/login");
ElMessage.success("已退出登录");
}).catch(() => {});
};
+10 -1
View File
@@ -2,9 +2,18 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
/** 统一认证中心地址,如 https://api.yunzer.cn/auth */
readonly VITE_AUTH_BASE?: string
/** 统一认证客户端 ID,默认 yz-backend */
readonly VITE_AUTH_CLIENT_ID?: string
/** 授权回跳地址,必须与认证中心登记的 redirect_uris 完全一致 */
readonly VITE_AUTH_REDIRECT_URI?: string
/** 单点登出后回跳地址 */
readonly VITE_AUTH_POST_LOGOUT_URI?: string
/** 兼容标记:本地登录已停用,该值不再决定登录方式 */
readonly VITE_AUTH_MODE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}