ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CAS 5.3单点登录改造实战:从overlay到MySQL与AJAX登录

CAS 5.3单点登录改造实战:从overlay到MySQL与AJAX登录

上个月接手一套遗留系统的登录模块改造,权限中心的认证服务端还是 CAS 5.3。看到版本号那一刻我第一反应是头大——现在搜“CAS 配置”出来的大多是 6.x、7.x 的玩法,5.3 的官方文档都已经躺进 legacy 目录了。但在企业里这种“老但稳定”的版本存量非常大,特别是已经接入十几个子系统的认证中心,没人敢为了追新随便动地基。这篇东西就是我改造过程的完整记录:覆盖 war overlay、链接 MySQL、自定义密码加密、登录页面替换、认证校验、自定义异常映射,以及很多人问的 AJAX 登录。如果你正好被 CAS 5.3 的历史包袱绊住脚,或者只是想搞明白 SSO 服务端内部到底怎么流转,这篇文章可以直接照着抄。

CAS 5.3 最大的特点,是它基于 Spring Boot 2.0 的配置体系,但又保留了从 4.x 继承下来的 Spring Web Flow 登录流程。这意味着你既可以用application.properties管理数据源、服务注册这些底层配置,又要跟 WebFlow 的execution参数、Thymeleaf 模板这些“上古”概念打交道。版本虽然老,但它内部的分层其实一点不乱,搞懂这一版之后,迁移到 6.x 的成本也没有想象中高。

1. 为什么还在折腾 CAS 5.3:先弄清楚版本背后的配置语系

1.1 5.3 的“双配置”结构和后续版本差在哪

CAS 5.3 服务端的代码组织方式,本质是 Maven 的 war overlay:你下载官方 war 包作为依赖,自己工程里的文件可以“覆盖”到 war 包里同名文件上。这跟我见过不少团队直接用cas.war丢进 Tomcat 再改内部配置文件的方式完全不同——后一种做法一旦升级版本就全丢了,overlay 才能在保留自研代码的同时跟上主版本。

配置层面,5.3 同时存在cas.properties和application.properties。前者放cas.*前缀的 CAS 自身属性,后者放 Spring Boot 层的属性,比如数据源、server.port。但在实际运行时,Spring Boot 配置中心会把两者一起加载,所以你也可以把所有内容塞进application.properties,只要注意cas.前缀属性从哪个配置文件读都行。

到了 CAS 6.x,官方把默认构建方式换成了 Gradle,且很多属性名做了统一调整。如果你只看 6.x 的教程来操作 5.3,有几个关键点会对不上:比如 5.3 里 JDBC 认证的前缀是cas.authn.jdbc.query[0],6.x 已经改成带password-policy等更细粒度的结构。所以本文所有配置都以 5.3.14 为准,这也是 5.3 系列最后的补丁版本。

1.2 标题里六件事的依赖顺序

标题列出的“链接 MySQL、自定义密码加密、登录页面、登录校验、自定义异常、AJAX 登录”,其实是服务端认证能力从底层到上层的递进关系。先把数据源接好,才有用户数据的来源;密码加密策略不对,数据库里存的哈希永远是摆设;登录页面是用户直接接触的入口;登录校验的结果要能区分“密码错误”“账号锁定”这些状态,就需要自定义异常参与;最后前端如果不想整页刷新,就得提供 AJAX 友好的接口。

这个顺序也对应本文的章节顺序。后面几节我不会按官方文档的目录抄,而是按我在真实项目里踩坑的顺序来写:先跑起来,再连库,再改界面,再调逻辑,最后处理前后端交互。

2. 环境准备与 war 包 overlay:让 CAS 先跑起来再谈改造

2.1 JDK、Servlet 容器、构建工具的版本组合

CAS 5.3 的编译目标是 JDK 8,这一点必须当成硬性约束。我见过有人图省事直接用 JDK 11 启动,结果汤姆猫直接抛UnsupportedClassVersionError,排查半天才发现是 JDK 版本太新。所以本机至少装 JDK 8,建议是 8u201 之后的版本,安全性好一些。

Servlet 容器方面,官方支持 Tomcat 8.5/9.0,或者 Jetty 9.x。我用的是 Tomcat 9.0,注意 CAS 5.3 的 WebFlow 对 session 依赖很强,容器切到轻量级 Jetty 也能跑,但没必要在生产环境玩花活。构建工具用 Maven 3.5+,Gradle 虽然可以,但 5.3 的官方 overlay 历史模板以 Maven 居多,社区案例也多,选 Maven 省心。

2.2 overlay 工程的最简结构

新建一个空的 Maven 工程,packaging设为war,然后引入 CAS 的 webapp 依赖:

<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>cas-overlay</artifactId> <version>1.0.0</version> <packaging>war</packaging> <properties> <cas.version>5.3.14</cas.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-webapp-tomcat</artifactId> <version>${cas.version}</version> <type>war</type> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.11</version> </dependency> </dependencies> </project>

构建时执行mvn clean package,生成目标 war 后扔到 Tomcat 的webapps目录下。这里有个容易忽略的点:war 解压后如果访问路径是/cas,那所有 CAS 的请求地址都要带/cas前缀,比如登录地址是http://localhost:8080/cas/login。

2.3 先验证默认登录,再动配置

刚部署完最干净的 CAS 5.3,如果你还没配置任何认证源,它会提供一个内置的演示账号:用户名casuser,密码Mellon。访问/cas/login能看到经典蓝白登录页,用这个账号能直接登录。

这个默认账号存在的意义,就是让你在“什么都没配”的情况下先确认环境是通的。很多初学者一上来就改数据库配置,结果分不清是环境问题还是配置问题,排除链路就被拉长了。我的习惯是:先默认跑通,再加数据源,再关掉默认账号,每一步都可回滚。

3. 链接 MySQL:数据源配置与用户表设计

3.1 用户表需要什么字段

CAS 的 JDBC 认证并不强制你使用它自带的 schema,你完全可以拿现有的用户表。但有一个前提:这张表必须能根据用户名唯一查出“密码字段”和“状态字段”。我习惯建一张这样的表:

CREATE DATABASE sso_db DEFAULT CHARACTER SET utf8mb4; USE sso_db; CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

password字段的长度建议留到 128,不要用 32。因为你今天可能是 MD5,明天想换成带盐的 SHA-256,长度不够就得改表结构,这是我没少吃过的亏。

3.2 application.properties 里的 JDBC 认证配置

把下面的配置加到src/main/resources/application.properties:

server.port=8080 server.servlet.context-path=/cas # 数据源 cas.authn.jdbc.query[0].url=jdbc:mysql://127.0.0.1:3306/sso_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai cas.authn.jdbc.query[0].user=sso_user cas.authn.jdbc.query[0].password=sso_pass cas.authn.jdbc.query[0].driverClass=com.mysql.cj.jdbc.Driver # 查询语句,? 是用户名占位符 cas.authn.jdbc.query[0].sql=SELECT id, username, password, status FROM sys_user WHERE username = ? AND status = 1 # 返回字段映射 cas.authn.jdbc.query[0].fieldMap.username=username cas.authn.jdbc.query[0].fieldMap.password=password cas.authn.jdbc.query[0].fieldMap.status=status

注意这里的前缀是cas.authn.jdbc.query[0]。[0]是数据源索引,你可以配置多个数据源,比如一个查用户、一个查管理员。SQL 里的?是 CAS 预编译语句的位置占位符,用来绑定用户输入的用户名。fieldMap的作用是把查询结果集的列名映射到 CAS 内部用户凭证的属性上,如果列名和属性名一致,理论上可以省略,但保留出来的话排查问题会直观很多。

3.3 驱动、时区、SSL 这些隐形坑

MySQL 8 的连接驱动类名是com.mysql.cj.jdbc.Driver,MySQL 5.x 是com.mysql.jdbc.Driver。如果你用的驱动版本和数据库服务端版本不一致,最常见的是Communications link failure。另外连接串里的serverTimezone=Asia/Shanghai必须加上,否则 MySQL 8 默认时区和 JVM 时区不一致,CAS 初始化连接池时会直接报错。

useSSL=false在测试环境能省掉一堆证书告警。生产环境如果数据库有 SSL,那另当别论。还有编码参数characterEncoding=utf8建议固定写死,避免数据库里存在中文用户名时登录页面显示乱码。

配置完成后重启 Tomcat,再用那个内置账号casuser/Mellon登录,你会发现已经登不进去了。因为此时 CAS 已经不使用默认的内存用户,而你的数据库里还没有任何数据。需要往sys_user插一条测试记录,密码字段先随便填一个明文也可以,因为下一步我们会处理密码加密。

4. 自定义密码加密:给 PasswordEncoder 一个清晰的接入点

4.1 内置加密类型和自定义类型的区分

CAS 5.3 的 JDBC 认证里,密码比对最终都走 Spring Security 的PasswordEncoder接口。接口只有两个方法:encode和matches。CAS 内置了几种实现,可以在配置文件中用type指定:

cas.authn.jdbc.query[0].passwordEncoder.type=DEFAULT cas.authn.jdbc.query[0].passwordEncoder.type=com.example.MyPasswordEncoder

DEFAULT表示不加密,也就是明文比对;STANDARD对应 Spring Security 的StandardPasswordEncoder;BCRYPT对应BCryptPasswordEncoder。如果你在维护旧系统,密码可能是 MD5、SHA-256,甚至加了固定盐,内置类型往往对不上,这时就要写自己的实现类。

4.2 自定义 PasswordEncoder 的完整写法

假设旧系统的密码规则是“明文 MD5,小写十六进制”,那实现类可以长这样:

package com.example; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import org.springframework.security.crypto.password.PasswordEncoder; public class Md5PasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { return md5(rawPassword.toString()); } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (encodedPassword == null) { return false; } return md5(rawPassword.toString()).equalsIgnoreCase(encodedPassword); } private String md5(String value) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder builder = new StringBuilder(); for (byte b : bytes) { builder.append(String.format("%02x", b)); } return builder.toString(); } catch (Exception e) { throw new IllegalStateException("MD5 error", e); } } }

然后在配置里指定:

cas.authn.jdbc.query[0].passwordEncoder.type=com.example.Md5PasswordEncoder cas.authn.jdbc.query[0].passwordEncoder.characterEncoding=UTF-8

自定义类实现PasswordEncoder后会由 CAS 反射创建,所以类必须有一个公共无参构造器。characterEncoding这个参数也值得写,它控制 CAS 对原始密码做字节编码时用的字符集,如果数据库存的是 GBK 而这里是 UTF-8,那同样的密码永远匹配不上。

4.3 matches 永远返回 false 的排查顺序

我调试密码问题时的固定排查顺序是:先确认数据库里的密文和明文密码是否真的对应同一套算法;再确认自定义 Encoder 的算法细节(是否转小写、是否加盐);最后确认characterEncoding。这中间最常见的坑是数据库字段长度不够导致的密文截断,或者密文里混着不可见字符。

还有一点经验:matches方法里要容忍大小写。数据库里既有可能是 32 位大写 MD5,也有可能是小写,返回前用equalsIgnoreCase比直接equals稳得多。如果你以后想从 MD5 平滑升级到 BCrypt,可以在这个 Encoder 里做双算法兼容,检测密文前缀是否带{bcrypt}再选择对应的比对逻辑。

5. 登录页面替换:别动 execution、别漏 _eventId

5.1 模板覆盖机制

CAS 5.3 的登录页由 Thymeleaf 渲染,模板在 war 包内的templates/casLoginView.html。overlay 的好处在这里体现出来了:在你自己的工程里创建一个同名文件src/main/resources/templates/casLoginView.html,构建后就能覆盖掉默认页面。

直接写整页模板也可以,但更聪明的做法是复制默认页面的基础结构,只改样式和文案。默认模板里除了 HTML 标签外,还充斥着 Thymeleaf 的th:*属性,这些属性是 WebFlow 和页面交互的关键,一个都不能乱删。

5.2 表单结构:execution 和 _eventId 为什么不能删

一个可以工作的自定义登录页,表单部分至少长这样:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <link rel="stylesheet" th:href="@{/css/cas.css}"> <title>统一认证平台</title> </head> <body> <div class="login-container"> <form id="fm1" method="post" th:action="@{/login}"> <!-- 这两个隐藏字段是 CAS WebFlow 维持会话的核心 --> <input type="hidden" name="execution" th:value="${flowExecutionKey}"/> <input type="hidden" name="_eventId" value="submit"/> <p>用户名</p> <input type="text" name="username" th:value="${username}" autocomplete="off"/> <p>密码</p> <input type="password" name="password" autocomplete="off"/> <div th:if="${status}" class="error-message"> <p th:text="${status}">错误信息</p> </div> <button type="submit">登 录</button> </form> </div> </body> </html>

execution是 Spring WebFlow 的流程执行标识,相当于服务端给这个会话发的一张入场券。没有它,表单提交后 CAS 会直接拒绝,提示找不到 flow execution。_eventId=submit则表示用户在 WebFlow 里触发的是“提交登录”事件。这两个字段不用理解得多深,记住“不能删”就够了。

th:value="${username}"是 CAS 在认证失败后回填用户名的机制,方便用户不用重新输入账号。但密码字段不会回填,这是安全设计,不是 bug。

5.3 页面里的错误信息从哪里来

当登录失败时,CAS 会往 model 里塞一个status属性,默认值是类似“Invalid credentials”的英文消息。你可以在自定义页面用th:if="${status}"判断并渲染。但更关键是别让用户看到一堆内部异常堆栈,这就引出后面“自定义异常”这一节。

静态资源方面,默认的 CSS 路径是/css/cas.css,你也可以在src/main/resources/static/css/cas.css里放自己的样式,覆盖掉默认蓝色风格。不想走 CSS 覆盖也可以直接用内联样式,但生产环境建议从前端规范考虑,维护性比省事重要得多。

6. 登录校验与自定义异常:从“密码错误”到“账号已锁定”

6.1 登录请求在 CAS 内部的流转链路

用户点“登录”后,请求进入 Spring WebFlow。WebFlow 会按定义好的状态机执行认证逻辑:先调用注册的AuthenticationManager,在这个管理器里,多个AuthenticationHandler按 order 依次尝试认证。我们前面配置的 JDBC query handler,只是其中一个 handler。

AuthenticationHandler的职责是“拿凭证换取 Principal”。它查出用户行,用PasswordEncoder.matches比密码,成功就返回一个HandlerResult;失败就抛出安全异常。所有 handler 都通过之后,CentralAuthenticationService才会创建 TGT 票据。

理解这条链之后,你会发现一个关键点:如果你想区分“用户不存在”“密码错误”“账号锁定”这些情况,只靠普通 JDBC handler 不够。因为 JDBC query 模式默认只把“查不到用户”或“密码不匹配”统一抛成FailedLoginException,前端只能看到一句笼统的“用户名或密码错误”。要精细化,就要自定义 handler。

6.2 自定义异常类:继承 CAS 的 AuthenticationException

自定义异常的作用是让不同登录失败原因有独立的 Java 类型,这样页面和接口才能根据类型做差异化处理。继承 CAS 的异常基类即可:

package com.example; import org.apereo.cas.authentication.AuthenticationException; public class AccountLockedException extends AuthenticationException { private static final long serialVersionUID = 1L; public AccountLockedException(String msg) { super(msg); } }

注意 CAS 的AuthenticationException并不是 Spring Security 那个,它是 CAS 自己定义的、携带 handler 执行信息的运行时异常。如果你的 IDE 里报构造器签名不匹配,翻一下当前版本源码,按实际构造器重写即可。

6.3 自定义 Handler:接管用户状态判断

继承AbstractUsernamePasswordAuthenticationHandler,在内部做三件事:查用户、查状态、比密码。比密码这一步直接调用父类的getPasswordEncoder().matches,这样能复用之前自定义的加密器。

package com.example; import java.security.GeneralSecurityException; import java.util.List; import java.util.Map; import javax.security.auth.login.FailedLoginException; import org.apereo.cas.authentication.AbstractUsernamePasswordAuthenticationHandler; import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult; import org.apereo.cas.authentication.credentials.UsernamePasswordCredential; import org.springframework.jdbc.core.JdbcTemplate; public class MyUserAuthenticationHandler extends AbstractUsernamePasswordAuthenticationHandler { private JdbcTemplate jdbcTemplate; public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal( UsernamePasswordCredential credential, String originalPassword) throws GeneralSecurityException { String sql = "SELECT id, password, status FROM sys_user WHERE username = ?"; List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, credential.getUsername()); if (rows.isEmpty()) { throw new FailedLoginException("用户不存在"); } Map<String, Object> row = rows.get(0); int status = ((Number) row.get("status")).intValue(); if (status != 1) { throw new AccountLockedException("账号已被锁定"); } if (!getPasswordEncoder().matches(originalPassword, (String) row.get("password"))) { throw new FailedLoginException("用户名或密码错误"); } return createHandlerResult(credential, this.principalFactory.createPrincipal(credential.getUsername())); } }

Handler 注册到 CAS 的认证执行计划里,可以用配置类:

package com.example; import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer; import org.apereo.cas.authentication.AuthenticationHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; @Configuration("myAuthenticationConfiguration") public class MyAuthenticationConfiguration { @Bean public AuthenticationHandler myUserAuthenticationHandler(DataSource dataSource) { MyUserAuthenticationHandler handler = new MyUserAuthenticationHandler(); handler.setJdbcTemplate(new JdbcTemplate(dataSource)); handler.setPasswordEncoder(new Md5PasswordEncoder()); return handler; } @Bean public AuthenticationEventExecutionPlanConfigurer myAuthenticationPlanConfigurer() { return plan -> plan.registerAuthenticationHandler(myUserAuthenticationHandler(null)); } }

这种方式比在application.properties里写cas.authn.custom.handler要容易控制得多,因为 JdbcTemplate 可以直接通过 Spring 注入。如果你想在配置文件中指定自定义 handler 的类型,CAS 5.3 也支持cas.authn.custom.handler[0].type=com.example.MyUserAuthenticationHandler,但那样的话 handler 内部的数据源依赖不好处理,bean 注册是更符合 Spring 习惯的做法。

6.4 错误消息映射:让页面显示人能看懂的话

自定义异常只是 Java 类型,要变成页面上的“账号已被锁定”,还需要在消息资源文件里加 key。在src/main/resources下建一个messages_zh_CN.properties:

authenticationFailure.AccountLockedException=账号已被锁定,请联系管理员 authenticationFailure.AccountExpiredException=账号已经过期 authenticationFailure.AccountDisabledException=账号被禁用 authenticationFailure.AccountBadCredentialsException=用户名或密码错误

CAS 在 WebFlow 认证失败时,会根据异常类的全限定名去资源文件里找authenticationFailure.<异常类名>对应的文案。找不到就用默认英文。这一步经常被忽略,结果就是后端改了老半天,前端看到的还是那句 “Invalid credentials”。

7. AJAX 登录:不刷新页面拿 TGT,再换 ST

7.1 直接对 /login 发 AJAX 为什么不合适

登录页是 Thymeleaf 渲染的,表单提交默认是整页刷新。如果你用fetch直接 POST/login,服务端返回的是一个完整的 HTML 页面——认证成功是重定向,认证失败还是登录页,根本不是 JSON。前端很难从中提取出规范的错误码,更拿不到票据信息。

那能不能通过解析 HTML 里的隐藏字段来模拟表单提交?能,但很脆弱。HTML 结构变一下,你的解析逻辑就废了。更稳的做法是用 CAS 5.3 自带的 REST API,或者在服务端自己写一个 JSON 接口。下面两种方式我都用过,各有适用场景。

7.2 官方 REST API:/v1/tickets 的完整交互

在 pom 里加上 REST 支持依赖:

<dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-rest</artifactId> <version>${cas.version}</version> </dependency>

重新构建部署后,REST API 就挂在/v1/tickets上。创建 TGT:

curl -X POST 'http://localhost:8080/cas/v1/tickets' \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"123456"}'

成功的话,响应状态是201 Created,响应头Location指向新建的 TGT:

Location: http://localhost:8080/cas/v1/tickets/TGT-xxxxxxxxxxxxxxxx

拿到 TGT 之后,再用它换取某个应用的 ST:

curl -X POST 'http://localhost:8080/cas/v1/tickets/TGT-xxxxxxxxxxxxxxxx' \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'service=http://localhost:8081/app/callback'

响应体就是 ST 票据,例如ST-xxxxxxxx。前端拿到 ST 后拼到业务系统的地址上跳转:http://localhost:8081/app/callback?ticket=ST-xxxxxxxx。业务系统拿这个 ticket 去 CAS 校验,就能完成单点登录。

前端用 fetch 实现,核心代码如下:

async function ajaxLogin(username, password, service) { const resp = await fetch('/cas/v1/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, service }) }); if (resp.status !== 201) { const err = await resp.json().catch(() => ({})); throw new Error(err.message || '登录失败'); } const tgtUrl = resp.headers.get('Location'); const stResp = await fetch(tgtUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ service }) }); if (!stResp.ok) { throw new Error('获取服务票据失败'); } const st = await stResp.text(); return `${service}?ticket=${encodeURIComponent(st)}`; }

REST API 方式的优点是官方维护、路径稳定,基本不用自己写认证代码。缺点也很明显:返回格式是固定约定,遇到需要返回自定义业务字段(比如用户角色列表)时,官方接口帮不上太多忙。

7.3 自定义 JSON 接口:适合要严格控制返回格式的场景

如果你的场景是前后端分离,前端希望在同一个 JSON 里拿到code、message、tgt、用户信息,那自定义 Controller 更合适。实现逻辑不复杂:注入 CAS 的认证支持组件,手动完成“凭证认证 + 发放 TGT”。

package com.example; import java.util.HashMap; import java.util.Map; import org.apereo.cas.authentication.Authentication; import org.apereo.cas.authentication.AuthenticationResult; import org.apereo.cas.authentication.AuthenticationSystemSupport; import org.apereo.cas.authentication.credentials.UsernamePasswordCredential; import org.apereo.cas.ticket.TicketGrantingTicket; import org.apereo.cas.CentralAuthenticationService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class ApiLoginController { @Autowired private AuthenticationSystemSupport authenticationSystemSupport; @Autowired private CentralAuthenticationService centralAuthenticationService; @PostMapping("/api/auth/login") public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest req) { UsernamePasswordCredential credential = new UsernamePasswordCredential(req.getUsername(), req.getPassword()); Map<String, Object> result = new HashMap<>(); try { AuthenticationResult authResult = authenticationSystemSupport .handleAndFinalizeSingleAuthentication(credential); Authentication authentication = authResult.getAuthentication(); TicketGrantingTicket tgt = centralAuthenticationService.createTicketGrantingTicket(authentication); result.put("code", 0); result.put("message", "登录成功"); result.put("tgt", tgt.getId()); result.put("username", authentication.getPrincipal().getId()); return ResponseEntity.ok(result); } catch (Exception e) { result.put("code", 1001); result.put("message", e.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(result); } } public static class LoginRequest { private String username; private String password; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } }

注意这个接口的路径不能和 CAS 内部的/login冲突。放在/api/auth/login这种独立路径下,最省心。接口内部抛出的异常就是你自定义的AccountLockedException、FailedLoginException等,统一在前面 catch 住后转成 JSON 错误码。

7.4 自定义接口模式下前端怎么处理

这种模式下前端拿到的是一段纯 JSON,状态码可以通过业务字段判断:

const resp = await fetch('/cas/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await resp.json(); if (data.code === 0) { // 拿到 TGT,业务端如果想走完整的 SSO 流程,可以再用 TGT 换 ST } else { alert(data.message); // “账号已被锁定,请联系管理员” }

如果你想让自定义接口同样具备“换 ST”的能力,可以再把createServiceTicket的逻辑加进去,或者在前端拿到 TGT 后用第三节的方法继续调用 REST API。两种方式混合使用也很常见,核心就是:认证走你控制的 JSON 接口,票据交互走官方路径。

8. 部署后的真实排查:日志开关、常见报错与处理

8.1 必须打开的日志开关

CAS 5.3 默认的日志级别是 INFO,排查认证问题时基本不够用。建议在src/main/resources/log4j2.xml里把关键包的级别调到 DEBUG:

<Logger name="org.apereo.cas" level="debug" additivity="false"> <AppenderRef ref="casConsole"/> <AppenderRef ref="casFile"/> </Logger> <Logger name="org.springframework.webflow" level="debug" additivity="false"> <AppenderRef ref="casConsole"/> <AppenderRef ref="casFile"/> </Logger> <Logger name="org.springframework.security.web" level="debug" additivity="false"> <AppenderRef ref="casConsole"/> <AppenderRef ref="casFile"/> </Logger>

改完日志后重启服务,再走一次登录流程,重点看两个地方:认证阶段是否出现HandledAuthenticationEvent或异常抛出记录;WebFlow 阶段 session 是否有execution匹配失败。日志里能看到Initial flow execution url这类字样,基本就能判断登录页有没有正常进入 WebFlow。

8.2 常见报错和对应的处理方式

现象原因处理
Failed to configure a DataSource数据源参数不完整或连接失败检查 url、user、password,先用数据库客户端直连测试
Communications link failureMySQL 连不上,端口或网络不通检查 3306 端口、防火墙、连接串的 host/port
Access denied for user 'xxx'@'...'数据库账号权限不足给账号授权,或换一个有权限的账号
登录总提示用户名或密码错误PasswordEncoder 算法和数据库密文不一致用同一个 Encoder 对已知密码手动加密,比对数据库
No such flow execution自定义表单把execution隐藏字段删了加回<input type="hidden" name="execution" th:value="${flowExecutionKey}"/>
Certificate for <host> doesn't match访问 CAS 的协议和 service 注册协议不匹配检查 service 注册 URL 是http还是https,以及是否启用了 HTTPS 强制
重启后已有登录用户掉线默认 TicketRegistry 是内存存储生产环境把 TicketRegistry 配到 Redis 或数据库,不要用默认内存

8.3 一个值得养成的部署习惯

最后说个实操层面的建议:CAS 服务端的配置应该独立到外部配置文件里,而不是都写在工程包内。5.3 支持通过 JVM 参数指定配置目录,例如-Dcas.config=/etc/cas/config或者直接把application.properties放到/etc/cas/config下。这样升级 war 包时,数据库账号、密钥这些敏感配置不会跟着工程一起打包,也不会被同事不经意间提交到代码仓库。

另外,如果 CAS 跑完登录流程之后,业务系统总报“未授权服务”,十有八九是 service registry 里没有配置允许的服务地址。CAS 5.3 默认只允许白名单里的 service 使用 ST,测试阶段可以用 JSON service registry 快速加一条规则,而不是关闭整个校验。这个点不算配置本身,但在“登录校验”的完整闭环里经常成为最后一公里的堵点,值得你多看一眼。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进