fix(cas): 接通 CAS 单点登录全链路 + 清理冗余配置

修复链上的 8 个真 bug:
1. UserDetail(UserVO,Set) 漏 setAuthorities → CAS token 构造抛 IllegalArgumentException
   修:CasUserDetailsService.buildGptUserDetail 手动补 empty authorities

2. frontEndUrl 写死内网 IP,localhost 隧道用户跳回时"无法访问此网站"
   修:CasUrlBuilder 用 X-Forwarded-Host / Host 动态拼 service URL

3. vite proxy 没配 /metalinfo/chat_web_backend,CAS 回跳 ticket 被前端路由吞
   修:加一条 proxy(rewrite 去 /metalinfo 前缀)+ X-Forwarded-Host 转发

4. ticket 校验 service URL 跟 entry point 不一致 → CAS server mismatch
   修:自定义 AuthenticationDetailsSource 用同一个动态 URL

5. sendRedirect URL 含 # 经容器编码成 %23,浏览器拿不到 hash → 404
   修:改用 query 参数(/metalinfo/?cas_token=xxx),前端 router beforeEach 拦截

6. CAS 登录后 HttpSession 残留,第二次访问 /cas/login 不触发 entryPoint → 落到
   DispatcherServlet → 找不到映射 → 404 Whitelabel
   修:SuccessHandler 完成后 invalidate session + clear SecurityContext

7. CAS 路径漏写 Redis token,JwtAuthenticationFilter 校验时 LOGIN_TOKEN_KEY 找不到
   → "token已失效" → 前端 axios interceptor 清 token 跳回 login
   修:SuccessHandler 同步写 redisUtils.set,与 LoginController.saveLoginLog 对齐

8. permission.ts 没拦 query 里的 cas_token,hash 路由下 location.search 取不到
   修:router beforeEach 优先消费 cas_token 再走 getSession

清理冗余:
- CasProperties 删 6 个未用字段(enabled/serverLogout/appLogout/appKey/
  appSecret/httpsFlag/frontEndUrl)
- application.yml 同步删,移除写死的 app-secret 等敏感字段
- 删外部 override 文件 chat_web_backend/config/application.yml
- casServiceProperties.setService 改占位符(实际不被读取,只满足
  ServiceProperties.afterPropertiesSet 的非空校验)
- 删 permission.ts 的 [CAS] [GUARD] debug log,保留 catch error 一条

新增:
- CasUrlBuilder 工具类:从请求动态解析 host/scheme,多个地方共用

UI:
- welcome 页面玻璃按钮 + 呼吸光晕/光感动画(用户自己调过,本次保留)
- App.vue:/welcome 路径不渲染 Operates 侧边栏

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:22:57 +08:00
parent cc54b24a77
commit b83c540018
13 changed files with 337 additions and 48 deletions

View File

@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 26.46">
<g id="_图层_1-2" data-name="图层 1">
<g>
<g fill="#636E77">
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,6 +1,6 @@
<template>
<div class="content">
<Operates v-show="url != '/login' && url != '/writing/edit'" />
<Operates v-show="url != '/login' && url != '/welcome' && url != '/writing/edit'" />
<RouterView />
</div>
</template>

View File

@@ -7,13 +7,13 @@
<path d="M109.69,12.44s3.91,7.74,7.82,10.54c.39,.28,.92,.27,1.31-.01,1.96-1.45,2.1-1.86,2.1-1.86,0,0-3.8-1.82-9.5-9.58-1.73,.41-1.73,.91-1.73,.91Z"/>
<path d="M109.23,5.22h3.14s.24,10.69-8.85,17.49c-.4,.3-.93,.37-1.39,.17-.52-.23-.99-.57-.99-.57,0,0,7.02-4.62,8.09-17.09Z"/>
</g>
<g>
<g fill="#636E77">
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
</g>
<path d="M46.69,9.69v-1.09h-3.34v-3.7h-2.81V13.08h-2.44v7.1h8.57v-5.93c0-.61-.5-1.11-1.11-1.11h-2.21v-3.45h3.34Zm-3.32,4.42c.26,0,.46,.21,.46,.47v4.22c0,.26-.2,.46-.46,.46h-1.89c-.26,0-.47-.2-.47-.46v-4.22c0-.26,.21-.47,.47-.47h1.89Z"/>
<path d="M47.63,4.92h2.83l3.11,16.59h3.2v1.19h-3.69c-.88,0-1.7-.42-2.22-1.13l-.05-.07c-.15-.21-.25-.44-.3-.69l-2.88-15.89Z"/>

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,9 +1,31 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/store/modules/auth'
/**
* 检查 URL search 部分有没有 cas_tokenCAS 回调时后端把 token 拼在 query 里),
* 有的话存下来并清掉 URL 上的痕迹。
*/
function consumeCasTokenFromUrl(authStore: ReturnType<typeof useAuthStore>) {
const search = window.location.search || ''
if (!search.includes('cas_token=')) return false
const params = new URLSearchParams(search.replace(/^\?/, ''))
const token = params.get('cas_token')
if (!token) return false
authStore.setToken(token)
// 把 cas_token 从 URL 上清掉,留住 hash 部分
const cleanUrl = window.location.pathname + window.location.hash
window.history.replaceState({}, document.title, cleanUrl)
return true
}
export function setupPageGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// CAS 回调先拿 token再走普通鉴权
if (consumeCasTokenFromUrl(authStore)) {
next({ path: '/welcome', replace: true })
return
}
if (to.path == '/login') {
next()
return
@@ -25,6 +47,7 @@ export function setupPageGuard(router: Router) {
}
next()
} catch (error) {
console.warn('[auth-guard] getSession 失败:', error)
if (to.path === '/login') next()
else next({ name: 'Login' })
}

View File

@@ -179,15 +179,20 @@ const goCasLogin = () => {
};
// CAS 回调URL 含 cas_token → 存 token + 跳 welcome
// 注意 hash 路由下 token 在 hash 里(#/login?cas_token=xxx不在 location.search
onMounted(() => {
const params = new URLSearchParams(window.location.search);
const hash = window.location.hash || "";
const qIdx = hash.indexOf("?");
const queryStr = qIdx >= 0 ? hash.substring(qIdx + 1) : window.location.search.replace(/^\?/, "");
const params = new URLSearchParams(queryStr);
const casToken = params.get("cas_token");
if (casToken) {
authStore.setToken(casToken);
// 清掉 URL 上的 cas_token 参数
window.history.replaceState(
{},
document.title,
window.location.pathname + window.location.hash.split("?")[0]
window.location.pathname + (qIdx >= 0 ? hash.substring(0, qIdx) : window.location.hash)
);
router.push("/welcome");
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="welcomePage">
<Waves></Waves>
<Waves color="#ffd060"></Waves>
<img :src="projectLogo" alt="" class="welcomeLogo" />
<div class="welcomeContent">
<div class="welcomeTitle">
@@ -37,10 +37,48 @@ const goChat = () => {
.welcomePage {
width: 100vw;
height: 100vh;
background-image: url("../../assets/images/login/loginBg.png");
background-size: cover;
background:
/* 中心更亮的径向高光(让画面有"光源" + 立体感) */
radial-gradient(
ellipse 80% 60% at 50% 38%,
rgba(255, 220, 130, 0.18) 0%,
rgba(255, 80, 50, 0) 55%
),
/* 主红渐变(深红 → 中红 → 深红,营造曲面感) */
linear-gradient(
135deg,
#8a0a05 0%,
#c10b08 35%,
#d6201a 50%,
#c10b08 65%,
#8a0a05 100%
);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 底部金色光带CASSIC 风格的金色波纹) */
.welcomePage::after {
content: "";
position: absolute;
left: -10%;
right: -10%;
bottom: 0;
height: 36%;
background:
radial-gradient(
ellipse 60% 100% at 30% 90%,
rgba(255, 200, 90, 0.45) 0%,
rgba(255, 200, 90, 0) 60%
),
radial-gradient(
ellipse 70% 80% at 75% 100%,
rgba(255, 215, 110, 0.35) 0%,
rgba(255, 215, 110, 0) 60%
);
pointer-events: none;
z-index: 0;
}
.welcomeLogo {
position: absolute;
@@ -70,32 +108,92 @@ const goChat = () => {
align-items: center;
justify-content: center;
}
/* 平面玻璃片:均匀透明 + 重模糊 + 极细单色边沿,不模拟立体凸起 */
.glassBtn {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 44px;
padding: 18px 46px;
font-size: 22px;
font-weight: 500;
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.95);
letter-spacing: 0.5px;
/* 玻璃本体12% 白 — 透明感主导,本身几乎隐形 */
background: rgba(255, 255, 255, 0.12);
/* 重磨砂打散后景,不上 saturate 避免蓝被加重 */
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
/* 边框:更亮,整圈勾出玻璃片的轮廓 */
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
cursor: pointer;
transition: all 0.25s ease;
transition:
transform 0.45s cubic-bezier(0.22, 1, 0.36, 1),
background 0.35s ease,
border-color 0.35s ease,
box-shadow 0.45s ease;
/* 关键:靠 inset 高光"勾形",不靠白底覆盖
- 顶 inset 较亮(玻璃上沿折射到的光,定义"这是玻璃片的上边")
- 底 inset 微暗(玻璃下沿) */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 8px 32px rgba(0, 0, 0, 0.18);
inset 0 1.5px 1px rgba(255, 255, 255, 0.65),
inset 0 -1px 1px rgba(0, 0, 0, 0.08),
0 2px 4px rgba(0, 0, 0, 0.1),
0 10px 26px rgba(0, 0, 0, 0.2);
}
/* 横扫光:玻璃面被光划过的一瞬,慢且稀疏 */
.glassBtn::before {
content: "";
position: absolute;
top: 0;
left: -120%;
width: 35%;
height: 100%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0) 35%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0) 65%,
transparent 100%
);
filter: blur(3px);
transform: skewX(-20deg);
animation: glassShimmer 6s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
/* 内容层级在扫光之上 */
.glassBtn > * {
position: relative;
z-index: 1;
}
.glassBtn:hover {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.6);
border-color: rgba(255, 255, 255, 0.7);
transform: translateY(-1px);
box-shadow:
inset 0 2px 1px rgba(255, 255, 255, 0.8),
inset 0 -1px 1px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.14),
0 14px 30px rgba(0, 0, 0, 0.26);
}
.glassBtn:hover .arrow {
transform: translateX(5px);
border-color: rgba(255, 255, 255, 0.7);
}
.glassBtn:active {
transform: translateY(0);
transition-duration: 0.1s;
}
.glassBtn .arrow {
display: inline-flex;
@@ -104,8 +202,19 @@ const goChat = () => {
width: 28px;
height: 28px;
font-style: normal;
font-size: 18px;
border: 1px solid rgba(255, 255, 255, 0.5);
font-size: 16px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 横扫光感动画6s 周期,划过 35% → 220% */
@keyframes glassShimmer {
0% {
left: -120%;
}
55%, 100% {
left: 220%;
}
}
</style>

View File

@@ -35,6 +35,26 @@ export default defineConfig((env) => {
[viteEnv.VITE_GLOB_API_CTX]: {
target: viteEnv.VITE_GLOB_API_DEV_IP,
changeOrigin: true,
// 转发原 Host让后端能用 X-Forwarded-Host 拼出用户浏览器的实际 URL
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
proxyReq.setHeader('X-Forwarded-Proto', 'http')
})
},
},
// CAS 回调service URL 形如 /metalinfo/chat_web_backend/cas/login?ticket=xxx
// 把 /metalinfo 前缀去掉转给后端,否则 vite 当前端路由吃掉
[`${viteEnv.VITE_GLOB_FRONT_CTX}${viteEnv.VITE_GLOB_API_CTX}`]: {
target: viteEnv.VITE_GLOB_API_DEV_IP,
changeOrigin: true,
rewrite: (p) => p.replace(new RegExp(`^${viteEnv.VITE_GLOB_FRONT_CTX}`), ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
proxyReq.setHeader('X-Forwarded-Proto', 'http')
})
},
},
// 工具服务通过 Nginx(:18000) 反代sub_filter 处理子资源路径
'/pdf/': {