Appearance
CAS Spring Webflow 定制与验证码集成:深入掌控登录流程的每一个环节
作者: 必码 | bima.cc
前言
在单点登录(SSO)领域,Apereo CAS 是企业级应用中最为成熟和广泛使用的开源解决方案之一。然而,许多开发者在实际项目中遇到一个共同的痛点:当需要在标准登录流程中集成验证码、多因素认证、自定义字段等业务需求时,往往对 CAS 内部的流程编排机制缺乏深入理解,导致集成工作举步维艰。
CAS 的登录流程并非简单的 Controller-View 模式,而是基于 Spring Webflow 构建的一套有状态的、可编排的流程引擎。理解 Webflow 的工作原理,是掌控 CAS 登录流程的基石。
本文基于我们团队在多个实际 CAS Overlay 项目(涵盖 5.3、6.6、7.3 三个大版本)中的实战经验,从 Spring Webflow 的核心概念出发,逐步深入到 CAS 登录流程的 Webflow 定义、配置方式演进、自定义 Webflow 配置器的设计模式,最终以验证码集成作为核心实战案例,完整呈现如何在不修改 CAS 源码的前提下,通过 Overlay 机制深度定制登录流程。
无论你是正在维护旧版 CAS 5.3 的系统架构师,还是规划升级到 CAS 7.3 的技术负责人,本文都将为你提供一套系统性的方法论和可落地的实践方案。
第一章 Spring Webflow 在 CAS 中的角色
1.1 什么是 Spring Webflow
Spring Webflow 是 Spring 生态中一个独特的框架,它解决了一个传统 MVC 框架难以优雅处理的问题:如何管理跨越多次请求的业务流程。
在传统的 Spring MVC 中,每个 HTTP 请求都是独立的,Controller 处理请求后返回视图,用户再次提交时又是一个全新的请求。这种无状态的模型对于简单的页面跳转没有问题,但当面对一个需要多步骤、有状态转换的业务流程时(比如登录流程中涉及初始检查、凭证验证、票据生成、重定向等多个阶段),传统的 MVC 模式需要开发者手动管理大量的中间状态,代码往往变得混乱且难以维护。
Spring Webflow 的核心设计理念是:将一个业务流程定义为一个 Flow,Flow 由多个 State 组成,State 之间通过 Transition 连接。这种声明式的流程定义方式,使得复杂的业务流程可以被清晰地描述和管理。
从技术架构的角度来看,Spring Webflow 具有以下核心特征:
有状态性(Stateful): Webflow 在服务端维护一个 FlowExecution,记录当前流程执行到哪个 State、携带了哪些变量。这些状态信息通过 FlowExecutionKey 与客户端关联(通常通过 URL 参数或 Session),使得跨越多次 HTTP 请求的流程可以无缝衔接。
基于流程的编排(Flow-Based Orchestration): 整个业务流程被抽象为一个有向图,节点是 State,边是 Transition。流程的走向由事件(Event)驱动,开发者只需要定义"在某个状态下,发生某个事件后,流程应该转向哪个状态"。
可扩展的 State 类型: Webflow 提供了多种内置的 State 类型,每种类型对应不同的处理逻辑:
- ViewState:渲染一个视图(通常是 HTML 页面),等待用户输入
- ActionState:执行一个业务动作(Action),根据执行结果决定流程走向
- DecisionState:根据条件判断,选择不同的流程分支
- EndState:流程的终止节点
- SubflowState:嵌入一个子流程
与 Spring MVC 的无缝集成: Webflow 并非替代 Spring MVC,而是构建在 Spring MVC 之上的一个抽象层。ViewState 渲染的视图仍然由 Spring MVC 的 ViewResolver 解析,表单数据的绑定仍然使用 Spring 的 DataBinder 机制。
以下是一个简化的 Webflow XML 定义示例,帮助读者建立直观认知:
xml
<flow xmlns="http://www.springframework.org/schema/webflow"
xmlns:xsi="http://www.w3.org/2000/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/webflow
http://www.springframework.org/schema/webflow/spring-webflow.xsd">
<!-- 流程开始:on-start 在流程启动时执行 -->
<on-start>
<evaluate expression="initialFlowSetupAction" />
</on-start>
<!-- 视图状态:显示登录表单 -->
<view-state id="viewLoginForm" view="casLoginView" model="credential">
<on-entry>
<evaluate expression="setupLoginFormAction" />
</on-entry>
<transition on="submit" to="realSubmit" />
</view-state>
<!-- 动作状态:执行实际的登录验证 -->
<action-state id="realSubmit">
<evaluate expression="authenticationViaFormAction" />
<transition on="success" to="sendTicketGrantingTicket" />
<transition on="error" to="viewLoginForm" />
</action-state>
<!-- 动作状态:发送 TGT 票据 -->
<action-state id="sendTicketGrantingTicket">
<evaluate expression="sendTicketGrantingTicketAction" />
<transition on="success" to="serviceCheck" />
</action-state>
<!-- 决策状态:检查是否有 Service 参数 -->
<decision-state id="serviceCheck">
<if test="flowScope.service != null" then="generateServiceTicket" else="postLogin" />
</decision-state>
<!-- 流程结束 -->
<end-state id="postLogin" view="externalRedirect:${context.externalContext.contextPath}/cas/login" />
<end-state id="generateServiceTicket" view="externalRedirect:${flowScope.service.url}?ticket=${flowScope.ticket}" />
</flow>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
从上面的示例可以看出,Webflow 的 XML 定义本质上是在描述一个有限状态机(FSM)。每个 State 是一个状态节点,Transition 是状态转移的条件,Expression 是在状态转移过程中执行的业务逻辑。
1.2 为什么 CAS 选择 Webflow
CAS 之所以选择 Spring Webflow 作为登录流程的基础框架,是由其业务特性决定的。SSO 登录流程天然具备以下特征:
多步骤性: 一个完整的 CAS 登录流程至少包含以下步骤:
- 初始化(检查 TGT Cookie、Service 参数等)
- 显示登录表单
- 接收并验证用户凭证
- 生成 TGT(Ticket Granting Ticket)
- 检查是否需要生成 ST(Service Ticket)
- 生成 ST 并重定向到目标服务
- 处理各种异常情况(凭证错误、账户锁定、需要跳转到警告页面等)
如果使用传统的 MVC 模式,开发者需要在 Controller 中手动管理这些步骤之间的跳转关系和中间状态。而使用 Webflow,这些步骤可以被声明式地定义在一个 XML 文件中,流程的走向一目了然。
可扩展性: CAS 作为一个开源框架,需要允许用户在不修改源码的情况下定制登录流程。Webflow 的配置器模式(Configurer Pattern)完美契合这一需求。用户可以通过编写自定义的 WebflowConfigurer,在 CAS 默认流程的基础上插入、修改或删除流程节点。
状态管理: 在登录流程中,很多信息需要在多个步骤之间共享,比如当前的 Service 信息、认证结果、警告消息等。Webflow 提供了 FlowScope、ViewScope、ConversationScope 等多种作用域,可以方便地管理这些共享状态。
可测试性: Webflow 的流程定义是声明式的,可以独立于 Web 容器进行测试。CAS 官方提供了大量的 Webflow 集成测试,确保流程定义的正确性。
从架构设计的角度来看,CAS 选择 Webflow 体现了"约定优于配置"和"开放封闭原则"的设计哲学。CAS 定义了一套标准的登录流程(即默认的 login-webflow.xml),同时通过 WebflowConfigurer 扩展点,允许用户在不修改默认流程的前提下进行定制。
1.3 CAS 登录流程的 Webflow 定义
CAS 的登录流程定义在 login-webflow.xml 文件中。这个文件是 CAS 登录流程的核心蓝图,定义了从用户访问 CAS 登录页面到最终完成认证的完整流程。
在 CAS Overlay 项目中,这个文件通常位于 CAS 的核心 JAR 包内(路径为 classpath:/WEB-INF/cas-servlet.xml 中引用,实际文件在 cas-server-core-webflow 模块中)。不同版本的 CAS,其 login-webflow.xml 的内容会有所差异,但核心流程节点是稳定的。
以下是 CAS 登录流程的核心节点及其职责的概览:
[start]
|
v
[initialFlowSetup] -- 初始化流程,设置 Service、Theme、Locale 等
|
v
[ticketGrantingTicketCheck] -- 检查是否存在有效的 TGT Cookie
| |
|--- (有 TGT) --> [hasServiceCheck] -- 检查是否有 Service 参数
| |
| |--- (有 Service) --> [generateServiceTicket] --> [redirect]
| |
| |--- (无 Service) --> [postLogin] --> [redirect]
|
|--- (无 TGT) --> [viewLoginForm] -- 显示登录表单
|
v
[realSubmit] -- 提交登录表单
|
v
[authenticationViaFormAction] -- 执行认证
|
|--- (成功) --> [sendTicketGrantingTicket] --> [hasServiceCheck]
|
|--- (失败) --> [viewLoginForm](显示错误信息)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这个流程图展示了 CAS 登录流程的主干路径。在实际的 Webflow 定义中,还有大量的辅助节点用于处理警告页面、协议切换、身份提供者选择等场景。
让我们来看一个简化但完整的 CAS login-webflow.xml 定义(基于 CAS 5.3 的结构,适用于教学目的):
xml
<?xml version="1.0" encoding="UTF-8"?>
<flow xmlns="http://www.springframework.org/schema/webflow"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/webflow
http://www.springframework.org/schema/webflow/spring-webflow.xsd"
start-state="initialFlowSetup">
<!-- ==================== 初始化阶段 ==================== -->
<!-- 流程启动时执行的全局初始化 -->
<on-start>
<evaluate expression="initialFlowSetupAction" />
</on-start>
<!-- ==================== 核心流程节点 ==================== -->
<!-- Action State: 初始化流程设置 -->
<action-state id="initialFlowSetup">
<evaluate expression="initialFlowSetupAction" />
<transition on="success" to="ticketGrantingTicketCheck" />
</action-state>
<!-- Decision State: 检查是否存在有效的 TGT -->
<decision-state id="ticketGrantingTicketCheck">
<if test="flowScope.ticketGrantingTicketId != null"
then="hasServiceCheck"
else="viewLoginForm" />
</decision-state>
<!-- Decision State: 检查是否有 Service 参数 -->
<decision-state id="hasServiceCheck">
<if test="flowScope.service != null"
then="generateServiceTicket"
else="postLogin" />
</decision-state>
<!-- View State: 显示登录表单 -->
<view-state id="viewLoginForm"
view="casLoginView"
model="credential">
<binder>
<binding property="username" required="true" />
<binding property="password" required="true" />
</binder>
<on-entry>
<evaluate expression="setupLoginFormAction" />
</on-entry>
<transition on="submit" bind="true" validate="true" to="realSubmit" />
</view-state>
<!-- Action State: 处理表单提交 -->
<action-state id="realSubmit">
<evaluate expression="authenticationViaFormAction" />
<transition on="warn" to="warn" />
<transition on="success" to="sendTicketGrantingTicket" />
<transition on="successWithWarnings" to="showWarningView" />
<transition on="authenticationFailure" to="handleAuthenticationFailure" />
<transition on="error" to="viewLoginForm" />
</action-state>
<!-- Action State: 处理认证失败 -->
<action-state id="handleAuthenticationFailure">
<evaluate expression="handleAuthenticationFailureAction" />
<transition on="authenticationFailure" to="viewLoginForm" />
<transition on="accountLocked" to="casAccountLockedView" />
<transition on="accountDisabled" to="casAccountDisabledView" />
</action-state>
<!-- Action State: 发送 TGT -->
<action-state id="sendTicketGrantingTicket">
<evaluate expression="sendTicketGrantingTicketAction" />
<transition on="success" to="hasServiceCheck" />
<transition on="error" to="viewLoginForm" />
</action-state>
<!-- Action State: 生成 Service Ticket -->
<action-state id="generateServiceTicket">
<evaluate expression="generateServiceTicketAction" />
<transition on="success" to="redirect" />
<transition on="error" to="viewLoginForm" />
<transition on="gateway" to="gatewayServicesCheck" />
</action-state>
<!-- End State: 重定向到目标服务 -->
<end-state id="redirect"
view="externalRedirect:${requestScope.response.url}" />
<!-- End State: 登录后处理 -->
<end-state id="postLogin"
view="externalRedirect:${context.externalContext.contextPath}/cas/login" />
<!-- View State: 警告页面 -->
<view-state id="warn"
view="casConfirmView">
<transition on="submit" to="warnSubmit" />
</view-state>
<!-- Action State: 处理警告确认 -->
<action-state id="warnSubmit">
<evaluate expression="warnAction" />
<transition on="success" to="sendTicketGrantingTicket" />
<transition on="redirect" to="redirect" />
</action-state>
<!-- View State: 显示警告信息 -->
<view-state id="showWarningView"
view="casLoginWarningView">
<on-entry>
<evaluate expression="warningAction" />
</on-entry>
<transition on="success" to="sendTicketGrantingTicket" />
</view-state>
<!-- View State: 账户锁定 -->
<view-state id="casAccountLockedView" view="casAccountLockedView" />
<!-- View State: 账户禁用 -->
<view-state id="casAccountDisabledView" view="casAccountDisabledView" />
<!-- ==================== 全局转换 ==================== -->
<!-- 全局异常处理 -->
<global-transitions>
<transition on-exception="org.springframework.webflow.execution.repository.FlowExecutionRestorationFailureException"
to="viewLoginForm" />
</global-transitions>
</flow>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
这个 XML 文件完整地描述了 CAS 登录流程的所有状态节点和转换关系。接下来,我们将逐一深入分析每个核心节点。
1.4 核心流程节点详解
1.4.1 initialFlowSetup -- 流程初始化
initialFlowSetup 是整个登录流程的起点(由 start-state="initialFlowSetup" 指定)。它的职责是在流程启动时执行一系列初始化操作。
在 CAS 的实现中,initialFlowSetupAction 通常是一个 InitialFlowSetupAction 类的实例,它负责以下工作:
- 解析 Service 参数: 从请求 URL 中提取
service参数,并将其存入 FlowScope。Service 参数标识了用户登录后需要跳转的目标应用。 - 设置主题(Theme): 根据请求参数或配置,确定当前登录页面使用的主题。
- 设置区域(Locale): 根据请求头或配置,确定当前登录页面的语言环境。
- 检查 Gateway 参数: 如果请求中包含
gateway参数,则标记为 Gateway 模式。在 Gateway 模式下,如果用户已有有效的 TGT,则自动生成 ST 并重定向到目标服务,而不显示登录表单。 - 设置方法(Method): 如果请求中包含
method参数(如POST),则记录请求方法。
java
// InitialFlowSetupAction 的简化实现(教学用途)
public class InitialFlowSetupAction extends AbstractAction {
@Override
protected Event doExecute(final RequestContext context) throws Exception {
final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
// 解析 Service 参数并存入 FlowScope
final String service = request.getParameter("service");
if (StringUtils.isNotBlank(service)) {
final WebApplicationService webAppService =
new WebApplicationService.Builder().url(service).build();
WebUtils.putServiceIntoFlowScope(context, webAppService);
}
// 设置主题
final String themeId = request.getParameter("theme");
if (StringUtils.isNotBlank(themeId)) {
WebUtils.putThemeIntoFlowScope(context, themeId);
}
// 设置区域
final String locale = request.getParameter("locale");
if (StringUtils.isNotBlank(locale)) {
WebUtils.putLocaleIntoFlowScope(context, new Locale(locale));
}
// 检查 Gateway 模式
final boolean isGateway = StringUtils.isNotBlank(
request.getParameter("gateway"));
WebUtils.putGatewayIntoFlowScope(context, isGateway);
return success();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
1.4.2 ticketGrantingTicketCheck -- TGT 检查
ticketGrantingTicketCheck 是一个 DecisionState,它的核心逻辑是检查用户是否已经拥有一个有效的 TGT(Ticket Granting Ticket)。TGT 是 CAS SSO 机制的核心票据,它存储在服务端(通常在 Ticket Registry 中),同时通过一个名为 TGT 的 Cookie 发送给浏览器。
判断逻辑非常简单:
xml
<decision-state id="ticketGrantingTicketCheck">
<if test="flowScope.ticketGrantingTicketId != null"
then="hasServiceCheck"
else="viewLoginForm" />
</decision-state>1
2
3
4
5
2
3
4
5
如果 flowScope.ticketGrantingTicketId 不为空,说明用户之前已经成功登录过,且 TGT 尚未过期。此时流程跳转到 hasServiceCheck,检查是否需要生成 Service Ticket 并重定向到目标服务。
如果 flowScope.ticketGrantingTicketId 为空,说明用户尚未登录或 TGT 已过期。此时流程跳转到 viewLoginForm,显示登录表单。
这个节点体现了 SSO 的核心价值:用户只需要登录一次,后续访问其他受保护的服务时,CAS 会自动通过 TGT 生成 ST,实现无感知的单点登录。
1.4.3 realSubmit -- 表单提交处理
realSubmit 是整个登录流程中最为关键的 ActionState。当用户在登录表单中输入用户名和密码并点击提交按钮后,流程进入这个节点。
xml
<action-state id="realSubmit">
<evaluate expression="authenticationViaFormAction" />
<transition on="warn" to="warn" />
<transition on="success" to="sendTicketGrantingTicket" />
<transition on="successWithWarnings" to="showWarningView" />
<transition on="authenticationFailure" to="handleAuthenticationFailure" />
<transition on="error" to="viewLoginForm" />
</action-state>1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
authenticationViaFormAction 是 CAS 核心的认证 Action,它的职责包括:
- 从 FlowScope 中获取 Credential 对象: Credential 对象包含了用户提交的用户名和密码。
- 调用 AuthenticationManager 执行认证: AuthenticationManager 会根据配置的认证策略(如数据库认证、LDAP 认证、OAuth 认证等)验证用户凭证。
- 根据认证结果返回不同的事件:
success:认证成功,无任何警告warn:认证成功,但存在需要用户确认的警告(如用户之前选择了"记住我"但当前是从不同设备登录)successWithWarnings:认证成功,但存在需要展示给用户的警告信息authenticationFailure:认证失败(用户名或密码错误)error:系统错误
这个节点是我们在集成验证码时需要重点关注的位置。验证码的校验逻辑需要在认证之前执行,如果验证码校验失败,流程应该直接返回登录表单,而不执行实际的认证操作。
1.4.4 generateServiceTicket -- 生成服务票据
当用户认证成功且存在 Service 参数时,流程进入 generateServiceTicket 节点。这个节点的职责是:
- 从 FlowScope 中获取 TGT: 使用之前创建或已存在的 TGT。
- 为指定的 Service 生成一个 ST(Service Ticket): ST 是一次性的票据,目标服务使用 ST 向 CAS 验证用户身份。
- 构建重定向 URL: 将 ST 附加到目标服务的 URL 上,构建重定向地址。
xml
<action-state id="generateServiceTicket">
<evaluate expression="generateServiceTicketAction" />
<transition on="success" to="redirect" />
<transition on="error" to="viewLoginForm" />
<transition on="gateway" to="gatewayServicesCheck" />
</action-state>1
2
3
4
5
6
2
3
4
5
6
1.4.5 sendTicketGrantingTicket -- 发送 TGT
sendTicketGrantingTicket 节点在认证成功后执行,它的职责是将 TGT 写入浏览器的 Cookie 中。
xml
<action-state id="sendTicketGrantingTicket">
<evaluate expression="sendTicketGrantingTicketAction" />
<transition on="success" to="hasServiceCheck" />
<transition on="error" to="viewLoginForm" />
</action-state>1
2
3
4
5
2
3
4
5
sendTicketGrantingTicketAction 的实现逻辑包括:
- 创建 TGT: 调用
TicketGrantingTicket的构造方法,传入认证结果(Authentication)和过期策略。 - 将 TGT 存入 Ticket Registry: Ticket Registry 可以是内存存储、Redis 存储或其他分布式存储。
- 将 TGT ID 写入 Cookie: 通过
response.addCookie()将 TGT Cookie 发送给浏览器。
1.4.6 postLogin -- 登录后处理
postLogin 是一个 EndState,当用户认证成功但没有 Service 参数时,流程到达这个节点。此时 CAS 会重定向到默认的登录成功页面。
xml
<end-state id="postLogin"
view="externalRedirect:${context.externalContext.contextPath}/cas/login" />1
2
2
1.4.7 redirect -- 重定向
redirect 也是一个 EndState,当需要生成 ST 并重定向到目标服务时,流程到达这个节点。
xml
<end-state id="redirect"
view="externalRedirect:${requestScope.response.url}" />1
2
2
1.5 View State、Action State、Decision State 深度解析
在深入 CAS Webflow 定制之前,我们需要对 Webflow 的三种核心 State 类型有更深入的理解。
1.5.1 ViewState 深度解析
ViewState 是用户与系统交互的界面节点。当流程进入一个 ViewState 时,Webflow 会渲染一个视图(通常是 HTML 页面),然后暂停流程执行,等待用户操作(如提交表单、点击按钮等)。
在 CAS 的 login-webflow.xml 中,最重要的 ViewState 是 viewLoginForm:
xml
<view-state id="viewLoginForm"
view="casLoginView"
model="credential">
<binder>
<binding property="username" required="true" />
<binding property="password" required="true" />
</binder>
<on-entry>
<evaluate expression="setupLoginFormAction" />
</on-entry>
<transition on="submit" bind="true" validate="true" to="realSubmit" />
</view-state>1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
这个 ViewState 定义包含以下几个关键属性和子元素:
id 属性: ViewState 的唯一标识符,在 Transition 中通过这个 ID 引用。
view 属性: 指定要渲染的视图名称。casLoginView 会被 Spring MVC 的 ViewResolver 解析为具体的 Thymeleaf 模板文件(如 templates/casLoginView.html)。
model 属性: 指定表单数据的绑定对象。credential 是一个在 FlowScope 中注册的 Bean 名称,Webflow 会使用 Spring 的 DataBinder 将表单数据绑定到这个对象上。
<binder> 元素: 定义表单字段与 Model 对象属性的绑定规则。required="true" 表示该字段为必填项。在 CAS 7.3 中,Binder 的配置方式有所变化,但核心概念是一致的。
<on-entry> 元素: 在进入 ViewState 之前执行的 Action。setupLoginFormAction 通常用于准备登录表单所需的上下文数据,如可用的身份认证方式列表、Service 信息等。
<transition> 元素: 定义用户操作触发的流程转换。on="submit" 表示当用户触发的 Spring Webflow 事件名为 submit 时(通常对应表单的提交按钮),流程转换到 realSubmit 节点。bind="true" 表示在转换之前执行表单数据绑定,validate="true" 表示在绑定之后执行数据验证。
ViewState 的生命周期:
- 流程进入 ViewState
- 执行
<on-entry>中定义的 Action - 渲染视图
- 等待用户操作
- 用户操作触发一个事件(Event)
- 如果 Transition 配置了
bind="true",执行表单数据绑定 - 如果 Transition 配置了
validate="true",执行数据验证 - 根据验证结果,匹配 Transition 规则,转换到下一个 State
理解 ViewState 的生命周期对于验证码集成至关重要。验证码字段需要在 <binder> 中声明,验证码的校验逻辑需要在数据验证阶段执行。
1.5.2 ActionState 深度解析
ActionState 是执行业务逻辑的节点。当流程进入一个 ActionState 时,Webflow 会执行指定的 Action,并根据 Action 的返回结果(Event)决定流程的走向。
xml
<action-state id="realSubmit">
<evaluate expression="authenticationViaFormAction" />
<transition on="warn" to="warn" />
<transition on="success" to="sendTicketGrantingTicket" />
<transition on="authenticationFailure" to="handleAuthenticationFailure" />
<transition on="error" to="viewLoginForm" />
</action-state>1
2
3
4
5
6
7
2
3
4
5
6
7
<evaluate> 元素: 指定要执行的 Action。expression="authenticationViaFormAction" 是一个 Spring EL 表达式,它引用了一个在 Spring 容器中注册的 Bean。Webflow 会调用这个 Bean 的 execute() 方法。
Action 的实现模式: CAS 中的 Action 通常继承自 AbstractAction 类,重写 doExecute() 方法:
java
public class AuthenticationViaFormAction extends AbstractAction {
@Override
protected Event doExecute(final RequestContext context) throws Exception {
// 从 FlowScope 获取 Credential
final Credential credential = WebUtils.getCredential(context);
// 执行认证
final AuthenticationResult result = authenticationManager.authenticate(credential);
// 根据结果返回不同的事件
if (result.isSuccess()) {
return success();
} else {
return error();
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ActionState 的生命周期:
- 流程进入 ActionState
- 执行
<evaluate>中指定的 Action - Action 返回一个 Event(如
success、error、warn等) - Webflow 根据返回的 Event 匹配
<transition>规则 - 转换到匹配的下一个 State
多个 Action 的链式执行: 一个 ActionState 可以包含多个 <evaluate> 元素,它们会按顺序执行:
xml
<action-state id="multiActionExample">
<evaluate expression="firstAction" />
<evaluate expression="secondAction" />
<evaluate expression="thirdAction" />
<transition on="success" to="nextState" />
</action-state>1
2
3
4
5
6
2
3
4
5
6
这种链式执行模式在验证码集成中非常有用。我们可以在认证 Action 之前插入一个验证码校验 Action,如果验证码校验失败,直接返回错误事件,跳过后续的认证操作。
1.5.3 DecisionState 深度解析
DecisionState 是条件分支节点,它不执行任何业务逻辑,只是根据一个布尔表达式选择不同的流程分支。
xml
<decision-state id="ticketGrantingTicketCheck">
<if test="flowScope.ticketGrantingTicketId != null"
then="hasServiceCheck"
else="viewLoginForm" />
</decision-state>1
2
3
4
5
2
3
4
5
test 属性: 一个布尔表达式,使用 Spring EL 语法。表达式可以访问 FlowScope、ViewScope、RequestScope 等作用域中的变量。
then 属性: 当 test 表达式为 true 时,流程转换到的目标 State。
else 属性: 当 test 表达式为 false 时,流程转换到的目标 State。
多个条件判断: 一个 DecisionState 可以包含多个 <if> 元素,它们按顺序评估:
xml
<decision-state id="complexDecision">
<if test="flowScope.conditionA" then="stateA" />
<if test="flowScope.conditionB" then="stateB" />
<if test="flowScope.conditionC" then="stateC" />
<!-- 以上都不满足时,进入最后一个 else -->
<if test="true" then="defaultState" />
</decision-state>1
2
3
4
5
6
7
2
3
4
5
6
7
在 CAS 的实际实现中,DecisionState 被广泛用于流程分支判断,如检查 TGT 是否存在、检查 Service 参数是否存在、检查是否需要多因素认证等。
1.6 Transition 转换机制
Transition 是连接 State 的"边",它定义了在什么条件下,流程从一个 State 转换到另一个 State。
1.6.1 Transition 的触发方式
Transition 可以通过以下方式触发:
事件触发(on 属性): 最常见的触发方式。当用户操作(如表单提交、按钮点击)产生一个事件时,匹配 on 属性对应的 Transition。
xml
<transition on="submit" to="realSubmit" />1
异常触发(on-exception 属性): 当指定类型的异常被抛出时,匹配对应的 Transition。
xml
<transition on-exception="java.lang.IllegalArgumentException"
to="errorView" />1
2
2
通配符匹配(on="*"): 匹配所有未被其他 Transition 匹配的事件。
xml
<transition on="*" to="defaultHandler" />1
1.6.2 Transition 的执行流程
当一个 Transition 被触发时,Webflow 会执行以下步骤:
- 绑定(bind): 如果 Transition 配置了
bind="true",Webflow 使用 DataBinder 将请求参数绑定到 Model 对象。 - 验证(validate): 如果 Transition 配置了
validate="true",Webflow 调用 Model 对象的验证方法。 - 执行 Action: 如果 Transition 包含
<evaluate>子元素,执行指定的 Action。 - 状态转换: 流程从当前 State 转换到目标 State。
1.6.3 Global Transitions
Global Transitions 是全局级别的转换规则,它们适用于流程中的所有 State。CAS 在 login-webflow.xml 中定义了一个 Global Transition 用于处理流程恢复异常:
xml
<global-transitions>
<transition on-exception=
"org.springframework.webflow.execution.repository.FlowExecutionRestorationFailureException"
to="viewLoginForm" />
</global-transitions>1
2
3
4
5
2
3
4
5
这意味着无论流程处于哪个 State,只要抛出了 FlowExecutionRestorationFailureException,流程都会跳转到登录表单页面。这种异常通常发生在用户的 Session 过期后尝试恢复之前的流程时。
1.7 Flow Scope、View Scope、Conversation Scope
Webflow 提供了多种作用域来管理流程中的数据。理解这些作用域的区别和适用场景,是正确进行 Webflow 定制的前提。
1.7.1 FlowScope
FlowScope 是与一个 FlowExecution 绑定的作用域。数据在 FlowScope 中存储后,在整个流程执行期间都可以访问。当流程结束时,FlowScope 中的数据会被销毁。
在 CAS 中,以下数据通常存储在 FlowScope 中:
service:当前请求的 Service 对象ticketGrantingTicketId:当前的 TGT IDcredential:用户的认证凭证warning:是否需要显示警告
在 Webflow XML 中访问 FlowScope:
xml
<if test="flowScope.ticketGrantingTicketId != null" then="..." />1
在 Java 代码中操作 FlowScope:
java
// 向 FlowScope 存入数据
context.getFlowScope().put("service", webAppService);
// 从 FlowScope 获取数据
final Service service = (Service) context.getFlowScope().get("service");1
2
3
4
5
2
3
4
5
1.7.2 ViewScope
ViewScope 是与一个 ViewState 绑定的作用域。数据在 ViewScope 中存储后,只有在当前 ViewState 的生命周期内可以访问。当流程离开当前 ViewState 时,ViewScope 中的数据会被销毁。
ViewScope 的典型使用场景是存储与特定视图相关的临时数据,如表单的初始值、视图特定的配置参数等。
在 Java 代码中操作 ViewScope:
java
// 向 ViewScope 存入数据
context.getViewScope().put("errorMessage", "用户名或密码错误");
// 从 ViewScope 获取数据
final String errorMsg = (String) context.getViewScope().get("errorMessage");1
2
3
4
5
2
3
4
5
1.7.3 ConversationScope
ConversationScope 是最高级别的作用域,它可以跨越多个 FlowExecution。当一个 Flow 启动了一个 Subflow 时,ConversationScope 中的数据在主 Flow 和 Subflow 之间共享。
在 CAS 中,ConversationScope 的使用相对较少,但在某些高级场景(如 OAuth 授权流程中涉及多个子流程时)会用到。
1.7.4 RequestScope
RequestScope 是与当前 HTTP 请求绑定的作用域。它的生命周期最短,只在当前请求的处理过程中有效。RequestScope 中的数据在请求结束后就会被销毁。
1.7.5 FlashScope
FlashScope 是一个特殊的作用域,它的数据在当前请求和下一个请求之间共享。FlashScope 通常用于在重定向后传递一次性消息(如操作成功的提示信息)。
1.7.6 作用域优先级
当在 EL 表达式中引用一个变量时,Webflow 按以下顺序搜索各作用域:
- RequestScope
- FlashScope
- ViewScope
- FlowScope
- ConversationScope
- SessionScope
这意味着如果同名变量存在于多个作用域中,RequestScope 中的值会优先被使用。
1.8 Webflow 与传统 MVC 的本质区别
为了帮助读者更好地理解 Webflow 的设计哲学,我们将其与传统 MVC 模式进行对比:
| 维度 | 传统 Spring MVC | Spring Webflow |
|---|---|---|
| 状态管理 | 无状态,每个请求独立 | 有状态,维护 FlowExecution |
| 流程定义 | 分散在多个 Controller 中 | 集中在一个 XML/Java 配置中 |
| 页面导航 | 通过返回视图名或重定向实现 | 通过 Transition 声明式定义 |
| 数据共享 | 通过 Model、Session、RedirectAttributes | 通过 FlowScope、ViewScope 等 |
| 适用场景 | 简单的 CRUD 操作 | 多步骤、有状态的业务流程 |
| 可视化 | 流程逻辑分散在代码中 | 流程定义本身就是一份可视化文档 |
| 可测试性 | 需要 Mock HTTP 请求 | 可以直接测试流程定义 |
从架构设计的角度来看,Webflow 的核心价值在于它将"流程编排"从"业务逻辑"中分离出来。在传统 MVC 中,流程的跳转逻辑(先做什么、后做什么、在什么条件下跳转到哪个页面)分散在多个 Controller 的方法中,开发者需要阅读大量代码才能理解完整的流程。而在 Webflow 中,流程的编排逻辑被集中在一个 XML 文件中,开发者可以一目了然地看到整个流程的结构。
这种分离也带来了一个重要的架构优势:流程的变更不需要修改业务逻辑代码。如果需要调整流程的顺序或添加新的步骤,只需要修改 Webflow 的 XML 定义,而不需要修改任何 Java 代码。
第二章 CAS Webflow 配置方式
CAS 在不同的版本中,Webflow 的配置方式经历了显著的演进。从早期的 XML 配置,到中间版本的 Groovy 配置,再到最新版本的 Java Config 配置,每种方式都有其适用的场景和优缺点。理解这种演进,有助于我们在不同版本的 CAS 项目中选择最合适的配置方式。
2.1 XML 方式(传统)
XML 配置是 Spring Webflow 最原始也是最经典的配置方式。在 CAS 5.3 及更早版本中,Webflow 的流程定义完全依赖 XML 文件。
2.1.1 login-webflow.xml
CAS 的核心登录流程定义在 login-webflow.xml 文件中。在 CAS 5.3 的 Overlay 项目中,这个文件位于 cas-server-core-webflow JAR 包内的 META-INF/cas.webflow/ 目录下。
如果需要完全自定义登录流程,可以在 Overlay 项目中创建一个同名文件,放在 src/main/resources/META-INF/cas.webflow/ 目录下。CAS 启动时会优先加载 Overlay 中的文件,从而覆盖默认的流程定义。
2.1.2 spring-configuration.xml
除了流程定义文件外,CAS 5.3 还使用 XML 文件来配置 Spring Bean。在我们的实际项目中,spring-common.xml 文件定义了数据源、事务管理器、MyBatis 配置等基础设施组件:
xml
<!-- spring-common.xml 示例(简化版) -->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="...">
<!-- 数据源配置 -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName" value="com.mysql.cj.jdbc.Driver" />
<property name="url" value="jdbc:mysql://localhost:3306/cas_db" />
<property name="username" value="cas_user" />
<property name="password" value="cas_password" />
</bean>
<!-- MyBatis 配置 -->
<bean id="sqlSessionFactory"
class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="mapperLocations" value="classpath:mapper/**/*.xml" />
</bean>
<!-- 事务管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" />
</bean>
<tx:annotation-driven transaction-manager="transactionManager" />
</beans>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2.1.3 XML 配置的优缺点
优点:
- 配置与代码完全分离,修改配置不需要重新编译
- 流程定义的可读性好,XML 结构清晰
- 与 Spring 生态的其他 XML 配置方式一致
缺点:
- XML 文件容易变得冗长,维护成本高
- 缺乏编译时类型检查,配置错误只能在运行时发现
- IDE 支持不如 Java 代码好(自动补全、重构等)
- 在 CAS 6.x 及更高版本中逐渐被淘汰
2.2 Groovy 方式
从 CAS 5.3 开始,CAS 引入了对 Groovy 配置的支持。Groovy 配置文件 deployerConfigContext.groovy 允许开发者使用 Groovy 语法来定义 Spring Bean。
2.2.1 deployerConfigContext.groovy
在我们的 CAS 5.3 Overlay 项目中,deployerConfigContext.groovy 文件的内容如下:
groovy
// deployerConfigContext.groovy(简化版)
beans {
xmlns([context:'http://www.springframework.org/schema/context'])
xmlns([lang:'http://www.springframework.org/schema/lang'])
xmlns([util:'http://www.springframework.org/schema/util'])
// 定义自定义的认证 Handler
beanDefinitionRegistry.addBeanDefinition('customAuthHandler',
BeanDefinitionBuilder.rootBeanDefinition('cc.bima.cas.auth.CustomAuthenticationHandler')
.addPropertyReference('dataSource', 'dataSource')
.addPropertyReference('passwordEncoder', 'passwordEncoder')
.getBeanDefinition()
)
// 定义密码编码器
beanDefinitionRegistry.addBeanDefinition('passwordEncoder',
BeanDefinitionBuilder.rootBeanDefinition('org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder')
.addConstructorArgValue(12)
.getBeanDefinition()
)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2.2.2 Groovy 配置的优缺点
优点:
- 比纯 XML 更灵活,可以使用编程式的 Bean 定义
- Groovy 语法简洁,减少样板代码
- 支持条件逻辑和循环,可以动态生成配置
- 在 CAS 5.3 和 6.6 中都支持
缺点:
- 需要引入 Groovy 依赖
- IDE 对 Groovy 的支持不如 Java
- 调试困难,错误信息不够友好
- 在 CAS 7.3 中已不推荐使用
2.3 Java Config 方式(7.3 推荐)
CAS 7.3 是一个里程碑式的版本,它在架构上进行了全面的现代化改造。其中最重要的变化之一就是全面拥抱 Spring Boot 的自动配置机制,推荐使用 Java Config 来替代 XML 和 Groovy 配置。
2.3.1 CAS 7.3 的配置体系
在 CAS 7.3 中,Webflow 的配置通过 CasWebflowConfigurer 接口及其实现类来完成。CAS 提供了一系列内置的 Configurer,每个 Configurer 负责配置 Webflow 的一个特定方面。
CAS 7.3 的 application.yml 配置文件示例(简化版):
yaml
# application.yml - CAS 7.3 配置示例
cas:
server:
name: https://cas.example.com:8443
prefix: ${cas.server.name}/cas
webflow:
crypto:
enabled: false
ticket:
registry:
redis:
enabled: true
host: 192.168.1.30
port: 6379
database: 0
tgc:
crypto:
enabled: false1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2.3.2 Java Config Webflow 定制示例
在 CAS 7.3 中,自定义 Webflow 的推荐方式是创建一个 @Configuration 类,注册自定义的 CasWebflowConfigurer:
java
// CAS 7.3 Java Config 方式自定义 Webflow
package cc.bima.cas.config;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
@Configuration
@AutoConfiguration
public class CasWebflowCustomConfiguration {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public CasWebflowConfigurer captchaWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
final CaptchaWebflowConfigurer configurer =
new CaptchaWebflowConfigurer(flowBuilderServices, loginFlowDefinitionRegistry);
return configurer;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2.3.3 Java Config 的优缺点
优点:
- 完整的类型安全,编译时即可发现配置错误
- 优秀的 IDE 支持(自动补全、重构、导航)
- 可以利用 Java 的全部语言特性(泛型、Lambda、Stream 等)
- 与 Spring Boot 的自动配置机制完美集成
- 易于编写单元测试
缺点:
- 修改配置需要重新编译
- 对于简单的配置变更,Java 代码可能显得过于繁琐
- 学习曲线相对较陡
2.4 三种配置方式的对比与选型建议
| 维度 | XML | Groovy | Java Config |
|---|---|---|---|
| CAS 5.3 支持 | 完全支持 | 支持 | 部分支持 |
| CAS 6.6 支持 | 支持(不推荐) | 支持 | 推荐 |
| CAS 7.3 支持 | 不推荐 | 不推荐 | 完全支持 |
| 类型安全 | 无 | 部分 | 完整 |
| IDE 支持 | 一般 | 一般 | 优秀 |
| 热更新 | 支持 | 支持 | 不支持 |
| 学习曲线 | 低 | 中 | 高 |
| 维护成本 | 高 | 中 | 低 |
选型建议:
- CAS 5.3 项目: 如果项目已经使用 XML 配置,可以继续使用。对于新的定制需求,建议使用 Groovy 配置,因为它更灵活且代码量更少。
- CAS 6.6 项目: 推荐使用 Java Config 方式。虽然 Groovy 仍然支持,但 Java Config 是未来的方向。
- CAS 7.3 项目: 必须使用 Java Config 方式。XML 和 Groovy 配置在 CAS 7.3 中已不推荐使用。
- 跨版本项目: 如果需要同时支持多个 CAS 版本,建议使用 Java Config 方式,因为它在所有版本中都可以使用(通过适当的适配)。
第三章 自定义 Webflow 配置器
自定义 Webflow 配置器是 CAS 提供的核心扩展点,它允许开发者在不修改 CAS 源码的前提下,对登录流程进行深度定制。本章将深入分析 CAS Webflow 配置器的架构设计,并通过实际案例演示如何创建和使用自定义配置器。
3.1 CAS Webflow 配置器架构
CAS 的 Webflow 配置器架构基于一个经典的设计模式:模板方法模式(Template Method Pattern)。
在 CAS 的内部实现中,AbstractCasWebflowConfigurer 是所有 Webflow 配置器的基类。它定义了配置 Webflow 的标准流程(模板方法),而具体的配置逻辑则由子类实现。
AbstractCasWebflowConfigurer (抽象基类)
|
+-- DefaultLoginWebflowConfigurer (默认登录流程配置器)
| |
| +-- CaptchaWebflowConfigurer (验证码配置器 - 我们的自定义实现)
|
+-- DefaultLogoutWebflowConfigurer (默认登出流程配置器)
|
+-- RememberMeWebflowConfigurer (记住我配置器)
|
+-- CustomWebflowConfigurer (其他自定义配置器)1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
AbstractCasWebflowConfigurer 的核心方法包括:
doInitialize():初始化方法,在配置器被加载时调用doExecute():执行配置的方法,这是子类需要重写的核心方法registerWebflowConfigurers():注册子配置器createFlowVariable():在 Flow 中创建变量createViewState():创建 ViewStatecreateActionState():创建 ActionStatecreateDecisionState():创建 DecisionStatecreateTransition():创建 TransitiongetLoginFlow():获取登录流程定义
3.2 继承 DefaultLoginWebflowConfigurer
在 CAS 5.3 和 6.6 中,自定义 Webflow 配置器通常继承 DefaultLoginWebflowConfigurer。在 CAS 7.3 中,推荐直接继承 AbstractCasWebflowConfigurer 或实现 CasWebflowConfigurer 接口。
以下是继承 DefaultLoginWebflowConfigurer 的基本模式:
java
package cc.bima.cas.config.webflow;
import org.apereo.cas.web.flow.CasWebflowConstants;
import org.apereo.cas.web.flow.config.AbstractCasWebflowConfigurer;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
/**
* 自定义登录 Webflow 配置器
*
* 职责:
* 1. 在登录表单中添加验证码字段
* 2. 注册验证码校验 Action
* 3. 修改登录提交的 Transition
*/
public class CustomLoginWebflowConfigurer extends AbstractCasWebflowConfigurer {
/** 登录表单 ViewState 的 ID */
private static final String VIEW_STATE_LOGIN_FORM = "viewLoginForm";
/** 登录表单的视图名称 */
private static final String VIEW_ID_CAS_LOGIN = "casLoginView";
/** 认证 Action 的 ID */
private static final String ACTION_ID_REAL_SUBMIT = "realSubmit";
public CustomLoginWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
super(flowBuilderServices, loginFlowDefinitionRegistry);
}
@Override
protected void doInitialize() throws Exception {
// 可选:在这里执行初始化逻辑
super.doInitialize();
}
@Override
public void doExecute() throws Exception {
// 获取登录流程定义
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
logger.warn("登录流程未找到,跳过自定义配置");
return;
}
// 在这里添加自定义的流程配置
registerCustomLoginFields(loginFlow);
}
private void registerCustomLoginFields(final Flow loginFlow) {
// 具体的配置逻辑将在后续章节中展开
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
3.3 doExecute() 方法重写详解
doExecute() 是自定义 Webflow 配置器的核心方法。当 CAS 启动时,会按顺序调用所有注册的 WebflowConfigurer 的 doExecute() 方法,从而将自定义的流程配置应用到默认的登录流程中。
在 doExecute() 方法中,我们可以执行以下操作:
- 获取流程定义: 通过
getLoginFlow()获取当前的登录流程定义对象。 - 查询现有节点: 通过
loginFlow.getState()查询流程中已存在的 State。 - 创建新节点: 使用
createViewState()、createActionState()、createDecisionState()等方法创建新的 State。 - 修改现有节点: 直接操作 State 对象,添加或修改 Transition、Binder 配置等。
- 注册 Flow 变量: 使用
createFlowVariable()在 Flow 中注册新的变量。
以下是一个完整的 doExecute() 方法示例,展示了常见的操作:
java
@Override
public void doExecute() throws Exception {
// ==================== 步骤 1:获取流程定义 ====================
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
logger.warn("登录流程未找到,跳过自定义配置");
return;
}
// ==================== 步骤 2:修改现有的 ViewState ====================
// 获取登录表单的 ViewState
final ViewState viewLoginForm = (ViewState) loginFlow.getState(VIEW_STATE_LOGIN_FORM);
if (viewLoginForm != null) {
// 修改 Binder 配置,添加自定义字段绑定
final BinderConfiguration binder = viewLoginForm.getBinderConfiguration();
if (binder != null) {
binder.addBinding(new BinderConfiguration.Binding(
"captchaResponse", // 字段名
null, // 转换器
true // 是否必填
));
}
}
// ==================== 步骤 3:创建新的 ActionState ====================
// 创建验证码校验 ActionState
final ActionState captchaValidateState = createActionState(
loginFlow,
"captchaValidate", // State ID
createEvaluateAction("captchaValidateAction") // Action 表达式
);
// 为新的 ActionState 添加 Transition
captchaValidateState.getTransitionSet().add(
createTransition("success", ACTION_ID_REAL_SUBMIT) // 验证码正确,进入认证
);
captchaValidateState.getTransitionSet().add(
createTransition("error", VIEW_STATE_LOGIN_FORM) // 验证码错误,返回登录表单
);
// ==================== 步骤 4:修改现有 Transition ====================
// 将登录表单的 submit Transition 从直接跳转到 realSubmit
// 改为跳转到 captchaValidate
final ViewState loginViewState = (ViewState) loginFlow.getState(VIEW_STATE_LOGIN_FORM);
if (loginViewState != null) {
// 移除原有的 submit -> realSubmit Transition
loginViewState.getTransitionSet().remove(
loginViewState.getTransition("submit")
);
// 添加新的 submit -> captchaValidate Transition
loginViewState.getTransitionSet().add(
createTransition("submit", "captchaValidate")
);
}
// ==================== 步骤 5:注册 Flow 变量 ====================
// 注册验证码相关的 Flow 变量
registerFlowVariable(loginFlow, "captchaEnabled", Boolean.TRUE);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
3.4 注册自定义 Action
在 Webflow 中,Action 是执行业务逻辑的核心单元。注册自定义 Action 是 Webflow 定制中最常见的操作之一。
3.4.1 Action 的实现
自定义 Action 需要继承 AbstractAction 类,并重写 doExecute() 方法:
java
package cc.bima.cas.web.flow.action;
import org.springframework.webflow.action.AbstractAction;
import org.springframework.webflow.execution.Event;
import org.springframework.webflow.execution.RequestContext;
/**
* 验证码校验 Action
*
* 职责:
* 1. 从 FlowScope 中获取用户输入的验证码
* 2. 从 Session 中获取正确的验证码
* 3. 比对两者是否一致
* 4. 返回校验结果事件
*/
public class CaptchaValidateAction extends AbstractAction {
/** 事件:校验成功 */
private static final String EVENT_SUCCESS = "success";
/** 事件:校验失败 */
private static final String EVENT_ERROR = "error";
/** 验证码存储在 Session 中的属性名 */
private static final String SESSION_CAPTCHA_KEY = "CAPTCHA_SESSION_KEY";
@Override
protected Event doExecute(final RequestContext context) throws Exception {
// 从 RequestScope 获取用户输入的验证码
final String userInputCaptcha = context.getRequestScope()
.getString("captchaResponse");
// 从 FlowScope 获取正确的验证码
final String sessionCaptcha = (String) context.getFlowScope()
.get(SESSION_CAPTCHA_KEY);
// 校验验证码
if (isValidCaptcha(userInputCaptcha, sessionCaptcha)) {
return new Event(this, EVENT_SUCCESS);
} else {
// 将错误信息存入 RequestScope,以便在视图中显示
context.getRequestScope().put("captchaError", "验证码错误,请重新输入");
return new Event(this, EVENT_ERROR);
}
}
/**
* 验证码比对逻辑
*
* @param userInput 用户输入的验证码
* @param sessionCaptcha Session 中存储的正确验证码
* @return 是否匹配
*/
private boolean isValidCaptcha(final String userInput,
final String sessionCaptcha) {
if (userInput == null || sessionCaptcha == null) {
return false;
}
// 忽略大小写比较
return userInput.equalsIgnoreCase(sessionCaptcha);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
3.4.2 Action 的 Spring Bean 注册
自定义 Action 需要注册为 Spring Bean,以便 Webflow 可以通过 EL 表达式引用它。
CAS 5.3/6.6 方式(XML):
xml
<bean id="captchaValidateAction"
class="cc.bima.cas.web.flow.action.CaptchaValidateAction" />1
2
2
CAS 7.3 方式(Java Config):
java
@Configuration
@AutoConfiguration
public class CaptchaActionConfiguration {
@Bean
public CaptchaValidateAction captchaValidateAction() {
return new CaptchaValidateAction();
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
3.5 修改 Transition
修改 Transition 是 Webflow 定制中非常常见的操作。以下是几种常见的 Transition 修改场景。
3.5.1 在现有 Transition 之前插入新的 State
这是验证码集成中最常见的模式。我们需要在登录表单提交后、认证执行前插入一个验证码校验步骤。
原始流程:
viewLoginForm --submit--> realSubmit1
修改后的流程:
viewLoginForm --submit--> captchaValidate --success--> realSubmit
--error--> viewLoginForm1
2
2
代码实现:
java
// 获取登录表单 ViewState
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");
// 创建验证码校验 ActionState
final ActionState captchaValidate = createActionState(
loginFlow,
"captchaValidate",
createEvaluateAction("captchaValidateAction")
);
// 为验证码校验添加 Transition
captchaValidate.getTransitionSet().add(
createTransition(CasWebflowConstants.TRANSITION_ID_SUCCESS, "realSubmit")
);
captchaValidate.getTransitionSet().add(
createTransition(CasWebflowConstants.TRANSITION_ID_ERROR, "viewLoginForm")
);
// 修改登录表单的 submit Transition
final Transition submitTransition = viewLoginForm.getTransition("submit");
if (submitTransition != null) {
// 方式 1:直接修改目标 State
// 注意:Webflow 的 Transition 对象一旦创建,其目标 State 不可直接修改
// 因此需要先移除旧的 Transition,再创建新的
viewLoginForm.getTransitionSet().remove(submitTransition);
}
viewLoginForm.getTransitionSet().add(
createTransition("submit", "captchaValidate")
);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
3.5.2 添加全局异常处理
在自定义配置器中添加全局异常处理:
java
// 添加全局 Transition
loginFlow.getGlobalTransitionSet().add(
createTransitionForException(
"java.lang.IllegalStateException",
"viewLoginForm"
)
);1
2
3
4
5
6
7
2
3
4
5
6
7
3.6 添加 Decision State
Decision State 用于在流程中添加条件分支。以下是一个实际的例子:根据配置决定是否启用验证码。
java
// 创建验证码检查 DecisionState
final DecisionState captchaCheckState = createDecisionState(
loginFlow,
"captchaCheck",
"flowScope.captchaEnabled == true",
"captchaValidate", // 启用验证码时的目标
"realSubmit" // 未启用验证码时的目标
);
// 修改登录表单的 submit Transition
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");
viewLoginForm.getTransitionSet().remove(viewLoginForm.getTransition("submit"));
viewLoginForm.getTransitionSet().add(
createTransition("submit", "captchaCheck")
);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这样,通过修改 flowScope.captchaEnabled 的值,就可以动态控制是否启用验证码功能,无需修改流程定义。
3.7 配置器的注册与加载机制
自定义的 WebflowConfigurer 需要被 CAS 的配置加载机制发现和注册。不同版本的 CAS 有不同的注册方式。
3.7.1 CAS 5.3 注册方式
在 CAS 5.3 中,通过 Spring XML 配置注册:
xml
<!-- 在 spring-configuration.xml 或自定义的 XML 配置文件中 -->
<bean id="customWebflowConfigurer"
class="cc.bima.cas.config.webflow.CustomLoginWebflowConfigurer">
<constructor-arg index="0" ref="flowBuilderServices" />
<constructor-arg index="1" ref="loginFlowRegistry" />
</bean>1
2
3
4
5
6
2
3
4
5
6
3.7.2 CAS 6.6 注册方式
在 CAS 6.6 中,可以通过 @Configuration 类注册:
java
@Configuration("customWebflowConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CustomWebflowConfiguration implements BeanPostProcessor {
@Autowired
@Qualifier("loginFlowRegistry")
private FlowDefinitionRegistry loginFlowRegistry;
@Autowired
@Qualifier("flowBuilderServices")
private FlowBuilderServices flowBuilderServices;
@Bean
public CasWebflowConfigurer customWebflowConfigurer() {
final CustomLoginWebflowConfigurer configurer =
new CustomLoginWebflowConfigurer(flowBuilderServices, loginFlowRegistry);
return configurer;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
3.7.3 CAS 7.3 注册方式
在 CAS 7.3 中,使用 @AutoConfiguration 和 @ConditionalOnMissingBean 注解:
java
package cc.bima.cas.config;
import org.apereo.cas.web.flow.CasWebflowConfigurer;
import org.apereo.cas.web.flow.config.CasWebflowCustomConfiguration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
@AutoConfiguration
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustomCasWebflowConfiguration {
@Bean
@ConditionalOnMissingBean(name = "captchaWebflowConfigurer")
public CasWebflowConfigurer captchaWebflowConfigurer(
@Qualifier("loginFlowDefinitionRegistry")
final FlowDefinitionRegistry loginFlowDefinitionRegistry,
@Qualifier("flowBuilderServices")
final FlowBuilderServices flowBuilderServices) {
return new CaptchaWebflowConfigurer(flowBuilderServices, loginFlowDefinitionRegistry);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
同时,需要在 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中注册这个自动配置类:
cc.bima.cas.config.CustomCasWebflowConfiguration1
3.7.4 配置器加载顺序
当多个 WebflowConfigurer 同时存在时,它们的执行顺序非常重要。CAS 使用 Spring 的 @Order 注解来控制配置器的执行顺序:
@Order(Ordered.HIGHEST_PRECEDENCE):最先执行@Order(Ordered.LOWEST_PRECEDENCE):最后执行
重要提示: 如果你的自定义配置器依赖于 CAS 默认配置器创建的 State,你需要确保 CAS 默认配置器先执行。在这种情况下,应该使用较低的优先级(较大的 Order 值)。
第四章 验证码集成实战
验证码集成是 CAS 定制开发中最常见的需求之一,也是最能体现 Webflow 定制能力的实战案例。本章将完整地演示如何在 CAS Overlay 项目中集成验证码功能,涵盖从后端流程定制到前端页面展示的全部环节。
4.1 验证码需求分析
在实际项目中,验证码的需求通常包括以下几个方面:
- 登录表单中显示验证码图片: 用户在输入用户名和密码的同时,需要输入图片中显示的验证码。
- 验证码的生成与存储: 服务端生成随机验证码图片,并将正确的验证码文本存储在服务端(Session 或 Redis)。
- 验证码的校验: 用户提交登录表单时,先校验验证码是否正确,再执行认证操作。
- 验证码的刷新: 用户可以点击验证码图片或"换一张"链接来刷新验证码。
- 验证码的开关控制: 管理员可以通过配置开启或关闭验证码功能。
- 安全考量: 验证码需要有一定的复杂度,防止被自动化工具破解。
4.2 CaptchaWebflowConfigurer 设计
基于上述需求分析,我们设计 CaptchaWebflowConfigurer 类。这个类继承自 AbstractCasWebflowConfigurer,负责修改登录流程以集成验证码功能。
以下是完整的 CaptchaWebflowConfigurer 实现(适用于 CAS 5.3/6.6,教学简化版):
java
package cc.bima.cas.config.webflow;
import org.apereo.cas.web.flow.CasWebflowConstants;
import org.apereo.cas.web.flow.config.AbstractCasWebflowConfigurer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ActionState;
import org.springframework.webflow.engine.DecisionState;
import org.springframework.webflow.engine.Transition;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
/**
* 验证码 Webflow 配置器
*
* 设计思路:
* 1. 在 viewLoginForm 的 Binder 中添加 captchaResponse 字段绑定
* 2. 创建 captchaValidate ActionState 用于验证码校验
* 3. 创建 captchaEnabled DecisionState 用于动态控制验证码开关
* 4. 修改 viewLoginForm 的 submit Transition,插入验证码校验步骤
*
* 流程变化:
* 原始流程:viewLoginForm --submit--> realSubmit
* 修改后: viewLoginForm --submit--> captchaCheck
* |-- (enabled) --> captchaValidate
* | |-- (success) --> realSubmit
* | |-- (error) --> viewLoginForm
* |-- (disabled) --> realSubmit
*/
public class CaptchaWebflowConfigurer extends AbstractCasWebflowConfigurer {
private static final Logger logger = LoggerFactory.getLogger(CaptchaWebflowConfigurer.class);
/** 登录表单 ViewState ID */
private static final String STATE_ID_LOGIN_FORM = "viewLoginForm";
/** 认证 Action State ID */
private static final String STATE_ID_REAL_SUBMIT = "realSubmit";
/** 验证码校验 Action State ID */
private static final String STATE_ID_CAPTCHA_VALIDATE = "captchaValidate";
/** 验证码开关 Decision State ID */
private static final String STATE_ID_CAPTCHA_CHECK = "captchaCheck";
/** 验证码字段名 */
private static final String FIELD_CAPTCHA_RESPONSE = "captchaResponse";
/** 验证码开关 Flow 变量名 */
private static final String VAR_CAPTCHA_ENABLED = "captchaEnabled";
/** 提交事件名 */
private static final String EVENT_SUBMIT = "submit";
/** 成功事件名 */
private static final String EVENT_SUCCESS = "success";
/** 错误事件名 */
private static final String EVENT_ERROR = "error";
public CaptchaWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
super(flowBuilderServices, loginFlowDefinitionRegistry);
}
@Override
protected void doInitialize() throws Exception {
super.doInitialize();
logger.info("CaptchaWebflowConfigurer 初始化开始");
}
@Override
public void doExecute() throws Exception {
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
logger.warn("登录流程未找到,跳过验证码配置");
return;
}
logger.info("开始配置验证码 Webflow 定制");
// 步骤 1:注册 Flow 变量
registerCaptchaFlowVariable(loginFlow);
// 步骤 2:修改登录表单的 Binder 配置
modifyLoginFormBinder(loginFlow);
// 步骤 3:创建验证码校验 ActionState
createCaptchaValidateState(loginFlow);
// 步骤 4:创建验证码开关 DecisionState
createCaptchaCheckState(loginFlow);
// 步骤 5:修改登录表单的 submit Transition
modifyLoginFormTransition(loginFlow);
logger.info("验证码 Webflow 定制配置完成");
}
/**
* 步骤 1:注册 Flow 变量
*/
private void registerCaptchaFlowVariable(final Flow loginFlow) {
// 注册验证码开关变量(默认关闭,可通过配置修改)
registerFlowVariable(loginFlow, VAR_CAPTCHA_ENABLED, Boolean.FALSE);
logger.debug("已注册 Flow 变量: {}", VAR_CAPTCHA_ENABLED);
}
/**
* 步骤 2:修改登录表单的 Binder 配置
* 在登录表单的 Binder 中添加 captchaResponse 字段绑定
*/
private void modifyLoginFormBinder(final Flow loginFlow) {
final ViewState viewLoginForm = (ViewState) loginFlow.getState(STATE_ID_LOGIN_FORM);
if (viewLoginForm == null) {
logger.error("未找到登录表单 ViewState: {}", STATE_ID_LOGIN_FORM);
return;
}
final BinderConfiguration binder = viewLoginForm.getBinderConfiguration();
if (binder != null) {
// 添加验证码字段绑定(非必填,因为验证码可能被关闭)
binder.addBinding(new BinderConfiguration.Binding(
FIELD_CAPTCHA_RESPONSE,
null,
false
));
logger.debug("已在登录表单 Binder 中添加字段: {}", FIELD_CAPTCHA_RESPONSE);
}
}
/**
* 步骤 3:创建验证码校验 ActionState
*/
private void createCaptchaValidateState(final Flow loginFlow) {
final ActionState captchaValidateState = createActionState(
loginFlow,
STATE_ID_CAPTCHA_VALIDATE,
createEvaluateAction("captchaValidateAction")
);
// 验证码校验成功 -> 进入认证流程
captchaValidateState.getTransitionSet().add(
createTransition(EVENT_SUCCESS, STATE_ID_REAL_SUBMIT)
);
// 验证码校验失败 -> 返回登录表单
captchaValidateState.getTransitionSet().add(
createTransition(EVENT_ERROR, STATE_ID_LOGIN_FORM)
);
logger.debug("已创建验证码校验 ActionState: {}", STATE_ID_CAPTCHA_VALIDATE);
}
/**
* 步骤 4:创建验证码开关 DecisionState
*/
private void createCaptchaCheckState(final Flow loginFlow) {
final DecisionState captchaCheckState = createDecisionState(
loginFlow,
STATE_ID_CAPTCHA_CHECK,
"flowScope." + VAR_CAPTCHA_ENABLED + " == true",
STATE_ID_CAPTCHA_VALIDATE,
STATE_ID_REAL_SUBMIT
);
logger.debug("已创建验证码开关 DecisionState: {}", STATE_ID_CAPTCHA_CHECK);
}
/**
* 步骤 5:修改登录表单的 submit Transition
* 将 submit 事件的目标从 realSubmit 改为 captchaCheck
*/
private void modifyLoginFormTransition(final Flow loginFlow) {
final ViewState viewLoginForm = (ViewState) loginFlow.getState(STATE_ID_LOGIN_FORM);
if (viewLoginForm == null) {
logger.error("未找到登录表单 ViewState: {}", STATE_ID_LOGIN_FORM);
return;
}
// 移除原有的 submit -> realSubmit Transition
final Transition existingTransition = viewLoginForm.getTransition(EVENT_SUBMIT);
if (existingTransition != null) {
viewLoginForm.getTransitionSet().remove(existingTransition);
logger.debug("已移除原有 Transition: {} -> {}",
STATE_ID_LOGIN_FORM, existingTransition.getTargetStateId());
}
// 添加新的 submit -> captchaCheck Transition
viewLoginForm.getTransitionSet().add(
createTransition(EVENT_SUBMIT, STATE_ID_CAPTCHA_CHECK)
);
logger.debug("已添加新 Transition: {} -> {}",
STATE_ID_LOGIN_FORM, STATE_ID_CAPTCHA_CHECK);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
4.3 在登录表单中绑定验证码字段
在 Webflow 中,表单字段与 Model 对象的绑定是通过 BinderConfiguration 实现的。当用户提交表单时,Webflow 使用 Spring 的 DataBinder 机制将 HTTP 请求参数绑定到 Model 对象的属性上。
在 CAS 的登录表单中,默认的 Model 对象是 credential(类型为 UsernamePasswordCredential)。UsernamePasswordCredential 只有 username 和 password 两个属性,没有 captchaResponse 属性。
因此,我们需要创建一个自定义的 Credential 类来扩展默认的 UsernamePasswordCredential:
java
package cc.bima.cas.credential;
import org.apereo.cas.authentication.UsernamePasswordCredential;
import javax.validation.constraints.NotNull;
/**
* 自定义认证凭证,扩展默认的 UsernamePasswordCredential
* 添加验证码字段
*/
public class CaptchaCredential extends UsernamePasswordCredential {
/** 序列化版本号 */
private static final long serialVersionUID = 1L;
/** 用户输入的验证码 */
@NotNull(message = "验证码不能为空")
private String captchaResponse;
/**
* 获取验证码
* @return 验证码文本
*/
public String getCaptchaResponse() {
return captchaResponse;
}
/**
* 设置验证码
* @param captchaResponse 验证码文本
*/
public void setCaptchaResponse(final String captchaResponse) {
this.captchaResponse = captchaResponse;
}
@Override
public String toString() {
return new StringBuilder()
.append(super.toString())
.append(", captchaResponse=[PROTECTED]")
.toString();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
4.4 BinderConfiguration 配置
BinderConfiguration 定义了表单字段与 Model 对象属性之间的绑定规则。在 CaptchaWebflowConfigurer 中,我们已经通过代码方式添加了验证码字段的绑定:
java
binder.addBinding(new BinderConfiguration.Binding(
"captchaResponse", // 表单字段名(对应 HTML input 的 name 属性)
null, // 类型转换器(null 表示使用默认转换器)
false // 是否必填(false 表示非必填,因为验证码可能被关闭)
));1
2
3
4
5
2
3
4
5
BinderConfiguration.Binding 的构造参数详解:
- name(字段名): 对应 HTML 表单中
<input>元素的name属性。Webflow 会从 HTTP 请求中获取同名参数的值,并绑定到 Model 对象的对应属性上。 - converter(类型转换器): 用于将 String 类型的请求参数转换为 Model 属性的类型。对于 String 类型的属性,可以使用
null(使用默认转换器)。对于 Date、Number 等类型,需要指定相应的转换器。 - required(是否必填): 如果为
true,Webflow 会在绑定阶段检查该字段是否为空。如果为空,会自动添加验证错误。
注意事项:
- Binder 配置中的字段名必须与 HTML 表单中的
name属性一致。 - Binder 配置中的字段名必须与 Model 对象的属性名一致(遵循 JavaBean 命名规范)。
- 如果字段设置为非必填但验证码功能已开启,需要在验证码校验 Action 中手动检查字段是否为空。
4.5 Flow 变量绑定
在 Webflow 中,Flow 变量是在 FlowScope 中存储的数据,在整个流程执行期间有效。在验证码集成中,我们使用 Flow 变量来存储以下数据:
- captchaEnabled: 验证码开关标志,控制是否启用验证码功能。
- captchaId: 当前验证码的唯一标识,用于从服务端获取验证码图片。
在 CaptchaWebflowConfigurer 中注册 Flow 变量:
java
// 注册验证码开关变量
registerFlowVariable(loginFlow, "captchaEnabled", Boolean.FALSE);
// 注册验证码 ID 变量
registerFlowVariable(loginFlow, "captchaId", UUID.randomUUID().toString());1
2
3
4
5
2
3
4
5
在 Java 代码中操作 Flow 变量:
java
// 读取验证码开关
final Boolean captchaEnabled = (Boolean) context.getFlowScope().get("captchaEnabled");
// 设置验证码 ID
context.getFlowScope().put("captchaId", UUID.randomUUID().toString());1
2
3
4
5
2
3
4
5
在 Thymeleaf 模板中访问 Flow 变量:
html
<!-- 根据验证码开关决定是否显示验证码 -->
<div th:if="${flowScope.captchaEnabled}">
<label for="captchaResponse">验证码</label>
<input type="text" id="captchaResponse" name="captchaResponse"
class="form-control" placeholder="请输入验证码" />
<img th:src="@{/captcha/generate(id=${flowScope.captchaId})}"
alt="验证码" id="captchaImage"
onclick="refreshCaptcha()" style="cursor: pointer;" />
</div>1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
4.6 ViewState 操作
在验证码集成中,对 ViewState 的操作主要包括以下几个方面:
4.6.1 获取 ViewState
java
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");1
4.6.2 修改 ViewState 的 Binder
java
final BinderConfiguration binder = viewLoginForm.getBinderConfiguration();
binder.addBinding(new BinderConfiguration.Binding("captchaResponse", null, false));1
2
2
4.6.3 修改 ViewState 的 Transition
java
// 移除原有 Transition
final Transition oldTransition = viewLoginForm.getTransition("submit");
if (oldTransition != null) {
viewLoginForm.getTransitionSet().remove(oldTransition);
}
// 添加新 Transition
viewLoginForm.getTransitionSet().add(
createTransition("submit", "captchaCheck")
);1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
4.6.4 修改 ViewState 的 on-entry Action
在某些场景下,我们可能需要在进入登录表单时执行额外的逻辑(如生成验证码 ID)。可以通过以下方式实现:
java
// 获取 ViewState 的 EntryActionList
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");
// 添加 on-entry Action
viewLoginForm.getEntryActionList().add(
createActionState(
loginFlow,
"generateCaptchaId",
createEvaluateAction("generateCaptchaIdAction")
)
);1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
4.7 验证码生成与校验逻辑
4.7.1 验证码生成
验证码的生成通常由一个独立的 Controller 负责。以下是一个简化的验证码生成 Controller:
java
package cc.bima.cas.web.controller;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* 验证码生成 Controller
*/
@Controller
@RequestMapping("/captcha")
public class CaptchaController {
/** 验证码图片宽度 */
private static final int CAPTCHA_WIDTH = 120;
/** 验证码图片高度 */
private static final int CAPTCHA_HEIGHT = 40;
/** 验证码字符个数 */
private static final int CAPTCHA_LENGTH = 4;
/** 验证码 Session Key */
private static final String CAPTCHA_SESSION_KEY = "CAPTCHA_SESSION_KEY";
/** 验证码字符源 */
private static final String CAPTCHA_CHARS =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
private final Random random = new Random();
/**
* 生成验证码图片
*/
@GetMapping("/generate")
public void generateCaptcha(
final HttpServletRequest request,
final HttpServletResponse response) throws IOException {
// 设置响应类型
response.setContentType("image/jpeg");
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
// 生成验证码文本
final String captchaText = generateCaptchaText();
// 将验证码文本存入 Session
final HttpSession session = request.getSession(true);
session.setAttribute(CAPTCHA_SESSION_KEY, captchaText);
// 设置验证码过期时间(5 分钟)
session.setMaxInactiveInterval((int) TimeUnit.MINUTES.toSeconds(5));
// 生成验证码图片
final BufferedImage image = generateCaptchaImage(captchaText);
// 输出图片
ImageIO.write(image, "JPEG", response.getOutputStream());
}
/**
* 生成验证码文本
*/
private String generateCaptchaText() {
final StringBuilder sb = new StringBuilder(CAPTCHA_LENGTH);
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
sb.append(CAPTCHA_CHARS.charAt(random.nextInt(CAPTCHA_CHARS.length())));
}
return sb.toString();
}
/**
* 生成验证码图片
*/
private BufferedImage generateCaptchaImage(final String text) {
final BufferedImage image = new BufferedImage(
CAPTCHA_WIDTH, CAPTCHA_HEIGHT, BufferedImage.TYPE_INT_RGB);
final Graphics2D g2d = image.createGraphics();
// 设置背景色
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, CAPTCHA_WIDTH, CAPTCHA_HEIGHT);
// 设置字体
g2d.setFont(new Font("Arial", Font.BOLD, 28));
// 绘制干扰线
for (int i = 0; i < 6; i++) {
g2d.setColor(getRandomColor());
g2d.drawLine(
random.nextInt(CAPTCHA_WIDTH),
random.nextInt(CAPTCHA_HEIGHT),
random.nextInt(CAPTCHA_WIDTH),
random.nextInt(CAPTCHA_HEIGHT)
);
}
// 绘制验证码文字
for (int i = 0; i < text.length(); i++) {
g2d.setColor(getRandomDarkColor());
g2d.drawString(
String.valueOf(text.charAt(i)),
15 + i * 25,
28 + random.nextInt(8)
);
}
// 绘制干扰点
for (int i = 0; i < 30; i++) {
g2d.setColor(getRandomColor());
g2d.fillRect(
random.nextInt(CAPTCHA_WIDTH),
random.nextInt(CAPTCHA_HEIGHT),
2, 2
);
}
g2d.dispose();
return image;
}
private Color getRandomColor() {
return new Color(
random.nextInt(200) + 50,
random.nextInt(200) + 50,
random.nextInt(200) + 50
);
}
private Color getRandomDarkColor() {
return new Color(
random.nextInt(150),
random.nextInt(150),
random.nextInt(150)
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
4.7.2 验证码校验 Action
验证码校验 Action 是连接 Webflow 和验证码逻辑的桥梁:
java
package cc.bima.cas.web.flow.action;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.apereo.cas.web.support.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.webflow.action.AbstractAction;
import org.springframework.webflow.execution.Event;
import org.springframework.webflow.execution.RequestContext;
/**
* 验证码校验 Action
*
* 在 Webflow 中,这个 Action 被注册为 captchaValidateAction。
* 当用户提交登录表单时,流程先进入 captchaValidate ActionState,
* 执行验证码校验,校验通过后再进入认证流程。
*/
public class CaptchaValidateAction extends AbstractAction {
private static final Logger logger = LoggerFactory.getLogger(CaptchaValidateAction.class);
/** 验证码 Session Key */
private static final String CAPTCHA_SESSION_KEY = "CAPTCHA_SESSION_KEY";
/** 成功事件 */
private static final String EVENT_SUCCESS = "success";
/** 错误事件 */
private static final String EVENT_ERROR = "error";
@Override
protected Event doExecute(final RequestContext context) throws Exception {
final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
final HttpSession session = request.getSession(false);
if (session == null) {
logger.warn("Session 不存在,验证码校验失败");
context.getRequestScope().put("captchaError", "会话已过期,请刷新页面");
return error();
}
// 获取用户输入的验证码
final String userInputCaptcha = request.getParameter("captchaResponse");
logger.debug("用户输入的验证码: {}", userInputCaptcha);
// 获取 Session 中存储的正确验证码
final String sessionCaptcha = (String) session.getAttribute(CAPTCHA_SESSION_KEY);
logger.debug("Session 中的验证码: {}", sessionCaptcha);
// 校验验证码
if (!isValidCaptcha(userInputCaptcha, sessionCaptcha)) {
logger.info("验证码校验失败");
context.getRequestScope().put("captchaError", "验证码错误,请重新输入");
// 校验失败后,清除 Session 中的验证码,防止重复使用
session.removeAttribute(CAPTCHA_SESSION_KEY);
return error();
}
// 校验成功,清除 Session 中的验证码
session.removeAttribute(CAPTCHA_SESSION_KEY);
logger.info("验证码校验成功");
return success();
}
/**
* 验证码比对
*
* @param userInput 用户输入
* @param sessionCaptcha Session 中存储的正确验证码
* @return 是否匹配
*/
private boolean isValidCaptcha(final String userInput,
final String sessionCaptcha) {
if (userInput == null || userInput.trim().isEmpty()) {
return false;
}
if (sessionCaptcha == null || sessionCaptcha.trim().isEmpty()) {
return false;
}
// 忽略大小写比较,并去除前后空格
return userInput.trim().equalsIgnoreCase(sessionCaptcha.trim());
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
4.7.3 验证码校验 Action 的 Spring Bean 注册
CAS 5.3(XML 方式):
xml
<!-- 在 spring-configuration.xml 中注册 -->
<bean id="captchaValidateAction"
class="cc.bima.cas.web.flow.action.CaptchaValidateAction" />1
2
3
2
3
CAS 6.6(Java Config 方式):
java
@Configuration
public class CaptchaConfiguration {
@Bean
public CaptchaValidateAction captchaValidateAction() {
return new CaptchaValidateAction();
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
CAS 7.3(AutoConfiguration 方式):
java
@AutoConfiguration
public class CaptchaAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public CaptchaValidateAction captchaValidateAction() {
return new CaptchaValidateAction();
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
4.8 前端验证码展示
验证码的前端展示需要修改 CAS 的登录页面模板。CAS 使用 Thymeleaf 作为模板引擎,登录页面的模板文件通常位于 src/main/resources/templates/casLoginView.html。
4.8.1 修改登录表单模板
以下是一个简化版的登录表单模板,展示了验证码的集成方式:
html
<!-- casLoginView.html(简化版) -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>CAS 登录</title>
</head>
<body>
<div class="container">
<h1>统一认证登录</h1>
<form method="post" id="fm1"
th:action="@{/login}">
<!-- 隐藏字段 -->
<input type="hidden" name="execution"
th:value="${flowExecutionKey}" />
<input type="hidden" name="_eventId" value="submit" />
<!-- 错误信息展示 -->
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">
<span th:each="err : ${#fields.allErrors()}"
th:text="${err}">错误信息</span>
</div>
<!-- 验证码错误信息 -->
<div th:if="${captchaError}" class="alert alert-danger">
<span th:text="${captchaError}">验证码错误</span>
</div>
<!-- 认证错误信息 -->
<div th:if="${#request.getAttribute('authenticationFailureMessage')}"
class="alert alert-danger">
<span th:text="${#request.getAttribute('authenticationFailureMessage')}">
认证失败
</span>
</div>
<!-- 用户名 -->
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username"
class="form-control"
th:value="${credential?.username}"
placeholder="请输入用户名"
autocomplete="username" />
</div>
<!-- 密码 -->
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password"
class="form-control"
placeholder="请输入密码"
autocomplete="current-password" />
</div>
<!-- 验证码区域(根据 Flow 变量控制显示) -->
<div th:if="${captchaEnabled}" class="form-group">
<label for="captchaResponse">验证码</label>
<div class="captcha-container">
<input type="text" id="captchaResponse"
name="captchaResponse"
class="form-control captcha-input"
placeholder="请输入验证码"
maxlength="4"
autocomplete="off" />
<img id="captchaImage"
th:src="@{/captcha/generate}"
alt="验证码"
title="点击刷新验证码"
class="captcha-image"
onclick="refreshCaptcha()" />
<a href="javascript:void(0)"
onclick="refreshCaptcha()"
class="captcha-refresh">换一张</a>
</div>
</div>
<!-- 记住我 -->
<div class="form-group">
<label>
<input type="checkbox" name="rememberMe" value="true" />
记住我
</label>
</div>
<!-- 提交按钮 -->
<div class="form-group">
<button type="submit" class="btn btn-primary btn-block">
登 录
</button>
</div>
</form>
</div>
<!-- JavaScript -->
<script type="text/javascript">
/**
* 刷新验证码
* 通过在 URL 后添加时间戳参数来避免浏览器缓存
*/
function refreshCaptcha() {
var captchaImage = document.getElementById('captchaImage');
if (captchaImage) {
var timestamp = new Date().getTime();
captchaImage.src = '/captcha/generate?t=' + timestamp;
}
}
</script>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
4.8.2 验证码样式
以下是一些基本的 CSS 样式,用于美化验证码区域:
css
/* 验证码容器 */
.captcha-container {
display: flex;
align-items: center;
gap: 10px;
}
/* 验证码输入框 */
.captcha-input {
width: 120px;
text-transform: uppercase;
letter-spacing: 4px;
}
/* 验证码图片 */
.captcha-image {
height: 40px;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 4px;
}
/* 刷新链接 */
.captcha-refresh {
font-size: 12px;
color: #1890ff;
text-decoration: none;
white-space: nowrap;
}
.captcha-refresh:hover {
color: #40a9ff;
text-decoration: underline;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
4.9 完整集成流程回顾
让我们回顾一下验证码集成的完整流程,从用户访问登录页面到最终完成认证:
用户访问 CAS 登录页面
|
v
[initialFlowSetup] -- 初始化流程,设置 captchaEnabled Flow 变量
|
v
[ticketGrantingTicketCheck] -- 检查 TGT
|
|--- (有 TGT) --> [hasServiceCheck] --> ...
|
|--- (无 TGT) --> [viewLoginForm]
|
| 渲染登录表单(包含验证码)
| 用户填写用户名、密码、验证码
| 点击"登录"按钮
|
v
[submit 事件触发]
|
v
[captchaCheck] -- DecisionState
|
|--- (captchaEnabled=true) --> [captchaValidate]
| |
| | 校验验证码
| |
| |--- (success) --> [realSubmit]
| | |
| | | 执行认证
| | |
| | |--- (success) --> ...
| | |--- (error) --> [viewLoginForm]
| |
| |--- (error) --> [viewLoginForm]
| |
| | 显示"验证码错误"
| | 刷新验证码图片
| | 保留用户名
|
|--- (captchaEnabled=false) --> [realSubmit]
|
| 直接执行认证(跳过验证码)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
关键实现要点总结:
- CaptchaWebflowConfigurer: 修改登录流程,插入验证码校验步骤
- CaptchaValidateAction: 执行验证码校验逻辑
- CaptchaController: 生成验证码图片
- CaptchaCredential: 扩展 Credential 对象,添加验证码字段
- casLoginView.html: 在登录表单中添加验证码输入框和图片
- Spring Bean 注册: 将自定义的 Action 和 Controller 注册为 Spring Bean
第五章 RememberMe 集成
5.1 RememberMe 机制概述
RememberMe(记住我)是 CAS 提供的一种便利功能,允许用户在关闭浏览器后仍然保持登录状态。与标准的 TGT(Ticket Granting Ticket)不同,RememberMe 使用长期票据(Long-Term Ticket),其有效期远大于普通 TGT。
RememberMe 与标准 TGT 的区别:
| 维度 | 标准 TGT | RememberMe TGT |
|---|---|---|
| 存储方式 | 浏览器 Cookie | 浏览器 Cookie + 服务端 |
| 有效期 | 通常为 2 小时 | 通常为 14 天到 30 天 |
| 安全级别 | 较高 | 较低(需要额外的安全措施) |
| Cookie 名称 | TGT | TGT(相同) |
| 过期策略 | 空闲超时 + 绝对超时 | 空闲超时 + 绝对超时(更长) |
5.2 RememberMe Webflow 配置
RememberMe 功能的 Webflow 集成涉及以下几个步骤:
5.2.1 在登录表单中添加 RememberMe 复选框
首先,需要在登录表单中添加一个"记住我"复选框:
html
<div class="form-group">
<label class="checkbox-inline">
<input type="checkbox" name="rememberMe" value="true" />
记住我
</label>
</div>1
2
3
4
5
6
2
3
4
5
6
5.2.2 创建 RememberMe Webflow 配置器
java
package cc.bima.cas.config.webflow;
import org.apereo.cas.web.flow.config.AbstractCasWebflowConfigurer;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
/**
* RememberMe Webflow 配置器
*
* 职责:
* 1. 在登录表单的 Binder 中添加 rememberMe 字段绑定
* 2. 确保 rememberMe 参数在流程中正确传递
*/
public class RememberMeWebflowConfigurer extends AbstractCasWebflowConfigurer {
private static final String STATE_ID_LOGIN_FORM = "viewLoginForm";
private static final String FIELD_REMEMBER_ME = "rememberMe";
public RememberMeWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
super(flowBuilderServices, loginFlowDefinitionRegistry);
}
@Override
public void doExecute() throws Exception {
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
return;
}
// 在登录表单的 Binder 中添加 rememberMe 字段
final ViewState viewLoginForm = (ViewState) loginFlow.getState(STATE_ID_LOGIN_FORM);
if (viewLoginForm != null) {
final BinderConfiguration binder = viewLoginForm.getBinderConfiguration();
if (binder != null) {
binder.addBinding(new BinderConfiguration.Binding(
FIELD_REMEMBER_ME,
null,
false // 非必填
));
}
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
5.2.3 RememberMe 配置(application.yml)
在 CAS 的配置文件中启用 RememberMe 功能:
yaml
cas:
authn:
remember-me:
enabled: true
# RememberMe Token 的有效期(单位:秒)
# 默认 1209600 秒 = 14 天
time-to-kill-in-seconds: 1209600
# RememberMe Token 的加密签名配置
crypto:
enabled: true
signing:
key: "YOUR_SIGNING_KEY_AT_LEAST_256_BITS"
encryption:
key: "YOUR_ENCRYPTION_KEY_AT_LEAST_256_BITS"1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
5.3 长期票据管理
RememberMe 的核心机制是长期票据的管理。当用户勾选"记住我"并成功登录后,CAS 会:
- 创建 RememberMe Token: 生成一个加密的 Token,包含用户标识、过期时间等信息。
- 将 Token 写入 Cookie: 通过 Set-Cookie 头将 Token 发送给浏览器。
- 在服务端存储 Token: 将 Token 与用户的认证信息关联存储(通常在 Ticket Registry 中)。
当用户再次访问 CAS 时:
- 检查 TGT Cookie: 如果标准 TGT 有效,直接使用。
- 检查 RememberMe Cookie: 如果标准 TGT 无效,检查 RememberMe Cookie。
- 验证 RememberMe Token: 如果 Token 有效且未过期,自动创建新的 TGT,实现无感知登录。
5.4 安全考量
RememberMe 功能虽然方便,但引入了额外的安全风险。以下是一些重要的安全考量:
1. Token 加密与签名:
RememberMe Token 必须使用强加密算法进行加密和签名,防止 Token 被伪造或篡改。CAS 支持使用 AES 加密和 HMAC 签名。
yaml
cas:
authn:
remember-me:
crypto:
enabled: true
signing:
key: "至少256位的签名密钥"
encryption:
key: "至少256位的加密密钥"1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
2. Token 有效期:
RememberMe Token 的有效期应该根据业务需求合理设置。过长的有效期会增加安全风险。
yaml
cas:
authn:
remember-me:
# 14 天有效期
time-to-kill-in-seconds: 12096001
2
3
4
5
2
3
4
5
3. 敏感操作二次验证:
对于敏感操作(如修改密码、资金转账等),即使 RememberMe 有效,也应该要求用户重新输入密码进行二次验证。
4. Token 撤销机制:
当用户主动登出或修改密码时,应该撤销所有相关的 RememberMe Token,防止 Token 被滥用。
5. IP 绑定(可选):
可以将 RememberMe Token 与用户的 IP 地址绑定,当 IP 地址发生变化时要求重新认证。但需要注意,使用代理或负载均衡时,IP 地址可能会变化。
yaml
cas:
authn:
remember-me:
# 启用 IP 地址验证
ip-address-regex: "^192\\.168\\..*"1
2
3
4
5
2
3
4
5
6. 设备指纹(可选):
可以使用设备指纹(如 User-Agent、屏幕分辨率等)来增强 RememberMe 的安全性。
第六章 自定义登录字段
6.1 自定义字段需求场景
在实际项目中,除了标准的用户名和密码外,登录表单可能需要包含以下自定义字段:
- 租户标识(Tenant ID): 多租户系统中,用户需要选择或输入所属的租户。
- 登录类型(Login Type): 区分不同的登录方式(如密码登录、短信验证码登录、扫码登录)。
- 组织机构(Organization): 大型企业中,用户需要选择所属的组织机构。
- 客户端类型(Client Type): 区分 PC 端、移动端、第三方应用等不同的客户端类型。
- 附加安全字段: 如安全问题的答案、设备验证码等。
6.2 在登录表单中添加自定义字段
6.2.1 创建自定义 Credential
首先,创建一个包含自定义字段的 Credential 类:
java
package cc.bima.cas.credential;
import org.apereo.cas.authentication.UsernamePasswordCredential;
import javax.validation.constraints.NotBlank;
/**
* 扩展认证凭证
* 添加租户ID和登录类型字段
*/
public class ExtendedCredential extends UsernamePasswordCredential {
private static final long serialVersionUID = 1L;
/** 租户标识 */
@NotBlank(message = "租户标识不能为空")
private String tenantId;
/** 登录类型:password / sms / qrcode */
private String loginType = "password";
/** 组织机构代码 */
private String orgCode;
// ==================== Getter / Setter ====================
public String getTenantId() {
return tenantId;
}
public void setTenantId(final String tenantId) {
this.tenantId = tenantId;
}
public String getLoginType() {
return loginType;
}
public void setLoginType(final String loginType) {
this.loginType = loginType;
}
public String getOrgCode() {
return orgCode;
}
public void setOrgCode(final String orgCode) {
this.orgCode = orgCode;
}
@Override
public String toString() {
return new StringBuilder()
.append("ExtendedCredential[")
.append("username=").append(getUsername())
.append(", tenantId=").append(tenantId)
.append(", loginType=").append(loginType)
.append(", orgCode=").append(orgCode)
.append("]")
.toString();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
6.2.2 修改 Webflow 配置
创建自定义 Webflow 配置器,在登录表单中添加自定义字段绑定:
java
package cc.bima.cas.config.webflow;
import org.apereo.cas.web.flow.config.AbstractCasWebflowConfigurer;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
/**
* 自定义登录字段 Webflow 配置器
*/
public class CustomFieldWebflowConfigurer extends AbstractCasWebflowConfigurer {
public CustomFieldWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
super(flowBuilderServices, loginFlowDefinitionRegistry);
}
@Override
public void doExecute() throws Exception {
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
return;
}
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");
if (viewLoginForm == null) {
return;
}
final BinderConfiguration binder = viewLoginForm.getBinderConfiguration();
if (binder != null) {
// 添加租户ID字段绑定
binder.addBinding(new BinderConfiguration.Binding(
"tenantId", null, true
));
// 添加登录类型字段绑定
binder.addBinding(new BinderConfiguration.Binding(
"loginType", null, false
));
// 添加组织机构代码字段绑定
binder.addBinding(new BinderConfiguration.Binding(
"orgCode", null, false
));
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
6.2.3 修改登录表单模板
html
<!-- 在 casLoginView.html 中添加自定义字段 -->
<!-- 租户选择 -->
<div class="form-group">
<label for="tenantId">所属租户</label>
<select id="tenantId" name="tenantId" class="form-control">
<option value="">-- 请选择租户 --</option>
<option value="tenant_a">租户 A</option>
<option value="tenant_b">租户 B</option>
<option value="tenant_c">租户 C</option>
</select>
</div>
<!-- 登录类型选择 -->
<div class="form-group">
<label>登录方式</label>
<div class="radio-group">
<label class="radio-inline">
<input type="radio" name="loginType" value="password" checked />
密码登录
</label>
<label class="radio-inline">
<input type="radio" name="loginType" value="sms" />
短信验证码
</label>
</div>
</div>
<!-- 组织机构选择 -->
<div class="form-group">
<label for="orgCode">所属机构</label>
<select id="orgCode" name="orgCode" class="form-control">
<option value="">-- 请选择机构 --</option>
<option value="org_001">总部</option>
<option value="org_002">北京分公司</option>
<option value="org_003">上海分公司</option>
</select>
</div>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
6.3 自定义 Credential 绑定
在 CAS 中,Credential 对象通过 Webflow 的 Binder 机制与表单字段绑定。默认情况下,CAS 使用 UsernamePasswordCredential 作为登录表单的 Model 对象。
如果需要使用自定义的 Credential 类,需要通过以下方式替换默认的 Credential:
6.3.1 替换 Flow 中的 Credential 变量
在 Webflow 配置器中,替换 FlowScope 中的 credential 变量:
java
@Override
public void doExecute() throws Exception {
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
return;
}
// 替换默认的 Credential 类为自定义的 ExtendedCredential
registerFlowVariable(loginFlow, "credential", new ExtendedCredential());
// 修改 Binder 配置
final ViewState viewLoginForm = (ViewState) loginFlow.getState("viewLoginForm");
if (viewLoginForm != null) {
// 重新设置 ViewState 的 Model
// 注意:不同版本的 CAS,设置 Model 的方式可能不同
// CAS 5.3 中可以通过反射修改
// CAS 7.3 中提供了更优雅的 API
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
6.3.2 自定义 AuthenticationHandler
自定义的 Credential 需要对应的 AuthenticationHandler 来处理认证逻辑:
java
package cc.bima.cas.auth;
import org.apereo.cas.authentication.*;
import org.apereo.cas.authentication.handler.support.AbstractUsernamePasswordAuthenticationHandler;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.services.ServicesManager;
import javax.security.auth.login.LoginException;
/**
* 自定义认证 Handler
* 支持多租户认证
*/
public class MultiTenantAuthenticationHandler
extends AbstractUsernamePasswordAuthenticationHandler {
public MultiTenantAuthenticationHandler(
final String name,
final ServicesManager servicesManager,
final PrincipalFactory principalFactory,
final Integer order) {
super(name, servicesManager, principalFactory, order);
}
@Override
protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal(
final UsernamePasswordCredential transformedCredential,
final String originalPassword) throws LoginException {
// 转换为自定义 Credential
if (!(transformedCredential instanceof ExtendedCredential)) {
throw new LoginException("不支持的 Credential 类型");
}
final ExtendedCredential credential = (ExtendedCredential) transformedCredential;
final String tenantId = credential.getTenantId();
final String username = credential.getUsername();
final String password = credential.getPassword();
// 根据租户 ID 获取对应的数据源
// 执行认证逻辑
// ...
// 返回认证结果
return createHandlerResult(
credential,
this.principalFactory.createPrincipal(username),
null // 无警告
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
6.4 表单验证
CAS 支持在 Webflow 的 ViewState 中进行表单验证。验证逻辑可以通过以下方式实现:
6.4.1 JSR-303 Bean Validation
在自定义 Credential 类中使用 JSR-303 注解:
java
public class ExtendedCredential extends UsernamePasswordCredential {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3到50之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 100, message = "密码长度必须在6到100之间")
private String password;
@NotBlank(message = "租户标识不能为空")
private String tenantId;
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
6.4.2 自定义 Validator
如果需要更复杂的验证逻辑,可以实现自定义的 Validator:
java
package cc.bima.cas.validation;
import org.apereo.cas.credential.UsernamePasswordCredential;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
/**
* 自定义登录表单验证器
*/
public class CustomCredentialValidator implements Validator {
@Override
public boolean supports(final Class<?> clazz) {
return ExtendedCredential.class.isAssignableFrom(clazz);
}
@Override
public void validate(final Object target, final Errors errors) {
final ExtendedCredential credential = (ExtendedCredential) target;
// 验证用户名格式
if (credential.getUsername() != null
&& !credential.getUsername().matches("^[a-zA-Z0-9_@.-]+$")) {
errors.rejectValue("username",
"username.format.invalid",
"用户名格式不正确");
}
// 验证密码强度
if (credential.getPassword() != null
&& credential.getPassword().length() < 8) {
errors.rejectValue("password",
"password.strength.weak",
"密码长度不能少于8位");
}
// 验证租户 ID 格式
if (credential.getTenantId() != null
&& !credential.getTenantId().matches("^[a-z]{1}[a-z0-9_]{1,31}$")) {
errors.rejectValue("tenantId",
"tenantId.format.invalid",
"租户标识格式不正确");
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
6.5 多因素认证流程集成
多因素认证(MFA,Multi-Factor Authentication)是 CAS Webflow 定制的高级应用场景。CAS 原生支持多种 MFA 提供者(如 Google Authenticator、Duo Security、YubiKey 等),并且通过 Webflow 可以灵活地编排 MFA 流程。
6.5.1 MFA Webflow 流程
MFA 的 Webflow 流程通常如下:
[realSubmit] -- 认证成功 --> [mfaCheck] -- DecisionState
|
|--- (需要 MFA) --> [mfaView] -- 用户输入验证码
| |
| v
| [mfaValidate]
| |
| |--- (成功) --> [sendTicketGrantingTicket]
| |
| |--- (失败) --> [mfaView]
|
|--- (不需要 MFA) --> [sendTicketGrantingTicket]1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
6.5.2 MFA Webflow 配置器
java
package cc.bima.cas.config.webflow;
import org.apereo.cas.web.flow.config.AbstractCasWebflowConfigurer;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ActionState;
import org.springframework.webflow.engine.DecisionState;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.Transition;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
/**
* MFA Webflow 配置器
*/
public class MfaWebflowConfigurer extends AbstractCasWebflowConfigurer {
public MfaWebflowConfigurer(
final FlowBuilderServices flowBuilderServices,
final FlowDefinitionRegistry loginFlowDefinitionRegistry) {
super(flowBuilderServices, loginFlowDefinitionRegistry);
}
@Override
public void doExecute() throws Exception {
final Flow loginFlow = getLoginFlow();
if (loginFlow == null) {
return;
}
// 创建 MFA 检查 DecisionState
final DecisionState mfaCheck = createDecisionState(
loginFlow,
"mfaCheck",
"flowScope.requireMfa == true",
"mfaView",
"sendTicketGrantingTicket"
);
// 创建 MFA 验证码输入 ViewState
final ViewState mfaView = createViewState(
loginFlow,
"mfaView",
"casMfaView"
);
mfaView.getTransitionSet().add(
createTransition("submit", "mfaValidate")
);
// 创建 MFA 验证 ActionState
final ActionState mfaValidate = createActionState(
loginFlow,
"mfaValidate",
createEvaluateAction("mfaValidateAction")
);
mfaValidate.getTransitionSet().add(
createTransition("success", "sendTicketGrantingTicket")
);
mfaValidate.getTransitionSet().add(
createTransition("error", "mfaView")
);
// 修改 realSubmit 的 success Transition
final ActionState realSubmit = (ActionState) loginFlow.getState("realSubmit");
if (realSubmit != null) {
final Transition successTransition = realSubmit.getTransition("success");
if (successTransition != null) {
realSubmit.getTransitionSet().remove(successTransition);
}
realSubmit.getTransitionSet().add(
createTransition("success", "mfaCheck")
);
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
6.5.3 MFA 配置(application.yml)
yaml
cas:
authn:
mfa:
# 全局 MFA 配置
global:
enabled-mfa-providers: "[\"mfa-gauth\"]"
# Google Authenticator 配置
gauth:
core:
issuer: "CAS-SSO"
# MFA 设备注册流程
trusted-device-enabled: true1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
第七章 Webflow 调试技巧
Webflow 的调试是 CAS 定制开发中不可避免的环节。由于 Webflow 的流程定义是声明式的,当流程出现问题时,错误信息往往不够直观。本章将分享一些实用的调试技巧,帮助开发者快速定位和解决 Webflow 相关的问题。
7.1 流程跟踪
7.1.1 启用 Webflow 调试日志
Webflow 提供了详细的日志输出,通过调整日志级别可以跟踪流程的执行过程:
xml
<!-- log4j2.xml -->
<Configuration>
<Loggers>
<!-- Webflow 核心日志 -->
<Logger name="org.springframework.webflow" level="DEBUG" />
<Logger name="org.springframework.webflow.engine" level="TRACE" />
<Logger name="org.springframework.webflow.execution" level="TRACE" />
<!-- CAS Webflow 日志 -->
<Logger name="org.apereo.cas.web.flow" level="DEBUG" />
<Logger name="org.apereo.cas.web.flow.config" level="DEBUG" />
<!-- CAS 认证日志 -->
<Logger name="org.apereo.cas.authentication" level="DEBUG" />
<Logger name="org.apereo.cas.ticket" level="DEBUG" />
</Loggers>
</Configuration>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 CAS 7.3 的 application.yml 中配置:
yaml
logging:
level:
org.springframework.webflow: DEBUG
org.springframework.webflow.engine: TRACE
org.apereo.cas.web.flow: DEBUG
org.apereo.cas.authentication: DEBUG1
2
3
4
5
6
2
3
4
5
6
7.1.2 流程执行日志分析
启用 DEBUG 日志后,Webflow 会在日志中输出详细的流程执行信息。以下是一个典型的流程执行日志:
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Starting new execution of flow 'login-webflow' in state 'initialFlowSetup'
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Event 'success' signaled from state 'initialFlowSetup'
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Transition 'success' -- [on success] --> state 'ticketGrantingTicketCheck'
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Entering state 'ticketGrantingTicketCheck' of flow 'login-webflow'
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Deciding [flowScope.ticketGrantingTicketId != null]: false
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Transition 'else' -- [if false] --> state 'viewLoginForm'
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Entering state 'viewLoginForm' of flow 'login-webflow'
DEBUG [org.springframework.webflow.engine.ActionExecutor]
- Executing [setupLoginFormAction@xxx]
DEBUG [org.springframework.webflow.engine.ViewState]
- Rendering view [casLoginView]
DEBUG [org.springframework.webflow.engine.impl.FlowExecutionImpl]
- Pausing in state 'viewLoginForm' of flow 'login-webflow'
- Waiting for user event 'submit'1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
通过分析这些日志,可以清楚地看到流程在每个节点的执行情况,包括:
- 当前处于哪个 State
- 触发了什么 Event
- Transition 的匹配结果
- Action 的执行情况
- ViewState 的渲染信息
7.1.3 使用 FlowExecutionKey 跟踪流程
Webflow 通过 FlowExecutionKey 来标识一个流程实例。在 URL 中可以看到这个 Key:
https://cas.example.com/cas/login?execution=e1s11
其中 e1s1 就是 FlowExecutionKey。通过这个 Key,可以在日志中搜索到对应的流程执行记录。
7.2 日志分析
7.2.1 常见日志模式
正常登录流程日志:
INFO [o.a.c.w.f.a.AuthenticationViaFormAction] - 成功认证用户: admin
INFO [o.a.c.t.r.DefaultTicketGrantingTicket] - 创建 TGT: TGT-1-xxx
INFO [o.a.c.t.r.DefaultServiceTicket] - 创建 ST: ST-1-xxx1
2
3
2
3
认证失败日志:
WARN [o.a.c.w.f.a.AuthenticationViaFormAction] - 认证失败: 凭证无效
WARN [o.a.c.w.f.a.HandleAuthenticationFailureAction] - 认证失败处理: 账户锁定1
2
2
Webflow 配置错误日志:
ERROR [o.a.c.w.f.c.AbstractCasWebflowConfigurer] - 未找到 State: viewLoginForm
ERROR [o.s.w.e.FlowBuilderException] - 无法解析 Action: captchaValidateAction1
2
2
7.2.2 关键日志搜索关键词
在排查 Webflow 问题时,以下关键词可以帮助快速定位相关日志:
| 关键词 | 含义 |
|---|---|
FlowExecutionImpl | 流程执行记录 |
ActionExecutor | Action 执行记录 |
ViewState | 视图状态渲染记录 |
Transition | 状态转换记录 |
FlowBuilderException | 流程构建异常 |
FlowExecutionRestorationFailureException | 流程恢复异常 |
IllegalStateException | 非法状态异常 |
BeanCreationException | Bean 创建异常 |
7.3 常见错误排查
7.3.1 错误:FlowExecutionRestorationFailureException
现象: 用户在登录页面停留时间过长后提交表单,出现错误页面。
原因: Webflow 的 FlowExecution 有超时机制。当用户在登录页面停留时间超过配置的超时时间后,FlowExecution 会被清理。此时用户提交表单,Webflow 尝试恢复流程,但发现流程已不存在,抛出此异常。
解决方案:
- 增加 FlowExecution 的超时时间:
yaml
spring:
webflow:
execution-repository:
max-executions: 100
max-execution-idle-time: 30m1
2
3
4
5
2
3
4
5
- 在 Global Transitions 中处理此异常(CAS 默认已处理):
xml
<global-transitions>
<transition on-exception=
"org.springframework.webflow.execution.repository.FlowExecutionRestorationFailureException"
to="viewLoginForm" />
</global-transitions>1
2
3
4
5
2
3
4
5
7.3.2 错误:无法解析 Action Bean
现象: 启动时或流程执行时报错:Cannot resolve action 'captchaValidateAction'。
原因: 自定义的 Action Bean 未正确注册到 Spring 容器中。
排查步骤:
- 确认 Action 类上有
@Component、@Service或@Bean注解 - 确认 Spring 的组件扫描路径包含了 Action 类所在的包
- 确认 Bean 的名称与 Webflow EL 表达式中引用的名称一致
- 在日志中搜索 Bean 创建记录,确认 Bean 已成功创建
解决方案:
java
// 确保类上有注解
@Component("captchaValidateAction")
public class CaptchaValidateAction extends AbstractAction {
// ...
}
// 或者通过 @Bean 方法注册
@Configuration
public class CaptchaConfiguration {
@Bean(name = "captchaValidateAction")
public CaptchaValidateAction captchaValidateAction() {
return new CaptchaValidateAction();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
7.3.3 错误:ViewState 找不到
现象: CaptchaWebflowConfigurer 执行时报错:State 'viewLoginForm' not found。
原因: 自定义 WebflowConfigurer 的执行顺序不正确,在 CAS 默认配置器创建 viewLoginForm 之前就尝试获取它。
排查步骤:
- 检查自定义配置器的
@Order注解 - 确认 CAS 默认的
DefaultLoginWebflowConfigurer先于自定义配置器执行
解决方案:
java
// 使用较低的优先级(较大的 Order 值),确保在 CAS 默认配置器之后执行
@Configuration
@Order(Ordered.LOWEST_PRECEDENCE)
public class CustomCasWebflowConfiguration {
// ...
}1
2
3
4
5
6
2
3
4
5
6
7.3.4 错误:表单字段绑定失败
现象: 用户提交表单后,自定义字段的值为 null。
原因: BinderConfiguration 中未正确添加字段绑定,或字段名与 HTML 表单中的 name 属性不一致。
排查步骤:
- 确认 BinderConfiguration 中添加了正确的字段绑定
- 确认 HTML 表单中的
<input name="xxx">与 Binder 中的字段名一致 - 确认 Credential 类中有对应的属性和 Getter/Setter 方法
- 启用 Webflow 的 DEBUG 日志,查看 DataBinder 的绑定过程
解决方案:
java
// 确认 Binder 配置
binder.addBinding(new BinderConfiguration.Binding(
"captchaResponse", // 必须与 HTML input name 一致
null,
false
));
// 确认 Credential 类
public class CaptchaCredential extends UsernamePasswordCredential {
private String captchaResponse; // 必须有对应的属性
public String getCaptchaResponse() { return captchaResponse; }
public void setCaptchaResponse(String captchaResponse) { this.captchaResponse = captchaResponse; }
}
// 确认 HTML 模板
// <input type="text" name="captchaResponse" /> <!-- name 必须一致 -->1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
7.3.5 错误:流程进入死循环
现象: 用户提交表单后,页面不断刷新,或者流程在两个 State 之间无限循环。
原因: Transition 配置错误,导致流程在两个 State 之间来回跳转。
排查步骤:
- 绘制当前的流程图,检查所有 Transition 的目标 State
- 确认每个 State 都至少有一个出口(Transition 或 EndState)
- 确认不存在 A -> B -> A 这样的循环(除非有退出条件)
解决方案:
java
// 错误示例:viewLoginForm 和 captchaValidate 之间形成循环
// viewLoginForm --submit--> captchaValidate --error--> viewLoginForm --submit--> ...
// 正确做法:在 error Transition 中添加条件或使用不同的 Event
captchaValidateState.getTransitionSet().add(
createTransition("error", "viewLoginForm")
);
// 确保 viewLoginForm 的 submit 事件不会在验证码校验失败时自动重新提交1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
7.3.6 调试工具推荐
- 浏览器开发者工具: 查看 HTTP 请求和响应,确认表单字段、execution 参数、Event 名称等。
- Postman / curl: 模拟 HTTP 请求,快速测试 Webflow 接口。
- IDE 远程调试: 在 Action 类中设置断点,通过远程调试查看流程执行时的变量状态。
- FlowExecutionKey 分析: 通过解析 URL 中的 FlowExecutionKey,在日志中搜索对应的流程执行记录。
总结与展望
核心知识点回顾
本文从 Spring Webflow 的基本概念出发,系统地介绍了 CAS 登录流程的 Webflow 定制技术。以下是核心知识点的回顾:
1. Spring Webflow 基础:
- Webflow 是一个有状态的、基于流程的 MVC 框架
- 核心概念包括 Flow、State(ViewState、ActionState、DecisionState)、Transition、Scope
- CAS 选择 Webflow 是因为登录流程天然具有多步骤、有状态的特性
2. CAS Webflow 配置方式:
- XML 方式(CAS 5.3 传统方式)
- Groovy 方式(CAS 5.3/6.6)
- Java Config 方式(CAS 7.3 推荐)
3. 自定义 Webflow 配置器:
- 继承
AbstractCasWebflowConfigurer或DefaultLoginWebflowConfigurer - 重写
doExecute()方法实现自定义逻辑 - 通过
createViewState()、createActionState()、createDecisionState()等方法操作流程定义
4. 验证码集成实战:
CaptchaWebflowConfigurer设计与实现- BinderConfiguration 配置与 Flow 变量绑定
- 验证码生成 Controller 与校验 Action
- 前端验证码展示与刷新
5. RememberMe 集成:
- RememberMe 机制与标准 TGT 的区别
- 长期票据管理与安全考量
6. 自定义登录字段:
- 自定义 Credential 设计
- 表单验证(JSR-303 + 自定义 Validator)
- 多因素认证流程集成
7. Webflow 调试技巧:
- 日志级别配置与日志分析
- 常见错误排查方法论
版本迁移建议
对于正在进行 CAS 版本升级的团队,我们提供以下迁移建议:
从 CAS 5.3 升级到 6.6:
- 将 XML/Groovy 配置逐步迁移到 Java Config
- 更新 WebflowConfigurer 的基类(从
DefaultLoginWebflowConfigurer迁移到AbstractCasWebflowConfigurer) - 更新依赖管理方式(从手动指定版本迁移到 BOM 管理)
从 CAS 6.6 升级到 7.3:
- Java 版本升级(从 Java 11 升级到 Java 21)
- Spring Boot 版本升级(从 2.x 升级到 3.x)
- 全面使用
@AutoConfiguration替代@Configuration - 注意
javax.*包到jakarta.*包的迁移
架构设计原则
在进行 CAS Webflow 定制时,我们建议遵循以下架构设计原则:
- 最小侵入原则: 尽量通过 WebflowConfigurer 扩展点进行定制,避免修改 CAS 源码。
- 配置分离原则: 将流程定制逻辑与业务逻辑分离,保持代码的清晰性。
- 版本兼容原则: 在设计自定义组件时,考虑跨版本兼容性,使用抽象类或接口隔离版本差异。
- 安全优先原则: 任何定制都应以安全为前提,特别是涉及认证流程的修改。
- 可测试原则: 为自定义的 Action 和 Configurer 编写单元测试,确保流程定义的正确性。
展望
随着 CAS 7.3 的发布和持续演进,CAS 的架构正在向更加现代化、模块化的方向发展。未来,我们可以期待以下趋势:
- 更强大的 Java Config API: CAS 将继续完善 Java Config API,提供更丰富的流程定制能力。
- 更好的可观测性: 集成 Micrometer、OpenTelemetry 等可观测性框架,提供更详细的流程执行指标。
- 云原生支持: 更好的容器化支持,包括 Kubernetes 原生的配置管理和服务发现。
- 无密码认证: 随着 WebAuthn/FIDO2 的普及,CAS 将提供更完善的无密码认证支持。
版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。
本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。
文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc。