feat(sso): 治理面接入 CAS 登录往返——标记头+code302 整页跳转+记账回页签
build / build (push) Successful in 1m32s

- alova-core 通用头补 yschapirequest:1:后端 CAS 门据此对 SPA 出 200+{code:302},
  errorHandle 的 case 302 整页跳登录,跳转前 rememberReturnUrl 记账、返回挂起
  Promise 不再触发全局错误弹窗(Notice 同款)
- ssoReturn.ts:回跳目标记 sessionStorage(同页签跨站往返仍在),只认同 origin
  同壳页的地址防开放重定向;不照 Notice 拼 hex 进 service——本网关 ctx 为空、
  外链前缀被 Caddy 剥离,固定 service 与每次不同的目标不可共存(会票据校验死循环)
- main.ts 挂载前 consumeSsoReturnUrl 换回原页签,避免回跳闪默认页
- 重载注册表按钮改走浏览器面孪生入口 /api/internal/admin/routes/reload
  (机器面 /routes/refresh 收口内部令牌后浏览器再打会被 401 拦死)
- 顶栏登出:ssoHost 取自已登录会话 /admin/session(后端无 env-config 端点),
  取不到不渲染;mock 同步补 reload/session 两端点
This commit is contained in:
chenxing
2026-10-11 12:50:23 +08:00
parent a036f39713
commit 729ab9592d
7 changed files with 109 additions and 8 deletions
+2 -1
View File
@@ -59,7 +59,8 @@ npm run dev:mock
| `GET /api/internal/admin/targets` | 同信封分页 | 一致 |
| `PUT /api/internal/admin/targets/{id}` | body{baseUrl 非空, enabled 真布尔};成功 `data={targets:N}` | 一致 |
| `GET /api/internal/admin/logs?pageIndex&pageSize&targetCode&callerAppKey` | pageSize 默认 50 上限 500;筛选**精确等值**、空串=不过滤;排序 id desc;`ts` ISO 字符串;backendCode 恒 null | 一致(先过滤再分页) |
| `POST /api/internal/routes/refresh` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7) |
| `POST /api/internal/admin/routes/reload` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7;`apps` 键 mock 无数据源,前端按 `-` 渲染) |
| `GET /api/internal/admin/session` | `data={ssoHost,principal}`,值来自已登录 CAS 会话 | `ssoHost` 返空串 → 顶栏「退出」不渲染(同 Notice 的 ssoHost 空态) |
前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。
+4 -1
View File
@@ -15,6 +15,9 @@ export default {
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'],
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'],
// 重载注册表:走浏览器面孪生入口。机器面 /api/internal/routes/refresh 只认内部令牌,
// 浏览器带 CAS 会话去打它会被 401 拦死(两口共用 RegistryReloader,返回体同形)
'GatewayAdmin.reload': ['POST', '/api/internal/admin/routes/reload'],
'GatewayAdmin.session': ['GET', '/api/internal/admin/session'],
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
};
+6 -1
View File
@@ -498,10 +498,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server
send(res, ok(listLogs(q)));
return true;
}
if (api === "/api/internal/routes/refresh" && method === "POST") {
if (api === "/api/internal/admin/routes/reload" && method === "POST") {
send(res, ok({ loaded: routes.length, targets: targets.length }));
return true;
}
if (api === "/api/internal/admin/session" && method === "GET") {
// ssoHost 留空:mock 没有真 SSO,前端据此隐藏「退出登录」(同 Notice 的 ssoHost 空态行为)
send(res, ok({ ssoHost: "", principal: "mock 治理" }));
return true;
}
if (api === "/api/internal/admin/proxy-test" && method === "POST") {
const body = await readJsonBody(req);
if (body === undefined) {
+31 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { LayoutData } from "@/types/Basics";
import Utils from "@/utils";
@@ -22,14 +22,15 @@ const isMock = computed(() => !!config.isMock);
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
const opName = "网关治理";
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
/* 重载注册表(POST /api/internal/admin/routes/reload):治理面入口,与机器面
/api/internal/routes/refresh 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
const reloading = ref(false);
const lastReloadTime = ref("");
async function reloadRegistry() {
reloading.value = true;
try {
const d: any = await Apis.GatewayAdmin.refresh({});
const d: any = await Apis.GatewayAdmin.reload({});
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
} catch (e: any) {
@@ -39,6 +40,25 @@ async function reloadRegistry() {
}
}
/* SSO 基址:网关后端无 env-config 端点(conf.ssoHost 恒空),改从已登录会话里取。
取不到(未配 CAS / mock)则不渲染登出入口——与 Notice 的 ssoHost 空态一致。 */
const ssoHost = ref("");
onMounted(async () => {
try {
const d: any = await Apis.GatewayAdmin.session({});
ssoHost.value = d?.ssoHost || "";
} catch (e: any) {
// 未登录时这条会被 alova 整页跳去 SSO;其它失败只是没有登出入口,不额外弹
}
});
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
function logout() {
window.location.href = ssoHost.value + "/logout?service=" + window.location.href;
}
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
const reloadPage = inject("reload", () => {}) as () => void;
</script>
@@ -62,6 +82,8 @@ const reloadPage = inject("reload", () => {}) as () => void;
<div class="header-aside fr">
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
</div>
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
@@ -134,4 +156,10 @@ const reloadPage = inject("reload", () => {}) as () => void;
border: 1px solid rgba(255, 153, 0, 0.65);
color: #ffd49a;
}
/* 登出入口:贴用户胶囊右侧,沿用顶栏次要文字色(不做按钮,避免与「重载注册表」抢视觉权重) */
.header-aside .logout {
margin-left: 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
</style>
+6
View File
@@ -23,6 +23,7 @@ import "virtual:uno.css";
import "virtual:svg-icons-register";
import config from "@/assets/config/host.config";
import { consumeSsoReturnUrl } from "@/utils/ssoReturn";
const app = createApp(App);
@@ -32,4 +33,9 @@ app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing);
// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。
const ssoReturn = consumeSsoReturnUrl();
if (ssoReturn) router.replace(ssoReturn);
app.mount("#app");
+12 -2
View File
@@ -1,6 +1,7 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import { rememberReturnUrl } from "@/utils/ssoReturn";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
@@ -56,8 +57,14 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
return res.data ?? (null as T);
case 302:
console.log(res);
// 后端 CAS 门已拼好登录地址(含 service=会话建立端点),整页跳过去即可。
// 跳转前记下当前地址:固定 service 的回跳只能落默认页,hash 页签靠前端记账换回
//(原因见 utils/ssoReturn.ts——不能照 Notice 把 hex 拼进固定 service)。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,全局 onBizError 与
// 各页面 catch 都不再触发——否则会弹一个"redirect"错误提示(Notice 同款处理)。
rememberReturnUrl();
window.location.href = res.urlToRedirectTo!;
throw new Error("redirect");
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
@@ -90,8 +97,11 @@ export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, state
injectHeaders(method);
method.config.credentials = "include";
// 通用头(网关管理面不识别 yschapirequest/yschreferer/currentRole,故不注入)
// 通用头。yschapirequest 是治理面 CAS 门识别 SPA 的标记头(与平台
// CasSsoApiRedirectFilter 同名同值):带它,未登录才返回 200+{code:302,urlToRedirectTo}
// 让本页去整页跳 SSO;不带,网关按裸调用方出 401 json。
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* SSO 登录往返的「回到原页签」记账(纯前端)。
* <p>
* <b>为什么不是 Notice 的 {@code ;redirectToReferer=<hex>} 那套</b>:Notice 把 hex 拼进 service
* 参数,再靠校验过滤器从<b>实时请求 URI</b> 重算出同一个带 hex 的 service,两端逐字节一致才过得了
* CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出
* 带前缀的地址,只能改用<b>固定 service</b>(见后端 {@code SsoAddressing})——固定值里塞不进每次
* 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败
* → 又是一次 302,死循环。
* <p>
* 所以回跳目标不下服务端:整页跳去登录前把它记在 {@code sessionStorage}(同标签页、跨站点往返仍在),
* 回到控制台壳页时读出来换回原页签。副作用为零,且不引入任何服务端开放重定向面。
*/
const RETURN_KEY = "ysch-return-url";
/** 整页跳去 SSO 之前调用:记下当前控制台地址(含 hash 路由) */
export function rememberReturnUrl(href: string = window.location.href): void {
try {
sessionStorage.setItem(RETURN_KEY, href);
} catch (e: any) {
// 隐私模式/配额满:最坏只是回到默认页,不影响登录本身
}
}
/**
* 取出并清掉回跳目标,返回 vue-router 的 hash 路由段(如 {@code /ledger?pageIndex=2})。
* 只认「同 origin + 同一壳页」的地址,其余一律丢弃——防把用户导向站外或别的应用。
*/
export function consumeSsoReturnUrl(): string | null {
let raw: string | null = null;
try {
raw = sessionStorage.getItem(RETURN_KEY);
if (raw) sessionStorage.removeItem(RETURN_KEY);
} catch (e: any) {
return null;
}
if (!raw) return null;
try {
const u = new URL(raw);
if (u.origin !== window.location.origin) return null;
if (u.pathname !== window.location.pathname) return null;
const route = u.hash.replace(/^#\/?/, "/");
return route && route !== "/" ? route : null;
} catch (e: any) {
return null;
}
}