diff --git a/backend/components.d.ts b/backend/components.d.ts index bab72bc..bd7992f 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -31,7 +31,6 @@ declare module 'vue' { ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] - ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/backend/src/components/CommonHeader.vue b/backend/src/components/CommonHeader.vue index cf24c46..cfa58a8 100644 --- a/backend/src/components/CommonHeader.vue +++ b/backend/src/components/CommonHeader.vue @@ -394,7 +394,7 @@ const handleCommand = async (command) => { const tabsStore = useTabsStore(); tabsStore.resetTabs(); - router.push('/login'); + // clearToken 内部已跳认证中心登出页,本地登录页已下线 } }; diff --git a/backend/src/env.d.ts b/backend/src/env.d.ts index c09e1c2..8e3f372 100644 --- a/backend/src/env.d.ts +++ b/backend/src/env.d.ts @@ -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; // 添加其他环境变量... } diff --git a/backend/src/main.js b/backend/src/main.js index add76b4..e66b4b9 100644 --- a/backend/src/main.js +++ b/backend/src/main.js @@ -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); + }); } }); } diff --git a/backend/src/router/index.js b/backend/src/router/index.js index ad346af..67d6d8a 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -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(); }); diff --git a/backend/src/stores/auth.js b/backend/src/stores/auth.js index 0ef57c2..3e7ab9a 100644 --- a/backend/src/stores/auth.js +++ b/backend/src/stores/auth.js @@ -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() } // 检查认证状态 diff --git a/backend/src/utils/authClient.js b/backend/src/utils/authClient.js index b41f350..25358e9 100644 --- a/backend/src/utils/authClient.js +++ b/backend/src/utils/authClient.js @@ -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= + * 认证中心侧约定: + * - 未登录 → 展示登录页,登录成功后 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`, { diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index ce037b7..6aaac0a 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -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; diff --git a/backend/src/views/home/index.vue b/backend/src/views/home/index.vue index 6806c1e..b6108a8 100644 --- a/backend/src/views/home/index.vue +++ b/backend/src/views/home/index.vue @@ -409,7 +409,7 @@ async function handleLogout() { localStorage.removeItem("tenant"); sessionStorage.removeItem("tenant"); menuStore.resetMenus(); - router.push("/login"); + // clearToken 内部已跳认证中心登出页,本地登录页已下线 } // 加载模块列表 diff --git a/backend/src/views/user/userProfile.vue b/backend/src/views/user/userProfile.vue index 1075bc4..b66d8b4 100644 --- a/backend/src/views/user/userProfile.vue +++ b/backend/src/views/user/userProfile.vue @@ -60,7 +60,6 @@