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,37 +1,83 @@
package com.inspur.llm.chat.base.security.cas;
import com.inspur.llm.chat.base.constant.RedisConstants;
import com.inspur.llm.chat.base.constant.StringPoolConstant;
import com.inspur.llm.chat.base.security.JwtTokenUtils;
import com.inspur.llm.chat.base.security.UserDetail;
import com.inspur.llm.chat.base.util.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token
* CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token
*
* 重定向的前端 URL 用当前请求的 X-Forwarded-Host / Host 动态拼,
* 这样回到前端时浏览器地址不会突变(用户从哪个 host 来就回哪个 host
* 避免内网 IP 不可达造成"无法访问此网站"。
*/
@Slf4j
@Component
public class CasLoginSuccessHandler implements AuthenticationSuccessHandler {
/**
* 前端 base 路径token 用 query 参数传(不放 hash 里)。
* 原本想用 /metalinfo/#/login?cas_token=xxx但 sendRedirect 经过容器/代理时
* # 字符容易被 encode 成 %23导致浏览器拿不到 hash → vue-router 不知道路由 → 404。
* 改用 query/metalinfo/?cas_token=xxx前端 router beforeEach 拦截。
*/
private static final String FRONTEND_BASE_PATH = "/metalinfo/";
@Autowired
private CasProperties casProperties;
@Autowired
private RedisUtils redisUtils;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
log.info("CAS 登录成功: {}", authentication.getName());
String token = JwtTokenUtils.generateToken(authentication);
String redirect = casProperties.getFrontEndUrl()
+ "/#/login?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
// 把 token 写进 Redis否则 JwtAuthenticationFilter 校验时查不到会返"token已失效"
// 跟 LoginController.saveLoginLog 同样逻辑
try {
UserDetail userDetail = JwtTokenUtils.getUserDetail(authentication);
String key = RedisConstants.LOGIN_TOKEN_KEY
+ userDetail.getId() + StringPoolConstant.COLON + userDetail.getSessionId();
redisUtils.set(key, token, JwtTokenUtils.EXPIRE_TIME / 1000L);
log.info("CAS token 已写入 Redis, key={}", key);
} catch (Exception e) {
log.warn("CAS token 写 Redis 失败: {}", e.getMessage(), e);
}
String redirect = CasUrlBuilder.buildServiceUrl(request, FRONTEND_BASE_PATH)
+ "?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
log.info("CAS 登录成功跳转 URL: {}", redirect);
// 销毁 HttpSession + 清 SecurityContext
// 否则下次访问 /cas/login 时 Spring Security 看到 session 已认证 → 不触发 CAS entryPoint
// → 请求穿透到 DispatcherServlet → 找不到 /cas/login 映射 → 404 Whitelabel。
// 我们用 JWT 做认证,不需要 server-side session。
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();
response.sendRedirect(redirect);
}
}

View File

@@ -5,20 +5,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* CAS 单点登录配置(绑定 application.yml 中的 security.cas.*
* CAS 单点登录配置(绑定 application.yml 中的 security.cas.*
*
* 只保留实际使用的字段。logout/appKey/appSecret 等之前预留但未实现,
* 真正接入时再加,避免 dead config。
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "security.cas")
public class CasProperties {
private boolean enabled = false;
/** CAS 服务器根地址,例如 http://192.168.203.20:8180 */
private String serverHost;
/** CAS 服务器登录入口,通常 ${serverHost}/login */
private String serverLogin;
private String serverLogout;
/** 本应用的 CAS 回调路径servlet path通常 /cas/login */
private String appLogin;
private String appLogout;
private String appKey;
private String appSecret;
private boolean httpsFlag = false;
private String frontEndUrl;
}

View File

@@ -10,23 +10,34 @@ import org.springframework.security.cas.authentication.CasAssertionAuthenticatio
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* CAS 单点登录 Security 配置(仅处理 /cas/** 路径,与 WebSecurityConfig 共存)。
*
* 流程:
* /cas/login无 ticket → 触发 CasEntryPoint → 302 到 CAS 登录页
* /cas/login?ticket=xxx → CasAuthenticationFilter 验证 → 成功 → CasLoginSuccessHandler 生成 JWT → 302 前端
*
* service URL 用 X-Forwarded-Host / Host 动态拼,不依赖固定 frontEndUrl
* 这样浏览器在 localhost:3000 / 内网 IP / 公网域名 各种入口下 CAS 回跳都能命中。
*/
@Configuration
@Order(1)
public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
/** 给 CAS 的 service URL 后缀(不含 host */
static final String SERVICE_URL_SUFFIX = "/metalinfo/chat_web_backend/cas/login";
@Autowired
private CasProperties casProperties;
@@ -57,18 +68,39 @@ public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
auth.authenticationProvider(casAuthenticationProvider());
}
/**
* ServiceProperties 仅作为 bean 占位被 entry point / filter / provider 引用,
* 它的 service 字段实际不会被读到——entry point override 了 createServiceUrl
* filter 设了自定义 AuthenticationDetailsSource校验时用的是 details.getServiceUrl()。
*
* 但 ServiceProperties.afterPropertiesSet() 会硬性校验 service 字段非空,
* 所以这里 set 一个占位符值满足校验,不影响实际行为。
*/
@Bean
public ServiceProperties casServiceProperties() {
ServiceProperties sp = new ServiceProperties();
sp.setService(casProperties.getFrontEndUrl() + "/chat_web_backend" + casProperties.getAppLogin());
// 占位符必须非空afterPropertiesSet 会校验),实际值由 entry point 的 createServiceUrl
// 和 filter 的 AuthenticationDetailsSource 动态构造
sp.setService("placeholder-not-used");
sp.setSendRenew(false);
sp.setAuthenticateAllArtifacts(true);
return sp;
}
/**
* 自定义 entry pointoverride createServiceUrl用当前请求的 X-Forwarded-Host / Host
* 动态拼 service URL而不是用固定的 frontEndUrl 配置commence 是 final只能改这里
*
* 注意返回 raw URL未 encodecommence 内部的 constructRedirectUrl 会再 URLEncoder.encode 一次。
*/
@Bean
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint();
CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint() {
@Override
protected String createServiceUrl(HttpServletRequest request, HttpServletResponse response) {
return CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
}
};
ep.setLoginUrl(casProperties.getServerLogin());
ep.setServiceProperties(casServiceProperties());
return ep;
@@ -81,6 +113,12 @@ public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
filter.setFilterProcessesUrl(casProperties.getAppLogin());
filter.setAuthenticationSuccessHandler(casLoginSuccessHandler);
filter.setServiceProperties(casServiceProperties());
// 关键ticket 校验阶段也得用同一个动态 service URL含 /metalinfo 前缀),
// 否则 CAS 服务器对 ticket 的 service 校验会 mismatchvite proxy 已经把 /metalinfo
// 前缀剥掉request URL 没法直接拼出正确 service URL所以自定义一个
AuthenticationDetailsSource<javax.servlet.http.HttpServletRequest, ServiceAuthenticationDetails> source =
request -> () -> CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
filter.setAuthenticationDetailsSource(source);
// 仅在 URL 含 ticket 时才尝试认证;无 ticket → 让 ExceptionTranslationFilter 触发 EntryPoint 重定向 CAS
final String appLogin = casProperties.getAppLogin();
filter.setRequiresAuthenticationRequestMatcher(req ->

View File

@@ -0,0 +1,44 @@
package com.inspur.llm.chat.base.security.cas;
import javax.servlet.http.HttpServletRequest;
/**
* 从 HTTP 请求动态构造 CAS 相关 URL 的工具类。
*
* 优先用 X-Forwarded-Host / X-Forwarded-Protovite proxy 转发的原始 Host
* 退而求其次用请求自身的 Host。这样无论用户是 localhost、内网 IP 还是公网域名访问,
* 拼出来的 URL 都跟用户浏览器的实际地址栏一致。
*/
public final class CasUrlBuilder {
private CasUrlBuilder() {}
/** 取请求实际的 host优先 X-Forwarded-Host。 */
public static String resolveHost(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-Host");
if (forwarded != null && !forwarded.isEmpty()) {
// X-Forwarded-Host 可能是 "host1, host2"(多层代理),取第一个
int comma = forwarded.indexOf(',');
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
String host = request.getHeader("Host");
if (host != null && !host.isEmpty()) {
return host;
}
return request.getServerName() + ":" + request.getServerPort();
}
/** 取请求实际的 scheme优先 X-Forwarded-Proto。 */
public static String resolveScheme(HttpServletRequest request) {
String proto = request.getHeader("X-Forwarded-Proto");
if (proto != null && !proto.isEmpty()) {
return proto.trim();
}
return request.getScheme();
}
/** 拼一个绝对 URLscheme://host + suffix */
public static String buildServiceUrl(HttpServletRequest request, String suffix) {
return resolveScheme(request) + "://" + resolveHost(request) + suffix;
}
}

View File

@@ -17,6 +17,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
/**
@@ -54,21 +55,31 @@ public class CasUserDetailsService implements AuthenticationUserDetailsService<C
Wrappers.<User>lambdaQuery().eq(User::getCasUsername, casPrincipal).last("limit 1"));
if (user != null) {
log.info("CAS 命中 gpt_user (cas_username) id={}", user.getId());
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
return new UserDetail(vo, new HashSet<>());
return buildGptUserDetail(user);
}
user = userMapper.selectOne(
Wrappers.<User>lambdaQuery().eq(User::getTel, casPrincipal).last("limit 1"));
if (user != null) {
log.info("CAS 命中 gpt_user (tel fallback) id={}", user.getId());
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
return new UserDetail(vo, new HashSet<>());
return buildGptUserDetail(user);
}
log.warn("CAS 用户未在战知库中找到: {}", casPrincipal);
throw new UsernameNotFoundException("CAS 用户未绑定战知账号: " + casPrincipal);
}
/**
* 包装 gpt_user → UserDetail。
* 注意UserDetail(UserVO, Set) 构造器没初始化 authorities导致 getAuthorities() 返 null
* 而 CasAuthenticationToken 构造会校验 authorities 非 null不是非空 list→ IllegalArgumentException。
* 这里手动补一个空 list。
*/
private UserDetail buildGptUserDetail(User user) {
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
UserDetail ud = new UserDetail(vo, new HashSet<>());
ud.setAuthorities(new ArrayList<>());
return ud;
}
}

View File

@@ -92,17 +92,11 @@ xss:
# 词向量模型名称
embedding-model-name: bge_m3
# CAS 单点登录配置
# CAS 单点登录配置service URL 用 X-Forwarded-Host 动态拼,无需 front-end-url
security:
cas:
enabled: true
server-host: http://192.168.203.20:8180
server-login: ${security.cas.server-host}/login
server-logout: ${security.cas.server-host}/logout
app-login: /cas/login
app-logout: /cas/logout
app-key: 8UyEQdr6L6FfAH3D
app-secret: Ely1U6Uv2Nf8APQSfZdg1epSbQ6ORKwr
front-end-url: http://192.168.203.8:3000