CAS单点登录完整案例
系统架构概览
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 系统A │ │ 系统B │ │ 系统C │
│(应用1) │ │(应用2) │ │(应用3) │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└───────────────┼───────────────┘
│
┌──────▼──────┐
│ CAS服务器 │
│ 认证中心 │
└─────────────┘
环境搭建
1 CAS服务器部署
CAS版本选择: CAS 5.3.x (Java 8+)

<!-- Maven依赖配置 (使用CAS Overlay方式) -->
<dependencies>
<dependency>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-webapp-tomcat</artifactId>
<version>5.3.14</version>
<type>war</type>
</dependency>
</dependencies>
CAS服务器配置 (application.properties):
# 服务器配置 server.port=8443 server.ssl.key-store=classpath:keystore.jks server.ssl.key-store-password=changeit server.ssl.key-password=changeit # CAS核心配置 cas.server.name=https://cas.example.com:8443 cas.server.prefix=https://cas.example.com:8443/cas # 认证方式 cas.authn.accept.users=casuser::Mellon # 服务注册 cas.service-registry.init-from-json=true cas.service-registry.json.location=classpath:/services # Ticket过期时间 cas.ticket.tgt.timeout.maxTimeout=7200 cas.ticket.st.timeout=300
服务注册配置 (services/HTTPSandIMAPS-10000001.json):
{
"@class": "org.apereo.cas.services.RegexRegisteredService",
"serviceId": "^(https|http)://(localhost|127\\.0\\.0\\.1):.*",
"name": "Local Development Services",
"id": 10000001,
"description": "本地开发环境服务",
"evaluationOrder": 1,
"accessStrategy": {
"@class": "org.apereo.cas.services.DefaultRegisteredServiceAccessStrategy",
"enabled": true,
"ssoEnabled": true
},
"authenticationPolicy": {
"@class": "org.apereo.cas.services.DefaultRegisteredServiceAuthenticationPolicy",
"requiredAuthenticationHandlers": ["accept"]
}
}
2 数据库认证配置
# 数据库认证配置 cas.authn.jdbc.query[0].url=jdbc:mysql://localhost:3306/cas_db cas.authn.jdbc.query[0].user=cas_user cas.authn.jdbc.query[0].password=cas_password cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver cas.authn.jdbc.query[0].sql=SELECT * FROM users WHERE username = ? cas.authn.jdbc.query[0].field.principalId=username cas.authn.jdbc.query[0].field.password=password cas.authn.jdbc.query[0].passwordEncoder.type=BCRYPT
客户端集成示例
1 Java Web客户端示例
Maven依赖配置:
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.6.3</version>
</dependency>
web.xml配置:
<!-- CAS认证过滤器 -->
<filter>
<filter-name>CAS Authentication Filter</filter-name>
<filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
<init-param>
<param-name>casServerLoginUrl</param-name>
<param-value>https://cas.example.com:8443/cas/login</param-value>
</init-param>
<init-param>
<param-name>serverName</param-name>
<param-value>http://app1.example.com:8080</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Authentication Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS票据验证过滤器 -->
<filter>
<filter-name>CAS Validation Filter</filter-name>
<filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter</filter-class>
<init-param>
<param-name>casServerUrlPrefix</param-name>
<param-value>https://cas.example.com:8443/cas</param-value>
</init-param>
<init-param>
<param-name>serverName</param-name>
<param-value>http://app1.example.com:8080</param-value>
</init-param>
<init-param>
<param-name>redirectAfterValidation</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Validation Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS请求包装过滤器 -->
<filter>
<filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
<filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS单点登出过滤器 -->
<filter>
<filter-name>CAS Single Sign Out Filter</filter-name>
<filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Single Sign Out Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- CAS单点登出监听器 -->
<listener>
<listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class>
</listener>
2 Spring Boot客户端示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("https://cas.example.com:8443/cas/logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.and()
.addFilterBefore(casAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(casSingleSignOutFilter(),
CasAuthenticationFilter.class);
}
@Bean
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
CasAuthenticationFilter filter = new CasAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager());
filter.setServiceProperties(serviceProperties());
return filter;
}
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService("http://app1.example.com:8080/login/cas");
serviceProperties.setSendRenew(false);
return serviceProperties;
}
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider provider = new CasAuthenticationProvider();
provider.setServiceProperties(serviceProperties());
provider.setTicketValidator(casTicketValidator());
provider.setUserDetailsService(createUserDetailsService());
provider.setKey("CAS_PROVIDER_LOCALHOST_9000");
return provider;
}
@Bean
public Cas30ServiceTicketValidator casTicketValidator() {
return new Cas30ServiceTicketValidator("https://cas.example.com:8443/cas");
}
@Bean
public SingleSignOutFilter casSingleSignOutFilter() {
SingleSignOutFilter filter = new SingleSignOutFilter();
filter.setCasServerUrlPrefix("https://cas.example.com:8443/cas");
filter.setIgnoreInitConfiguration(true);
return filter;
}
}
完整登录流程演示
1 首次访问流程
// 假设用户首次访问系统A的受保护资源
// http://app1.example.com:8080/secure/page
// 1. CAS客户端过滤器检测到未认证状态
// 自动重定向到CAS服务器
// https://cas.example.com:8443/cas/login?service=http://app1.example.com:8080/login/cas
// 2. 用户输入用户名密码
POST https://cas.example.com:8443/cas/login
Content-Type: application/x-www-form-urlencoded
username=casuser&password=Mellon&service=http://app1.example.com:8080/login/cas
// 3. CAS服务器验证成功后,重定向回应用系统
HTTP/1.1 302 Found
Location: http://app1.example.com:8080/login/cas?ticket=ST-1-abc123xyz456
// 4. 客户端使用ticket向CAS服务器验证
GET https://cas.example.com:8443/cas/serviceValidate?service=http://app1.example.com:8080/login/cas&ticket=ST-1-abc123xyz456
// 5. CAS服务器返回XML
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>casuser</cas:user>
<cas:attributes>
<cas:email>user@example.com</cas:email>
<cas:role>ROLE_ADMIN</cas:role>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
// 6. 验证成功后建立本地会话
// 创建session,存储用户信息和认证状态
2 单点登录流程
// 用户已登录系统A,现在访问系统B // 1. 系统B检测到未认证 // 重定向到CAS服务器 // https://cas.example.com:8443/cas/login?service=http://app2.example.com:8080/login/cas // 2. CAS服务器检查到已有TGT(Ticket Granting Ticket) // 直接发放ST(Service Ticket)并重定向 HTTP/1.1 302 Found Location: http://app2.example.com:8080/login/cas?ticket=ST-2-xyz789abc123 // 3. 系统B验证service ticket成功 // 无需再次输入用户名密码
3 单点登出流程
// 用户在系统A发起登出 // 系统A登出地址:http://app1.example.com:8080/logout // 1. 系统A登出,同时重定向到CAS登出 // 并携带service参数跳回 // https://cas.example.com:8443/cas/logout?service=http://app1.example.com:8080/login // 2. CAS服务器销毁全局会话TGT // 3. CAS服务器向所有已认证的服务发送注销请求 // POST http://app2.example.com:8080/cas/logout (带有samlRequest) // POST http://app3.example.com:8080/cas/logout (带有samlRequest) // 4. 每个客户端收到请求后销毁本地会话
核心实现代码
1 用户信息获取
@Controller
public class UserController {
@GetMapping("/user/info")
public String getUserInfo(HttpServletRequest request) {
// 方式1:使用AssertionHolder获取用户
Assertion assertion = AssertionHolder.getAssertion();
String username = assertion.getPrincipal().getName();
// 获取CAS传递的属性
Map<String, Object> attributes = assertion.getPrincipal().getAttributes();
String email = (String) attributes.get("email");
String role = (String) attributes.get("role");
// 方式2:从session中获取
Object sessionUser = request.getSession()
.getAttribute(CasConstants.CAS_ASSERTION);
return "userInfo";
}
// 使用注解方式获取用户
@GetMapping("/user/current")
@ResponseBody
public String getCurrentUser(@AuthenticationPrincipal Principal principal) {
return principal.getName();
}
}
2 手动认证处理
@Component
public class CasAuthenticationService {
@Autowired
private ServiceProperties serviceProperties;
@Autowired
private TicketValidator ticketValidator;
// 手动处理票据验证
public User authenticateTicket(String ticket, String serviceUrl) {
try {
// 验证票据
Assertion assertion = ticketValidator.validate(ticket, serviceUrl);
// 获取用户信息
String username = assertion.getPrincipal().getName();
// 获取属性
Map<String, Object> attributes =
assertion.getPrincipal().getAttributes();
// 构建用户对象
User user = new User();
user.setUsername(username);
user.setEmail((String) attributes.get("email"));
user.setRoles((List<String>) attributes.get("roles"));
return user;
} catch (TicketValidationException e) {
log.error("票据验证失败", e);
throw new AuthenticationException("票据无效");
}
}
}
3 登出实现
@Controller
public class LogoutController {
@GetMapping("/logout")
public String logout(HttpServletRequest request,
HttpServletResponse response) {
// 定义登出后跳转地址
String serviceUrl = "http://app1.example.com:8080/login";
// 构造CAS登出URL
String casLogoutUrl = "https://cas.example.com:8443/cas/logout";
String redirectUrl = casLogoutUrl + "?service=" +
URLEncoder.encode(serviceUrl, "UTF-8");
// 清除本地session
request.getSession().invalidate();
// 重定向到CAS登出
return "redirect:" + redirectUrl;
}
// 处理CAS服务器发来的登出请求
@PostMapping("/cas/logout")
@ResponseBody
public String handleCasLogout(@RequestBody String logoutRequest) {
// 解析SAML登出请求
String sessionId = parseSessionIdFromLogoutRequest(logoutRequest);
// 根据sessionId找到对应的session并销毁
HttpSession session = sessionRegistry.getSession(sessionId);
if (session != null) {
session.invalidate();
}
return "success";
}
private String parseSessionIdFromLogoutRequest(String logoutRequest) {
// 实现SAML请求解析
// 从XML中提取SessionIndex
return "session-id-xxx";
}
}
CAS配置管理界面
@Configuration
@EnableWebSecurity
public class CasManagementConfig {
@Bean
public ManagementService managementService() {
// 管理CAS服务注册中心
return new DefaultManagementService();
}
@Bean
public Controller controller() {
// CAS管理界面控制器
return null;
}
}
测试用例
@SpringBootTest
@AutoConfigureMockMvc
public class CasAuthenticationTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testCasLoginFlow() throws Exception {
// 模拟访问受保护资源
MvcResult result = mockMvc.perform(MockMvcRequestBuilders
.get("/secure/resource"))
.andExpect(status().is3xxRedirection())
.andReturn();
// 验证重定向到CAS服务器
String redirectUrl = result.getResponse().getRedirectedUrl();
assertTrue(redirectUrl.startsWith("https://cas.example.com:8443/cas/login"));
}
@Test
public void testCasAuthentication() throws Exception {
// 模拟CAS服务器回调
mockMvc.perform(MockMvcRequestBuilders
.get("/login/cas")
.param("ticket", "ST-1-test-ticket"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/secure/resource"));
}
@Test
public void testLogoutFlow() throws Exception {
// 测试登出
mockMvc.perform(MockMvcRequestBuilders
.get("/logout"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("https://cas.example.com:8443/cas/logout*"));
}
}
常见问题与解决方案
1 会话不一致问题
// 解决方案:使用Redis存储session
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory("localhost", 6379);
}
}
2 证书问题
# 生成客户端信任的证书
keytool -import -alias cas-server \
-keystore client-truststore.jks \
-file cas-server-cert.cer \
-storepass changeit -noprompt
# 或禁用证书验证(仅开发环境)
System.setProperty("javax.net.ssl.trustStore", "truststore.jks");
System.setProperty("javax.net.ssl.trustStorePassword", "changeit");
3 跨域配置(CORS)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
监控与日志
# 开启CAS监控
management.endpoints.web.exposure.include=health,info,metrics,loggers
management.endpoint.health.show-details=always
# 日志配置
logging.level.org.jasig.cas=DEBUG
logging.level.org.jasig.cas.client=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
监控点:
/actuator/health- 健康检查/actuator/metrics- 性能监控/cas/status- CAS服务器状态/cas/statistics- CAS统计信息
部署架构图
┌─────────────────────┐
│ 负载均衡器 │
│ (Nginx/HAProxy) │
└─────────┬───────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────────▼─────┐ ┌──────▼────────┐ ┌─────▼─────────┐
│ CAS服务器1 │ │ CAS服务器2 │ │ CAS服务器3 │
│ (TGT存储) │ │ (TGT存储) │ │ (TGT存储) │
└─────────┬─────┘ └──────┬────────┘ └─────┬─────────┘
│ │ │
└───────────────┼───────────────┘
│
┌─────────▼─────────┐
│ Redis集群 │
│ (Session共享) │
└───────────────────┘
│
┌─────────▼─────────┐
│ 数据库(MySQL) │
│ (用户信息存储) │
└───────────────────┘
这个完整的CAS单点登录案例涵盖了从环境搭建、配置、开发到部署的全流程,适用于中小型企业的多系统统一认证需求。