在一个企业内部可能运行着数个相对独立的信息化系统,我们不可能要求所有员工记住多个系统的账号密码,这自然就产生了实现统一登录方案的需求。实现单点登录(SSO,Single Sign-On)有很多种方式,CAS(Central Authentication Service)是其中一种实现单点登录的标准流程,用于针对性的解决上述问题。这篇笔记我们介绍CAS协议的相关概念和基本流程。
CAS(Central Authentication Service)原本是耶鲁大学发起的一个开源项目,包括一套协议规范和Java语言编写的CAS认证服务器实现,CAS为Web应用系统提供一种可靠的单点登录解决方案。不过由于一些历史原因,我们常说的“CAS”这个名词通常泛指CAS协议的这套SSO单点登录流程,偶尔也可能是指代CAS1.0、CAS2.0、CAS3.0等版本的具体协议规范,或者也可以专门指代Apereo CAS项目。
确实,CAS协议有一套协议规范和Java编写的CAS认证服务器标准实现Apereo CAS(曾用名Yale CAS、Jasig CAS),出于参考和学习目的研究Apereo CAS的实现是值得的,但个人十分不推荐在商业项目的生产环境部署该项目。登录系统通常和业务逻辑紧密耦合并且需要很多定制化的修改,CAS协议的基本流程并不复杂,但出于一些历史原因CAS1.0协议的标准文档十分粗陋,CAS协议规范定义的接口、报文格式非常老旧和怪异,后续版本引入了更多的概念也让CAS协议规范变得越加复杂和不实用,Apereo CAS项目更是极为臃肿而且灵活性非常差,它绑定了一些例如Spring WebFlow等十分偏门和过时的技术,严重的过度设计也带来极高的定制开发成本以及额外的Bug和安全漏洞,例如Apereo CAS 4.x就曾受到反序列化RCE漏洞的影响,好在Apereo CAS并没被广泛接受而使用不多因此没有产生大范围的影响,在实际开发中,比较推荐的方式还是我们参考CAS协议标准流程实现自己的CAS认证服务器和客户端。
CAS协议中有一些基本概念需要我们提前了解。
CAS Server:CAS认证服务器。
CAS Client / Application Server:CAS认证服务器的客户端,即应用服务器。
Client:访问应用服务的客户端,通常指用户的浏览器。
Ticket Granting Ticket(TGT):客户端在CAS认证服务器登录后CAS认证服务器为用户签发的凭据,它可以是一个记录用户登录状态的对象,存储在CAS认证服务器Session中。
Ticket Granting Cookie(TGC):CAS认证服务器会将TGT存储在Session中,TGC其实就是对应的SessionId,它可以Cookie的形式存在于用户的浏览器中,客户端访问CAS认证服务器时,CAS认证服务器会用TGC在Session中查找TGT,获取用户是否已经登陆等信息。不过实际上一些前后端分离的系统不使用Cookie,这种情况下TGC其实也可能以其他方式传递,这里需要我们根据实际情况进行设计。
Service Ticket(ST):客户端访问应用服务时CAS认证服务器签发的凭据,签发后再由客户端交给应用服务器,由应用服务器访问CAS认证服务器进行验证。
CAS协议实现了单点登录(SSO,Single Sign-On)和单点注销(SLO,Single Logout),其中单点登录主要分为用户未在CAS认证服务器登录和用户已在CAS认证服务器登录两种情况。
如果用户未在CAS认证服务器登录,流程如下。
service
参数,它的值是用户要返回的应用系统页面,该参数用于指示CAS认证服务器签发ST后将用户送回哪个应用系统service
参数请求CAS认证服务器当用户经过上述步骤后已在CAS认证服务器登录,此时用户访问当前的应用系统,由于应用系统保存了用户的局部登陆状态,因此不需要再和CAS认证服务器交互了。但如果用户访问了另一个应用系统,此时应用系统发现用户未登录,因此需要向CAS认证服务器发起请求,检查用户是否在CAS认证服务器登录,流程如下。
service
参数,它的值是用户要返回的应用系统页面,该参数用于指示CAS认证服务器签发ST后将用户送回哪个应用系统service
参数请求CAS认证服务器CAS协议中,除了实现SSO还实现了单点注销SLO,具体流程如下。
当然,单点注销SLO是可选的,应用系统不支持SLO也是允许的,只不过此时用户在应用系统的局部登录状态销毁操作就不能由CAS认证服务器统一发出,得由应用服务器自己维护了。
下面我们编写一个简单的例子,它实现了单点登录和单点注销,注意它仅仅是个实现了CAS协议基本流程的Demo,它对性能、分布式场景等都没有考虑。实际生产环境中,情况可能会更加复杂一些。
这里我们使用SpringBoot框架来编写这个例子。要实现一个CAS单点登录的Demo,至少需要部署三个系统:应用系统1、应用系统2和CAS认证服务器。在理想状态下,这些系统之间是完全相互独立的,它们仅通过一组预定义的标准HTTP接口互相通信。依据前文介绍的CAS协议流程,我们在CAS认证服务器中设置以下接口。
/login
:登录接口,供用户浏览器调用,用于提交登录表单、签发ST并重定向回应用系统/validate
:ST校验接口,供应用系统调用,用于校验ST的有效性并返回用户信息此外为了实现单点注销,我们还要求应用服务器提供一个注销回调接口。
/logout
:注销回调接口,供CAS认证服务器调用,用于认证服务器通知应用系统用户注销注意:由于Cookie只区分域名不区分端口,我们的实验系统都搭建在本地就会产生冲突,这里需要修改hosts
文件实现Cookie的隔离。下面是我们设定的CAS认证服务器、应用系统1、应用系统2的域名。
127.0.0.1 casserver.ssotest.com
127.0.0.1 app1.ssotest.com
127.0.0.1 app2.ssotest.com
我们这里出于简单起见,仅考虑单节点部署的情况,在这个CAS认证服务器例子中,维护用户的登陆状态(即CAS概念中的TGT和TGC)采用Tomcat的HttpSession方式,当用户登录成功后,用户信息会被写入HttpSession,并在后续的访问中以此作为判断条件。
此外,在这个例子中还有一个重要的组件TicketStore,它负责一次性的临时缓存签发的ST和TGT的对应关系,在应用服务器调用CAS认证服务器校验ST时,我们会从TicketStore中取出对应的TGT作为用户信息,返回给应用系统。TicketStore的后端存储可以是数据库、Memcached、Redis或者其它的什么东西,不过这里我们仅仅用一个ConcurrentHashMap来演示。
CAS认证服务器还对接入的应用系统进行了固定配置,包括系统的标识、域名和注销回调接口,未配置的系统被禁止获得授权,在下面例子中这些配置映射到了CasConfiguration
类。
cas.apps[0].app-name=app1
cas.apps[0].domain=app1.ssotest.com
cas.apps[0].logout-url=http://app1.ssotest.com:8081/logout
cas.apps[1].app-name=app2
cas.apps[1].domain=app2.ssotest.com
cas.apps[1].logout-url=http://app2.ssotest.com:8082/logout
LoginController.java
package com.gacfox.democas.casserver.controller;
import com.gacfox.democas.casserver.config.CasConfiguration;
import com.gacfox.democas.casserver.config.TicketStore;
import com.gacfox.democas.casserver.model.ApiResult;
import com.gacfox.democas.casserver.model.SessionDto;
import com.gacfox.democas.casserver.model.User;
import com.gacfox.democas.casserver.util.UUIDUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Controller
public class LoginController {
@Resource
private CasConfiguration casConfiguration;
@Resource
private RestTemplate restTemplate;
@Resource
private TicketStore ticketStore;
@GetMapping("/login")
public String loginPage(@RequestParam String service, Model model, HttpServletRequest request) {
CasConfiguration.App app = casConfiguration.getAppConfigByServiceUrl(service);
if (app == null) {
// 非法请求,未配置的来源应用系统
return "error";
}
SessionDto sessionDto = (SessionDto) request.getSession().getAttribute("sessionDto");
if (sessionDto != null) {
// 已在CAS认证服务器登录,签发ST
String ticket = UUIDUtil.randUniqueId();
ticketStore.addTicket(sessionDto.getUser(), ticket);
// 更新Session中信息
Map<String, String> ticketMap = sessionDto.getLoginAppTicketMap();
if (ticketMap == null) {
ticketMap = new HashMap<>();
sessionDto.setLoginAppTicketMap(ticketMap);
}
ticketMap.put(app.getAppName(), ticket);
request.getSession().setAttribute("sessionDto", sessionDto);
// 携带ST重定向回应用系统
return "redirect:" + service + "?ticket=" + ticket;
}
// 未在CAS认证服务器登录,返回登录页面
model.addAttribute("service", service);
return "login";
}
@PostMapping("/login")
public String handleLogin(String username,
String password,
String service,
Model model,
HttpServletRequest request) {
CasConfiguration.App app = casConfiguration.getAppConfigByServiceUrl(service);
if (app == null) {
// 非法请求,未配置的来源应用系统
return "error";
}
if ("admin".equals(username) && "123456".equals(password)) {
// 这里假设查询数据库,取回登录信息并进行用户名和密码散列值的比对
User user = new User("admin");
// 登录成功,签发ST
String ticket = UUIDUtil.randUniqueId();
ticketStore.addTicket(user, ticket);
// 缓存登录状态、ST信息
SessionDto sessionDto = (SessionDto) request.getSession().getAttribute("sessionDto");
if (sessionDto == null) {
sessionDto = new SessionDto();
}
sessionDto.setUser(user);
Map<String, String> ticketMap = sessionDto.getLoginAppTicketMap();
if (ticketMap == null) {
ticketMap = new HashMap<>();
sessionDto.setLoginAppTicketMap(ticketMap);
}
ticketMap.put(app.getAppName(), ticket);
request.getSession().setAttribute("sessionDto", sessionDto);
// 携带ST重定向回应用系统
model.addAttribute("service", service);
model.addAttribute("ticket", ticket);
return "redirect";
}
// 登录失败
model.addAttribute("msg", "登录失败,用户名或密码错误");
model.addAttribute("service", service);
return "login";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request) {
// 查询用户所有已签发的ST
SessionDto sessionDto = (SessionDto) request.getSession().getAttribute("sessionDto");
if (sessionDto != null) {
Map<String, String> ticketMap = sessionDto.getLoginAppTicketMap();
for (String appName : ticketMap.keySet()) {
// 调用应用系统注销局部登录状态
String ticket = ticketMap.get(appName);
CasConfiguration.App app = casConfiguration.getAppConfigByAppName(appName);
String logoutUrl = app.getLogoutUrl();
logoutUrl += "?ticket=" + ticket;
restTemplate.exchange(logoutUrl, HttpMethod.GET, null, new ParameterizedTypeReference<ApiResult<?>>() {
});
log.info("注销应用系统[{}] ticket: {}", appName, ticket);
}
}
// 销毁Session
request.getSession().invalidate();
return "logout";
}
}
login.html
<!doctype html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Login</title>
</head>
<body>
<fieldset>
<form th:action="@{/login}" method="post">
<input type="hidden" name="service" th:value="${service}"/>
<label for="username">用户名</label>
<input id="username" type="text" name="username"/>
<label for="password">密码</label>
<input id="password" type="password" name="password"/>
<input type="submit" value="登录"/>
<span style="color: red" th:text="${msg}"></span>
</form>
</fieldset>
</body>
</html>
redirect.html
<!doctype html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>登录成功</title>
</head>
<body>
<p>登录成功,正在跳转</p>
<input type="hidden" id="service" th:value="${service}"/>
<input type="hidden" id="ticket" th:value="${ticket}"/>
<script>
window.onload = function (ev) {
setTimeout(function () {
var service = document.getElementById('service').value;
var ticket = document.getElementById('ticket').value;
window.location.href = service + '?ticket=' + ticket;
}, 1000);
};
</script>
</body>
</html>
在实际的项目中,登录可能是极为复杂的,可能包括各种验证码、多因素登录等功能,这里我们仅仅是一个Demo,因此没有考虑太多。
上面代码包含单点登录使用的/login
和单点注销使用的/logout
,它们都由用户的浏览器调用。这里出于简单起见我们没有采用前后端分离方式来开发而是使用传统的后端MVC实现的,不过前后端分离实现CAS的思路也是类似的,这里就不再赘述了。
注意这里我们有一个redirect.html
,我们没有在/login
内部直接返回302重定向而是采用了前端重定向的方式,这是因为session.setAttribute()
实际上会触发写Cookie的操作,如果直接向浏览器发送302跨域重定向,可能造成Cookie还没来得及写就跳到别的域了,经过尝试,Chrome在这种情况下是无法写入Cookie的,因此多加了一个页面在页面上通过JavaScript进行延迟重定向。如果采用前后端分离方式进行开发,这个重定向也一样可以是前端实现的。
ValidateController.java
package com.gacfox.democas.casserver.controller;
import com.gacfox.democas.casserver.config.CasConfiguration;
import com.gacfox.democas.casserver.config.TicketStore;
import com.gacfox.democas.casserver.model.ApiResult;
import com.gacfox.democas.casserver.model.SessionDto;
import com.gacfox.democas.casserver.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@Slf4j
@RestController
public class ValidateController {
@Resource
private CasConfiguration casConfiguration;
@Resource
private TicketStore ticketStore;
@GetMapping("/validate")
public ApiResult<?> validate(@RequestParam String service, @RequestParam String ticket, HttpServletRequest request) {
log.info("校验ticket: {}", ticket);
CasConfiguration.App app = casConfiguration.getAppConfigByServiceUrl(service);
if (app == null) {
return ApiResult.failure("非法请求,未配置的来源应用系统");
}
User user = ticketStore.getUserByTicket(ticket);
if (user == null) {
// ticket不存在或已删除
return ApiResult.failure("ticket校验失败");
}
// 校验完成,移除ticketStore中的ST(ST仅允许使用一次)
ticketStore.removeTicket(ticket);
return ApiResult.success(user);
}
}
上面代码包含了一个/validate
接口,它用于ST的校验,由应用系统调用(而非用户的浏览器),这里注意为了保证安全性ST是只允许使用一次的,因此校验完成后ST就立即被从TicketStore中移除了,这样能够有效降低ST在传输过程中被窃取造成的安全风险。
在应用系统中我们需要编写一个拦截器,它校验用户在应用系统中的局部登陆状态,如果用户未登陆则跳转到CAS认证服务器进行认证,如果用户已登陆则放行。此外,如果用户携带者ST访问应用系统,则应用系统需要调用CAS认证服务器的/validate
接口进行ST的校验。
另外要注意的是单点注销,这个功能有些特殊,它需要CAS认证服务器通知应用服务器对某个用户做注销处理,它的实现类似一个实时踢人下线的功能,如果仍然简单的使用HttpSession来维护用户的登陆状态可能实现起来比较绕弯,因为Servlet规范中似乎没有提供删除其它请求上下文的HttpSession的方法,我们可能得缓存其它的信息来做额外的判断。这里我们没有采用这种方式,我们直接使用一个叫做Token
的Cookie值和一个自己实现的SessionStore关联维护用户的登录状态,这个Token的取值我们直接采用ST的值。至于SessionStore后端采用什么类型的存储,我们这里简单起见也是直接采用了ConcurrentHashMap,不过在生产环境中,我们通常基于Redis来实现,或者更一般的情况是用SpringSecurity等框架来实现Session的维护。当然,如果不打算实现单点注销,则不必记录ST也无需考虑Session维护,直接用Servlet规范的HttpSession就可以了。
LoginInterceptor.java
package com.gacfox.democas.app1.interceptor;
import com.gacfox.democas.app1.config.SessionStore;
import com.gacfox.democas.app1.model.ApiResult;
import com.gacfox.democas.app1.model.User;
import com.gacfox.democas.app1.util.CookieUtil;
import com.gacfox.democas.app1.util.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.UriComponentsBuilder;
import javax.annotation.Resource;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Resource
private RestTemplate restTemplate;
@Resource
private SessionStore sessionStore;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestUrl = String.valueOf(request.getRequestURL());
String token = CookieUtil.findCookieValueByName("token", request);
if (token == null || sessionStore.getUserByToken(token) == null) {
// 未登录
String ticket = request.getParameter("ticket");
if (ticket != null) {
// 携带ST,判断是CAS认证服务器302跳回,调用认证服务器校验ST
log.info("单点登录校验ticket: {}", ticket);
String url = UriComponentsBuilder.fromHttpUrl("http://casserver.ssotest.com:8080/validate")
.queryParam("service", requestUrl)
.queryParam("ticket", ticket)
.build()
.toUriString();
ResponseEntity<ApiResult<User>> responseEntity = restTemplate.exchange(url, HttpMethod.GET, null, new ParameterizedTypeReference<ApiResult<User>>() {
});
if (responseEntity.getStatusCode() != HttpStatus.OK) {
throw new RuntimeException("CAS认证服务器ticket校验接口调用失败");
}
ApiResult<User> apiResult = responseEntity.getBody();
log.info("单点登录校验ticket RSP: {}", JsonUtil.dump(apiResult));
if (apiResult != null && "0".equals(apiResult.getCode())) {
// ST校验通过,取出用户名存入Session
User u = apiResult.getData();
sessionStore.addUserSession(ticket, u);
Cookie cookie = new Cookie("token", ticket);
cookie.setHttpOnly(true);
response.addCookie(cookie);
return true;
} else {
// ST校验失败,跳回登录页
log.info("");
String requestUrlEncoded = URLEncoder.encode(requestUrl, "UTF-8");
response.sendRedirect("http://casserver.ssotest.com:8080/login?service=" + requestUrlEncoded);
return false;
}
} else {
// 未携带ST,跳转到CAS认证服务器
String requestUrlEncoded = URLEncoder.encode(requestUrl, "UTF-8");
response.sendRedirect("http://casserver.ssotest.com:8080/login?service=" + requestUrlEncoded);
return false;
}
} else {
// 已登录
return true;
}
}
}
上面代码是一个拦截器,它实现了登录状态的检查,如果用户已经在应用服务器上登录则直接允许访问,否则跳转到CAS认证服务器进行登录,此外该拦截器还对用户是否携带ST进行判断,如果携带ST则说明该请求是从CAS认证服务器跳转回来的,此时需要调用CAS认证服务器对ST进行校验,如果校验通过则保存新的登录状态。
LogoutController.java
package com.gacfox.democas.app1.controller;
import com.gacfox.democas.app1.config.SessionStore;
import com.gacfox.democas.app1.model.ApiResult;
import com.gacfox.democas.app1.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@Slf4j
@RestController
public class LogoutController {
@Resource
private SessionStore sessionStore;
@GetMapping("/logout")
public ApiResult<?> logout(String ticket) {
log.info("注销ticket {}", ticket);
User user = sessionStore.getUserByToken(ticket);
if (user != null) {
// 移除登录状态
sessionStore.removeUserSessionByToken(ticket);
return ApiResult.success("注销成功");
} else {
return ApiResult.failure("未找到ticket信息");
}
}
}
上面代码编写了一个/logout
接口,注意该接口是用于CAS认证服务器回调注销的(而非用于用户的浏览器调用),它仅有一个参数即之前颁发过的ST,应用服务器收到CAS认证服务器回调注销的请求后,会根据ST找到用户Session信息并移除局部登录状态。
通过前面代码我们可以知道,CAS认证服务器会依次回调所有对该用户已颁发ST的应用服务器,因此一旦用户在CAS认证服务器上触发单点注销则所有应用服务器的局部登录状态都会被注销,CAS认证服务器上的登陆状态也会被注销,此时就完成了单点注销的整个流程。
我们首先访问应用1的地址http://app1.ssotest.com:8081/dashboard
,可以观察到浏览器自动跳转到了CAS认证服务器的登录页面,如下图所示。
输入用户名和密码后点击登录,此时浏览器会自动跳转回应用1。
此时再访问应用2的页面地址http://app2.ssotest.com:8082/dashboard
,可以观察到浏览器自动跳转到了CAS认证服务器,但紧接着又跳了回来,自动完成了应用2的ST颁发和登录。
最后,我们访问CAS认证服务器的单点注销功能http://casserver.ssotest.com:8080/logout
,看到注销成功后,再次访问应用1和应用2,发现都需要登录。
TGC安全性:在CAS协议中,TGC的安全性是最关键的,如果TGC被窃取就相当于用户在CAS认证服务器的登录状态被窃取,此时攻击者就可以访问CAS认证服务器可以授权的所有应用系统。在上面例子中,TGC的实现的用户浏览器的Cookie,本质是Tomcat维护的SessionID。此外TGT应该有一个超时时间,比如120分钟。
ST安全性:ST的安全性也很重要,ST是颁发给用户的,这也给了用户篡改ST的机会,ST必须足够随机而不能通过遍历等方式猜测出来,其次ST在CAS认证服务器上应该有较短的过期时间,且仅能用于验证1次,避免出现攻击者窃取ST后,手动重复使用ST骗取CAS认证服务器授权的情况。
内部接口安全性:前面提到过的CAS认证服务器的ST校验接口/validate
以及应用服务器的单点注销回调接口/logout
都是系统之间交互的接口,如非必要它们可以在网关层面限制用户浏览器的访问。此外,我们也可以采用接口认证机制、证书校验、加密传输等方式,避免来自攻击者浏览器对这些接口的滥用。