Pārlūkot izejas kodu

单点登录 - 待验证

xy 3 nedēļas atpakaļ
vecāks
revīzija
50374f2a12

+ 194 - 93
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/controller/admin/cas/CasController.java

@@ -1,31 +1,45 @@
 package cn.start.tz.module.system.controller.admin.cas;
 
+import cn.hutool.core.util.IdUtil;
 import cn.start.tz.framework.common.pojo.CommonResult;
+import cn.start.tz.framework.security.config.SecurityProperties;
+import cn.start.tz.framework.security.core.util.SecurityFrameworkUtils;
 import cn.start.tz.module.system.controller.admin.auth.vo.AuthLoginRespVO;
 import cn.start.tz.module.system.dal.dataobject.user.AdminUserDO;
+import cn.start.tz.module.system.enums.logger.LoginLogTypeEnum;
 import cn.start.tz.module.system.framework.cas.config.CasProperties;
 import cn.start.tz.module.system.service.auth.AdminAuthService;
 import cn.start.tz.module.system.service.user.AdminUserService;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
 import jakarta.annotation.security.PermitAll;
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.catalina.session.StandardSessionFacade;
 import org.apereo.cas.client.validation.Assertion;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.web.bind.annotation.*;
 import org.springframework.web.util.UriComponentsBuilder;
 
 import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
 
 import static cn.start.tz.framework.common.pojo.CommonResult.success;
 
 /**
  * CAS 单点登录控制器
  * 处理 CAS 登录回调、用户信息获取等操作
+ * <p>
+ * 安全流程(Authorization Code 模式):
+ * 1. CAS 认证成功 → 回调 /caslogin
+ * 2. /caslogin 验证用户 → 创建 Token → 生成一次性授权码存 Redis → 重定向前端?code=xxx
+ * 3. 前端拿 code → 调 /mineInfo?code=xxx → 后端用 code 从 Redis 取出 Token → 删除 code → 返回 Token
+ * <p>
+ * 优势:code 一次性使用、短时效(30秒),即使被截获也无法复用
  *
  * @author tz
  */
@@ -35,6 +49,11 @@ import static cn.start.tz.framework.common.pojo.CommonResult.success;
 @RequestMapping("/system/cas")
 public class CasController {
 
+    /** Redis key 前缀,用于存储 CAS 一次性授权码 */
+    private static final String CAS_CODE_KEY_PREFIX = "cas:auth:code:";
+    /** 授权码有效期(秒) */
+    private static final long CODE_EXPIRE_SECONDS = 30;
+
     @Resource
     private CasProperties casProperties;
 
@@ -44,16 +63,21 @@ public class CasController {
     @Resource
     private AdminUserService adminUserService;
 
+    @Resource
+    private SecurityProperties securityProperties;
+
+    @Resource
+    private StringRedisTemplate stringRedisTemplate;
+
     /**
      * CAS 登录回调接口
-     * CAS 服务器认证成功后会回调此接口
-     * <p>
-     * 注意:CAS 回调地址应该是: https://cloud-admin-uat.gzsei.com/admin-api/system/cas/caslogin
+     * CAS 服务器认证成功后会回调此接口。
      * <p>
      * 此接口的职责:
-     * 1. CAS ValidationFilter 会自动验证 ticket 参数
-     * 2. 验证成功后,Assertion 会自动存入 Session
-     * 3. 此接口只需简单重定向到前端,前端会调用 /mineInfo 获取 token
+     * 1. CAS ValidationFilter 自动验证 ticket 参数,验证成功后 Assertion 存入 Session
+     * 2. 查询本地用户、创建 Token
+     * 3. 生成一次性授权码(code)存入 Redis,将 Token 绑定到 code
+     * 4. 重定向到前端页面,URL 中携带 code(不携带 token)
      *
      * @param request  HTTP 请求对象
      * @param response HTTP 响应对象
@@ -64,36 +88,21 @@ public class CasController {
     public void casCallback(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response) throws IOException {
         try {
             log.info("[casCallback] CAS 登录回调开始");
-            log.info("[casCallback] 请求URI: {}", request.getRequestURI());
-            log.info("[casCallback] 请求URL: {}", request.getRequestURL());
-            log.info("[casCallback] Session ID: {}", request.getSession().getId());
 
             // 1. 从 Session 中获取 CAS Assertion(由 CAS ValidationFilter 验证后存储)
             Assertion assertion = (Assertion) request.getSession().getAttribute("_const_cas_assertion_");
 
             if (assertion == null || assertion.getPrincipal() == null) {
-                log.error("[casCallback] ❌ CAS Assertion 为空,ticket 未通过验证");
-                log.error("[casCallback] Session 属性列表:");
-                java.util.Enumeration<String> attrNames = request.getSession().getAttributeNames();
-                while (attrNames.hasMoreElements()) {
-                    String attrName = attrNames.nextElement();
-                    log.error("[casCallback]   - {}: {}", attrName, request.getSession().getAttribute(attrName));
-                }
-                log.error("[casCallback] 这通常意味着 ValidationFilter 未能成功验证 ticket");
-                log.error("[casCallback] 可能的原因:");
-                log.error("[casCallback]   1. CAS 服务器返回验证失败(ticket 无效或已过期)");
-                log.error("[casCallback]   2. service 参数与 CAS 服务器注册的不一致");
-                log.error("[casCallback]   3. CAS 服务器地址配置错误");
-                log.error("[casCallback]   4. 网络连接问题,无法访问 CAS 服务器");
+                log.error("[casCallback] CAS Assertion 为空,ticket 未通过验证");
                 response.sendError(401, "CAS ticket验证失败,请检查CAS服务器配置和网络连接");
                 return;
             }
 
             // 2. 获取用户名
             String username = assertion.getPrincipal().getName();
-            log.info("[casCallback] CAS认证成功,用户名: {}", username);
+            log.info("[casCallback] CAS认证成功,用户名: {}", username);
 
-            // 3. 查询本地用户(仅验证用户是否存在)
+            // 3. 查询本地用户
             AdminUserDO user = adminUserService.getUserByUsername(username);
             if (user == null) {
                 log.warn("[casCallback] 用户不存在: {}", username);
@@ -108,22 +117,37 @@ public class CasController {
                 return;
             }
 
-            // 5. 手动设置 Session Cookie(解决跨域问题)
-            String sessionId = request.getSession().getId();
-            String cookieValue = String.format("JSESSIONID=%s; Path=/; HttpOnly; SameSite=None; Max-Age=3600", sessionId);
-            response.setHeader("Set-Cookie", cookieValue);
-            log.info("[casCallback] 手动设置 Cookie: {}", cookieValue);
+            // 5. 创建本地 Token
+            AuthLoginRespVO loginResp;
+            try {
+                cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.setLoginTypeContext(
+                        cn.start.tz.framework.security.core.LoginUser.LOGIN_TYPE_CAS);
+                loginResp = adminAuthService.createTokenAfterLoginSuccess(
+                        user.getId(), username,
+                        cn.start.tz.module.system.enums.logger.LoginLogTypeEnum.LOGIN_SOCIAL);
+                log.info("[casCallback] Token 创建成功, userId={}", user.getId());
+            } finally {
+                cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.clearLoginTypeContext();
+            }
 
-            // 6. 重定向到前端页面,携带 CAS 登录标识(前端根据此参数调用 /mineInfo 获取 token)
+            // 6. 生成一次性授权码,将 Token 信息绑定到 code 存入 Redis
+            String code = IdUtil.fastSimpleUUID();
+            String redisKey = CAS_CODE_KEY_PREFIX + code;
+            // 存储格式:accessToken,refreshToken,expiresTime(ISO格式)
+            String redisValue = loginResp.getAccessToken() + "," + loginResp.getRefreshToken()
+                    + "," + loginResp.getExpiresTime().toString() + "," + loginResp.getUserId();
+            stringRedisTemplate.opsForValue().set(redisKey, redisValue, CODE_EXPIRE_SECONDS, TimeUnit.SECONDS);
+            log.info("[casCallback] 授权码已生成, code={}, 有效期={}秒", code, CODE_EXPIRE_SECONDS);
+
+            // 7. 重定向到前端页面,只携带一次性 code(不暴露 token)
             String frontUrl = casProperties.getFrontUrl();
             String redirectUrl = UriComponentsBuilder.fromHttpUrl(frontUrl)
                     .queryParam("loginType", "cas")
+                    .queryParam("code", code)
                     .build()
                     .toUriString();
 
-            log.info("[casCallback] Session ID: {}", sessionId);
-            log.info("[casCallback] Session 属性 - _const_cas_assertion_: {}", request.getSession().getAttribute("_const_cas_assertion_"));
-            log.info("[casCallback] 重定向到前端页面: {}", redirectUrl);
+            log.info("[casCallback] 重定向到前端页面(携带授权码)");
             response.sendRedirect(redirectUrl);
 
         } catch (Exception e) {
@@ -133,80 +157,157 @@ public class CasController {
     }
 
     /**
-     * 获取当前用户信息并创建 Token
-     * 前端用于检测用户登录状态,如果已通过 CAS 认证则返回 token
+     * 通过一次性授权码换取 Token
+     * 前端在 CAS 回调后,用 URL 中的 code 参数调用此接口换取真正的 Token。
+     * <p>
+     * 安全特性:
+     * - code 一次性使用,读取后立即从 Redis 删除
+     * - code 有效期仅 30 秒,超时自动失效
+     * - code 无法反推出 token 内容
      *
-     * @param request HTTP 请求对象
-     * @return 登录结果(包含 token 和用户信息)或 401 未认证
+     * @param code  一次性授权码(由 /casCallback 生成)
      */
     @GetMapping("/mineInfo")
     @PermitAll
-    @Operation(summary = "获取当前 CAS 用户信息并创建 Token")
-    public CommonResult<Object> mineInfo(HttpServletRequest request) {
+    @Operation(summary = "通过授权码换取 CAS 登录 Token")
+    @Parameter(name = "code", description = "一次性授权码", required = true)
+    public CommonResult<Object> mineInfo(@RequestParam("code") String code) {
         try {
-            log.info("[mineInfo] ========== 开始获取当前用户信息 ==========");
-            log.info("[mineInfo] 请求URI: {}", request.getRequestURI());
-            log.info("[mineInfo] Session ID: {}", request.getSession().getId());
-            log.info("[mineInfo] Session 是否 isNew: {}", request.getSession().isNew());
-            log.info("[mineInfo] Session 属性列表:");
-            java.util.Enumeration<String> attrNames = request.getSession().getAttributeNames();
-            while (attrNames.hasMoreElements()) {
-                String attrName = attrNames.nextElement();
-                log.info("[mineInfo]   - {}: {}", attrName, request.getSession().getAttribute(attrName));
+            if (code == null || code.isBlank()) {
+                return CommonResult.error(701, "授权码不能为空",
+                        casProperties.getServerUrlPrefix() + "/login?service=" + casProperties.getClientHostUrl() + "&secret=" + casProperties.getSecret());
             }
 
-            // 1. 首先检查 CAS Session
-            Assertion assertion = (Assertion) request.getSession().getAttribute("_const_cas_assertion_");
-            if (assertion == null || assertion.getPrincipal() == null) {
-                log.error("[mineInfo] ❌ 未获取到 CAS 认证信息");
-                log.error("[mineInfo] assertion = {}, principal = {}", assertion, assertion != null ? assertion.getPrincipal() : null);
-                return CommonResult.error(701, "未登录,请先登录",
-                        casProperties.getServerUrlPrefix() + "/login?service=" + casProperties.getClientHostUrl());
-            }
+            // 1. 从 Redis 中用 code 取出 Token 信息,并立即删除(一次性使用)
+            String redisKey = CAS_CODE_KEY_PREFIX + code;
+            String redisValue = stringRedisTemplate.opsForValue().getAndDelete(redisKey);
 
-            // 2. 获取 CAS 认证的用户名
-            String username = assertion.getPrincipal().getName();
-            log.info("[mineInfo] 获取到 CAS 认证用户名: {}", username);
-
-            // 3. 查询本地用户信息
-            AdminUserDO user = adminUserService.getUserByUsername(username);
-            if (user == null) {
-                log.warn("[mineInfo] 本地用户不存在: {}", username);
-                return CommonResult.error(704, "用户不存在,请联系管理员创建账号");
+            if (redisValue == null) {
+                log.warn("[mineInfo] 授权码无效或已过期, code={}", code);
+                return CommonResult.error(701, "授权码无效或已过期,请重新登录",
+                        casProperties.getServerUrlPrefix() + "/login?service=" + casProperties.getClientHostUrl() + "&secret=" + casProperties.getSecret());
             }
 
-            // 4. 检查用户状态
-            if (user.getStatus() == null || user.getStatus() != 0) {
-                log.warn("[mineInfo] 用户已被禁用: {}", username);
-                return CommonResult.error(703, "用户已被禁用");
+            // 2. 解析 Token 信息
+            String[] parts = redisValue.split(",", 4);
+            if (parts.length != 4) {
+                log.error("[mineInfo] 授权码数据格式异常, code={}", code);
+                return CommonResult.error(700, "授权码数据格式异常");
             }
 
-            // 5. 创建本地 Token(如果 Session 中还没有 Token)
-            AuthLoginRespVO loginResp;
-            try {
-                // 设置登录类型为 CAS
-                cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.setLoginTypeContext(
-                        cn.start.tz.framework.security.core.LoginUser.LOGIN_TYPE_CAS);
-
-                loginResp = adminAuthService.createTokenAfterLoginSuccess(
-                        user.getId(),
-                        username,
-                        cn.start.tz.module.system.enums.logger.LoginLogTypeEnum.LOGIN_SOCIAL
-                );
-
-                log.info("[mineInfo] 成功获取用户信息并创建 Token: username={}, userId={}, token={}",
-                        user.getUsername(), user.getId(), loginResp.getAccessToken());
-            } finally {
-                // 清除 ThreadLocal,避免内存泄漏
-                cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.clearLoginTypeContext();
-            }
+            // 3. 构建返回结果
+            AuthLoginRespVO loginResp = new AuthLoginRespVO();
+            loginResp.setAccessToken(parts[0]);
+            loginResp.setRefreshToken(parts[1]);
+            loginResp.setExpiresTime(LocalDateTime.parse(parts[2]));
+            loginResp.setUserId(parts[3]);
+            loginResp.setLoginType("cas");
 
-            // 6. 返回登录结果(包含 token 和用户信息)
+            log.info("[mineInfo] 授权码换 Token 成功");
             return success(loginResp);
 
         } catch (Exception e) {
-            log.error("[mineInfo] 获取用户信息异常", e);
+            log.error("[mineInfo] 换取 Token 异常", e);
             return CommonResult.error(700, "获取用户信息异常: " + e.getMessage());
         }
     }
+
+    /**
+     * CAS 退出登录
+     * 同时清除:1. CAS Session 中的 Assertion  2. 本地系统的 Token(支持请求头带 token 和不带 token 两种场景)
+     *
+     * @param request HTTP 请求对象
+     * @return CAS 服务器登出 URL,前端需重定向到该地址完成 CAS 全局登出
+     */
+    @PostMapping("/logout")
+    @PermitAll
+    @Operation(summary = "CAS 退出登录")
+    public CommonResult<String> logout(HttpServletRequest request) {
+        log.info("[casLogout] 开始 CAS 退出登录");
+
+        // 0. 前置校验:必须存在 token 或 CAS Assertion,否则拒绝
+        String token = SecurityFrameworkUtils.obtainAuthorization(request,
+                securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
+        HttpSession session = request.getSession(false);
+        Assertion assertion = session != null
+                ? (Assertion) session.getAttribute("_const_cas_assertion_") : null;
+        if (token == null && assertion == null) {
+            log.warn("[casLogout] 未登录,无需退出");
+            return CommonResult.error(401, "未登录,无需退出");
+        }
+
+        // 1. 清除 CAS Session 中的 Assertion
+        String username = null;
+        try {
+            if (session != null) {
+                StandardSessionFacade ssf = (StandardSessionFacade) session;
+                if (assertion != null && assertion.getPrincipal() != null) {
+                    username = assertion.getPrincipal().getName();
+                }
+                ssf.removeAttribute("_const_cas_assertion_");
+                log.info("[casLogout] CAS Assertion 已清除, username={}", username);
+            }
+        } catch (Exception e) {
+            log.warn("[casLogout] 清除 CAS Session 异常: {}", e.getMessage());
+        }
+
+        // 2. 清除本地系统 Token(两种策略,复用步骤0中已获取的 token)
+        if (token != null) {
+            adminAuthService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType(), request);
+            log.info("[casLogout] 通过请求头 token 清除本地登录信息");
+        } else if (username != null) {
+            // 2.2 统一平台请求没有携带 token,通过 CAS 用户名查到 userId 后清除所有有效 token
+            AdminUserDO user = adminUserService.getUserByUsername(username);
+            if (user != null) {
+                adminAuthService.logoutByUserId(user.getId(), LoginLogTypeEnum.LOGOUT_SELF.getType());
+                log.info("[casLogout] 通过 CAS 用户名({})/userId({}) 清除本地登录信息", username, user.getId());
+            } else {
+                log.warn("[casLogout] CAS 用户名({})在本地系统中不存在", username);
+            }
+        } else {
+            log.warn("[casLogout] 无法获取 token 或 CAS 用户名,跳过本地 Token 清除");
+        }
+        log.info("[casLogout] 退出登录完成");
+        return success("注销成功");
+    }
+
+    // ==================== 测试接口(上线前删除) ====================
+
+    /**
+     * 【测试接口】模拟生成授权码,用于调试 /mineInfo 接口
+     * 调用方式:GET /admin-api/system/cas/test-generate-code?username=xxx
+     * 返回 code 后,30秒内调用:GET /admin-api/system/cas/mineInfo?code=返回的code
+     */
+    @GetMapping("/test-generate-code")
+    @Operation(summary = "【测试】模拟生成授权码,用于调试 mineInfo 接口")
+    public CommonResult<String> testGenerateCode(@RequestParam("username") String username) {
+        // 1. 查询本地用户
+        AdminUserDO user = adminUserService.getUserByUsername(username);
+        if (user == null) {
+            return CommonResult.error(404, "用户不存在: " + username);
+        }
+
+        // 2. 创建 Token
+        AuthLoginRespVO loginResp;
+        try {
+            cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.setLoginTypeContext(
+                    cn.start.tz.framework.security.core.LoginUser.LOGIN_TYPE_CAS);
+            loginResp = adminAuthService.createTokenAfterLoginSuccess(
+                    user.getId(), username,
+                    cn.start.tz.module.system.enums.logger.LoginLogTypeEnum.LOGIN_SOCIAL);
+        } finally {
+            cn.start.tz.module.system.service.oauth2.OAuth2TokenServiceImpl.clearLoginTypeContext();
+        }
+
+        // 3. 生成授权码存入 Redis(调试用,有效期 120 秒)
+        String code = IdUtil.fastSimpleUUID();
+        String redisKey = CAS_CODE_KEY_PREFIX + code;
+        String redisValue = loginResp.getAccessToken() + "," + loginResp.getRefreshToken()
+                + "," + loginResp.getExpiresTime().toString() + "," + loginResp.getUserId();
+        stringRedisTemplate.opsForValue().set(redisKey, redisValue, 120, TimeUnit.SECONDS);
+
+        log.info("[testGenerateCode] 测试授权码已生成, username={}, code={}, 有效期=120秒", username, code);
+        return success("code=" + code + ",有效期120秒,请尽快调用 /mineInfo?code=" + code);
+    }
+
 }

+ 9 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java

@@ -23,6 +23,15 @@ public interface OAuth2AccessTokenMapper extends BaseMapperX<OAuth2AccessTokenDO
         return selectList(OAuth2AccessTokenDO::getRefreshToken, refreshToken);
     }
 
+    /**
+     * 查询指定用户所有有效的(未过期的)访问令牌
+     */
+    default List<OAuth2AccessTokenDO> selectValidListByUserId(String userId) {
+        return selectList(new LambdaQueryWrapperX<OAuth2AccessTokenDO>()
+                .eq(OAuth2AccessTokenDO::getUserId, userId)
+                .gt(OAuth2AccessTokenDO::getExpiresTime, LocalDateTime.now()));
+    }
+
     default PageResult<OAuth2AccessTokenDO> selectPage(OAuth2AccessTokenPageReqVO reqVO) {
         return selectPage(reqVO, new LambdaQueryWrapperX<OAuth2AccessTokenDO>()
                 .eqIfPresent(OAuth2AccessTokenDO::getUserId, reqVO.getUserId())

+ 8 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java

@@ -14,6 +14,14 @@ public interface OAuth2RefreshTokenMapper extends BaseMapperX<OAuth2RefreshToken
                 .eq(OAuth2RefreshTokenDO::getRefreshToken, refreshToken));
     }
 
+    /**
+     * 根据 userId 删除所有刷新令牌
+     */
+    default int deleteByUserId(String userId) {
+        return delete(new LambdaQueryWrapperX<OAuth2RefreshTokenDO>()
+                .eq(OAuth2RefreshTokenDO::getUserId, userId));
+    }
+
     @TenantIgnore // 获取 token 的时候,需要忽略租户编号。原因是:一些场景下,可能不会传递 tenant-id 请求头,例如说文件上传、积木报表等等
     default OAuth2RefreshTokenDO selectByRefreshToken(String refreshToken) {
         return selectOne(OAuth2RefreshTokenDO::getRefreshToken, refreshToken);

+ 114 - 13
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/framework/cas/config/CasConfiguration.java

@@ -2,18 +2,26 @@ package cn.start.tz.module.system.framework.cas.config;
 
 import cn.start.tz.module.system.framework.cas.filter.AuthenticationFilterExtended;
 import jakarta.annotation.Resource;
+import jakarta.annotation.security.PermitAll;
 import lombok.extern.slf4j.Slf4j;
 import org.apereo.cas.client.session.SingleSignOutFilter;
 import org.apereo.cas.client.session.SingleSignOutHttpSessionListener;
 import org.apereo.cas.client.util.AssertionThreadLocalFilter;
 import org.apereo.cas.client.util.HttpServletRequestWrapperFilter;
 import org.apereo.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter;
+import org.springframework.beans.factory.SmartInitializingSingleton;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.DependsOn;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
 
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * CAS 单点登录配置类
@@ -24,11 +32,19 @@ import java.util.List;
  */
 @Slf4j
 @Configuration
-public class CasConfiguration {
+public class CasConfiguration implements SmartInitializingSingleton {
 
     @Resource
     private CasProperties casProperties;
 
+    @Resource
+    private RequestMappingHandlerMapping requestMappingHandlerMapping;
+
+    /**
+     * 保持对 AuthenticationFilterExtended 的引用,用于启动后动态追加白名单
+     */
+    private AuthenticationFilterExtended authenticationFilter;
+
     /**
      * CAS Session 监听器
      * 监听用户 Session 的创建和销毁事件,配合单点登出功能使用
@@ -48,10 +64,11 @@ public class CasConfiguration {
     public FilterRegistrationBean<SingleSignOutFilter> singleSignOutFilter() {
         FilterRegistrationBean<SingleSignOutFilter> registration = new FilterRegistrationBean<>();
         registration.setFilter(new SingleSignOutFilter());
-        registration.addUrlPatterns("/*");
+        registration.setUrlPatterns(casProperties.getUrlPatternsAsArray());
         registration.setEnabled(casProperties.getEnabled() && casProperties.isConfigured());
         registration.setOrder(10);
-        log.info("CAS Single Sign Out Filter 初始化, enabled: {}", registration.isEnabled());
+        log.info("CAS Single Sign Out Filter 初始化, enabled: {}, urlPatterns: {}",
+                registration.isEnabled(), casProperties.getUrlPatternsAsArray());
         return registration;
     }
 
@@ -64,6 +81,7 @@ public class CasConfiguration {
     public FilterRegistrationBean<AuthenticationFilterExtended> casAuthenticationFilter() {
         // 创建过滤器实例(使用默认构造函数)
         AuthenticationFilterExtended filter = new AuthenticationFilterExtended();
+        this.authenticationFilter = filter; // 保存引用,用于后续 @PermitAll 扫描
 
         // 设置白名单路径
         List<String> ignorePatterns = casProperties.getIgnoreUrlListAsArray();
@@ -73,10 +91,18 @@ public class CasConfiguration {
         FilterRegistrationBean<AuthenticationFilterExtended> registration = new FilterRegistrationBean<>();
         registration.setFilter(filter);
 
-        // 配置CAS服务器登录地址
-        String casServerLoginUrl = casProperties.getServerUrlPrefix() + "/login";
-        registration.addInitParameter("casServerLoginUrl", casServerLoginUrl);
-        log.info("配置 CAS 服务器登录地址: {}", casServerLoginUrl);
+        // 配置CAS服务器登录地址(携带 secret 参数,CAS Client 会自动用 & 追加 service)
+        String serverUrlPrefix = casProperties.getServerUrlPrefix();
+        if (serverUrlPrefix != null) {
+            StringBuilder loginUrlBuilder = new StringBuilder(serverUrlPrefix)
+                    .append("/login");
+            if (casProperties.getSecret() != null && !casProperties.getSecret().trim().isEmpty()) {
+                loginUrlBuilder.append("?secret=").append(casProperties.getSecret());
+            }
+            String casServerLoginUrl = loginUrlBuilder.toString();
+            registration.addInitParameter("casServerLoginUrl", casServerLoginUrl);
+            log.info("配置 CAS 服务器登录地址: {}", casServerLoginUrl);
+        }
 
         // 使用 serverName(会动态拼接请求路径)
         String serverName = casProperties.getClientHostUrl();
@@ -85,7 +111,7 @@ public class CasConfiguration {
             log.info("配置 serverName: {} (动态拼接)", serverName);
         }
 
-        registration.addUrlPatterns("/*");
+        registration.setUrlPatterns(casProperties.getUrlPatternsAsArray());
         registration.setOrder(20);
 
         // 检查 CAS 是否启用且配置完整
@@ -122,7 +148,7 @@ public class CasConfiguration {
             log.info("配置 ValidationFilter serverName: {} (动态拼接)", serverName);
         }
 
-        registration.addUrlPatterns("/*");
+        registration.setUrlPatterns(casProperties.getUrlPatternsAsArray());
         registration.setOrder(30);
 
         // 检查 CAS 是否启用且配置完整
@@ -147,10 +173,11 @@ public class CasConfiguration {
     public FilterRegistrationBean<HttpServletRequestWrapperFilter> casHttpServletRequestWrapperFilter() {
         FilterRegistrationBean<HttpServletRequestWrapperFilter> registration = new FilterRegistrationBean<>();
         registration.setFilter(new HttpServletRequestWrapperFilter());
-        registration.addUrlPatterns("/*");
+        registration.setUrlPatterns(casProperties.getUrlPatternsAsArray());
         registration.setEnabled(casProperties.getEnabled() && casProperties.isConfigured());
         registration.setOrder(40);
-        log.info("CAS HttpServletRequest Wrapper Filter 初始化, enabled: {}", registration.isEnabled());
+        log.info("CAS HttpServletRequest Wrapper Filter 初始化, enabled: {}, urlPatterns: {}",
+                registration.isEnabled(), casProperties.getUrlPatternsAsArray());
         return registration;
     }
 
@@ -162,10 +189,84 @@ public class CasConfiguration {
     public FilterRegistrationBean<AssertionThreadLocalFilter> casAssertionThreadLocalFilter() {
         FilterRegistrationBean<AssertionThreadLocalFilter> registration = new FilterRegistrationBean<>();
         registration.setFilter(new AssertionThreadLocalFilter());
-        registration.addUrlPatterns("/*");
+        registration.setUrlPatterns(casProperties.getUrlPatternsAsArray());
         registration.setEnabled(casProperties.getEnabled() && casProperties.isConfigured());
         registration.setOrder(50);
-        log.info("CAS Assertion ThreadLocal Filter 初始化, enabled: {}", registration.isEnabled());
+        log.info("CAS Assertion ThreadLocal Filter 初始化, enabled: {}, urlPatterns: {}",
+                registration.isEnabled(), casProperties.getUrlPatternsAsArray());
         return registration;
     }
+
+    /**
+     * 所有 Bean 初始化完成后执行
+     * 扫描 @PermitAll 注解的 Controller 方法,自动加入 CAS 白名单
+     */
+    @Override
+    public void afterSingletonsInstantiated() {
+        if (authenticationFilter == null || !casProperties.getEnabled() || !casProperties.isConfigured()) {
+            return;
+        }
+        List<String> permitAllPatterns = scanPermitAllPatterns();
+        if (!permitAllPatterns.isEmpty()) {
+            authenticationFilter.addIgnorePatterns(permitAllPatterns);
+            log.info("CAS 自动扫描 @PermitAll 路径并加入白名单: {}", permitAllPatterns);
+        }
+    }
+
+    /**
+     * 扫描所有带 @PermitAll 注解的 Controller 方法的 URL 路径
+     * 只收集落在 CAS url-patterns 范围内的路径(如 /admin-api/**)
+     *
+     * @return @PermitAll 标注的且在 CAS 拦截范围内的路径列表
+     */
+    private List<String> scanPermitAllPatterns() {
+        List<String> result = new ArrayList<>();
+        List<String> casPrefixes = extractCasPrefixes();
+
+        Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
+        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethods.entrySet()) {
+            HandlerMethod handlerMethod = entry.getValue();
+            // 检查方法级别或类级别的 @PermitAll 注解
+            boolean hasPermitAll = handlerMethod.hasMethodAnnotation(PermitAll.class);
+            if (!hasPermitAll) {
+                hasPermitAll = handlerMethod.getBeanType().isAnnotationPresent(PermitAll.class);
+            }
+            if (!hasPermitAll) {
+                continue;
+            }
+
+            // 获取该方法的 URL 路径模式(已包含 /admin-api 等前缀)
+            Set<String> patternValues = entry.getKey().getPatternValues();
+            for (String pattern : patternValues) {
+                // 只收集落在 CAS 拦截范围内的路径
+                for (String prefix : casPrefixes) {
+                    if (pattern.startsWith(prefix)) {
+                        // 将 Spring PathPattern 的 {xxx} 变量转换为 AntPathMatcher 的 * 通配符
+                        // 例如 /admin-api/system/user/{id} -> /admin-api/system/user/*
+                        String antPattern = pattern.replaceAll("\\{[^}]+}", "*");
+                        result.add(antPattern);
+                        break;
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 从 CAS url-patterns 中提取路径前缀
+     * 例如 url-patterns="/admin-api/*" -> 提取出 "/admin-api/"
+     *
+     * @return 路径前缀列表
+     */
+    private List<String> extractCasPrefixes() {
+        List<String> prefixes = new ArrayList<>();
+        for (String pattern : casProperties.getUrlPatternsAsArray()) {
+            String prefix = pattern.replace("/*", "").replace("/**", "");
+            if (!prefix.isEmpty()) {
+                prefixes.add(prefix + "/");
+            }
+        }
+        return prefixes;
+    }
 }

+ 39 - 14
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/framework/cas/config/CasProperties.java

@@ -45,10 +45,9 @@ public class CasProperties {
     private String clientHostUrl;
 
     /**
-     * CAS 客户端服务地址
-     * 示例: http://192.168.0.131/dev-api/protocols/cas20
+     * 应用秘钥
      */
-    private String clientServiceUrl;
+    private String secret;
 
     /**
      * 是否启用 CAS 认证
@@ -56,19 +55,48 @@ public class CasProperties {
      */
     private Boolean enabled = true;
 
+    /**
+     * CAS 过滤器拦截的 URL 路径模式列表(逗号分隔)
+     * 只对这些路径启用 CAS 过滤器,其他路径完全不受 CAS 影响
+     * 默认: /admin-api/*(只拦截后台管理系统)
+     * 示例: /admin-api/*,/system/cas/*
+     */
+    private String urlPatterns = "/admin-api/*";
+
     /**
      * 获取忽略地址列表数组
      *
      * @return 忽略地址数组
      */
     public List<String> getIgnoreUrlListAsArray() {
+        return splitCommaSeparated(ignoreUrlList);
+    }
+
+    /**
+     * 获取 URL 拦截路径模式数组
+     * 如果未配置,默认返回 ["/admin-api/*"]
+     *
+     * @return URL 路径模式数组
+     */
+    public List<String> getUrlPatternsAsArray() {
+        List<String> patterns = splitCommaSeparated(urlPatterns);
+        if (patterns.isEmpty()) {
+            patterns.add("/admin-api/*");
+        }
+        return patterns;
+    }
+
+    /**
+     * 将逗号分隔的字符串拆分为列表
+     */
+    private List<String> splitCommaSeparated(String value) {
         List<String> list = new ArrayList<>();
-        if (ignoreUrlList != null && !ignoreUrlList.trim().isEmpty()) {
-            String[] urls = ignoreUrlList.split(",");
-            for (String url : urls) {
-                String trimmedUrl = url.trim();
-                if (!trimmedUrl.isEmpty()) {
-                    list.add(trimmedUrl);
+        if (value != null && !value.trim().isEmpty()) {
+            String[] items = value.split(",");
+            for (String item : items) {
+                String trimmed = item.trim();
+                if (!trimmed.isEmpty()) {
+                    list.add(trimmed);
                 }
             }
         }
@@ -82,7 +110,7 @@ public class CasProperties {
      */
     public boolean isConfigured() {
         boolean configured = serverUrlPrefix != null && !serverUrlPrefix.trim().isEmpty()
-                && (clientServiceUrl != null || clientHostUrl != null);
+                && clientHostUrl != null;
         if (!configured && enabled) {
             log.warn("CAS 启用但配置不完整。需要配置: server-url-prefix 和 (service 或 client-service-name 或 client-service-url 或 client-host-url)");
         }
@@ -91,14 +119,11 @@ public class CasProperties {
 
     /**
      * 获取有效的客户端服务地址
-     * 优先级: service > clientServiceUrl > clientServiceName > clientHostUrl
+     * clientHostUrl
      *
      * @return 客户端服务地址
      */
     public String getEffectiveServiceUrl() {
-        if (clientServiceUrl != null && !clientServiceUrl.trim().isEmpty()) {
-            return clientServiceUrl;
-        }
         if (clientHostUrl != null && !clientHostUrl.trim().isEmpty()) {
             return clientHostUrl;
         }

+ 18 - 2
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/framework/cas/filter/AuthenticationFilterExtended.java

@@ -14,6 +14,7 @@ import org.springframework.util.AntPathMatcher;
 import org.springframework.util.PathMatcher;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -47,8 +48,23 @@ public class AuthenticationFilterExtended implements Filter {
      * @param ignorePatterns 白名单路径列表
      */
     public void setIgnorePatterns(List<String> ignorePatterns) {
-        this.ignorePatterns = ignorePatterns;
-        log.info("AuthenticationFilterExtended 设置白名单: {}", ignorePatterns);
+        this.ignorePatterns = ignorePatterns != null ? new ArrayList<>(ignorePatterns) : new ArrayList<>();
+        log.info("AuthenticationFilterExtended 设置白名单: {}", this.ignorePatterns);
+    }
+
+    /**
+     * 动态追加白名单路径(如启动时扫描 @PermitAll 注解的结果)
+     * @param additionalPatterns 需要追加的路径列表
+     */
+    public void addIgnorePatterns(List<String> additionalPatterns) {
+        if (additionalPatterns == null || additionalPatterns.isEmpty()) {
+            return;
+        }
+        if (this.ignorePatterns == null) {
+            this.ignorePatterns = new ArrayList<>();
+        }
+        this.ignorePatterns.addAll(additionalPatterns);
+        log.info("AuthenticationFilterExtended 动态追加白名单: {}", additionalPatterns);
     }
 
     @Override

+ 9 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/service/auth/AdminAuthService.java

@@ -53,6 +53,15 @@ public interface AdminAuthService {
      */
     String logout(String token, Integer logType, HttpServletRequest request);
 
+    /**
+     * 基于 userId 退出登录(清除该用户所有有效 Token)
+     * 适用于统一平台 CAS 登出场景:请求中没有 token,但能通过 CAS Assertion 拿到用户名
+     *
+     * @param userId 用户ID
+     * @param logType 登出类型
+     */
+    void logoutByUserId(String userId, Integer logType);
+
     /**
      * 短信验证码发送
      *

+ 10 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/service/auth/AdminAuthServiceImpl.java

@@ -524,6 +524,16 @@ public class AdminAuthServiceImpl implements AdminAuthService {
         loginLogService.createLoginLog(reqDTO);
     }
 
+    @Override
+    public void logoutByUserId(String userId, Integer logType) {
+        log.info("[logoutByUserId] 开始按 userId 清除登录信息, userId={}", userId);
+        // 通过 tokenService 按 userId 删除所有有效 token
+        oauth2TokenService.removeAccessTokenByUserId(userId);
+        // 记录登出日志
+        createLogoutLog(userId, getUserType().getValue(), logType);
+        log.info("[logoutByUserId] userId={} 登出完成", userId);
+    }
+
     private String getUsername(String userId) {
         if (userId == null) {
             return null;

+ 8 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/service/oauth2/OAuth2TokenService.java

@@ -73,6 +73,14 @@ public interface OAuth2TokenService {
      */
     OAuth2AccessTokenDO removeAccessToken(String accessToken);
 
+    /**
+     * 根据 userId 移除该用户所有有效的访问令牌和刷新令牌
+     * 适用于 CAS 统一平台登出场景
+     *
+     * @param userId 用户ID
+     */
+    void removeAccessTokenByUserId(String userId);
+
     /**
      * 获得访问令牌分页
      *

+ 16 - 0
tz-module-system/tz-module-system-biz/src/main/java/cn/start/tz/module/system/service/oauth2/OAuth2TokenServiceImpl.java

@@ -217,6 +217,22 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
         return accessTokenDO;
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void removeAccessTokenByUserId(String userId) {
+        // 1. 查询该用户所有有效的访问令牌
+        List<OAuth2AccessTokenDO> accessTokenDOs = oauth2AccessTokenMapper.selectValidListByUserId(userId);
+        if (CollUtil.isEmpty(accessTokenDOs)) {
+            return;
+        }
+        // 2. 删除访问令牌(DB)
+        oauth2AccessTokenMapper.deleteByIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
+        // 3. 删除访问令牌(Redis)
+        oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
+        // 4. 删除该用户所有刷新令牌(DB)
+        oauth2RefreshTokenMapper.deleteByUserId(userId);
+    }
+
     @Override
     public PageResult<OAuth2AccessTokenDO> getAccessTokenPage(OAuth2AccessTokenPageReqVO reqVO) {
         return oauth2AccessTokenMapper.selectPage(reqVO);