Single Sign-On CAS协议实现
Single Sign-On Server
上一篇我简单介绍了CAS协议的原理和有关的API接口,这一篇来简单实现这几个API接口。
其中单点登录的会话管理采用cookie+session的方式,这样可以简单上手。
1.login接口
/login接口需要支持GET方法和POST方法,分别用于给用户展示登录表单和提交表单。
1.1 GET请求
请求参数:
参数名称 | 描述 | 是否必须 |
---|---|---|
service | 客户端尝试访问的应用程序的服务地址 | 否 |
renew | 如果设置为true,无论是否存在与 CAS 的单点登录会话,CAS 都将要求客户端提供凭据(暂不实现) | 否 |
gateway | 暂不实现 | 否 |
method | 暂不实现 | 否 |
除了协议规定的参数外,我还增加了一个自定义的参数:redirect,默认为true,登录成功后,CAS-Server端直接返回302重定向。
/login接口代码实现:
// 登录请求
@CrossOrigin(origins = "*", allowedHeaders = "*", exposedHeaders = "*", methods = RequestMethod.GET)
@GetMapping("login")
public String login(HttpServletResponse response,
HttpServletRequest request,
@RequestParam(value = "service", required = false) String service,
@RequestParam(value = "redirect", defaultValue = "true") Boolean redirect) throws IOException {
if (hasLogin(request)) {
String user = (String) request.getSession(false).getAttribute(SessionConstant.PRINCIPAL);
// 如果已经登录,直接分发service ticket,后面详细解析该方法
return grantTicketAndRedirect(service, true, response, request, user);
} else {
// 如果为登录,渲染html登录页面
HttpSession httpSession = request.getSession(true);
if (StringUtils.hasText(service)) {
httpSession.setAttribute(SERVICE, service);
}
if (redirect != null) {
httpSession.setAttribute(REDIRECT, redirect);
}
renderCasLoginPage(response);
return "redirect to login";
}
}
// 判断用户是否已登录
private boolean hasLogin(HttpServletRequest request) {
if (request.getSession(false) == null) {
return false;
}
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (Objects.equals(cookie.getName(), TGC)) {
String ticketGrantTicket = ticketStore.getTicketGrantTicket(request.getSession(false).getId());
if (ticketGrantTicket != null && Objects.equals(cookie.getValue(), ticketGrantTicket)) {
return true;
}
}
}
return false;
}
// 渲染html登录页面
private void renderCasLoginPage(HttpServletResponse response) throws IOException {
ClassPathResource resource = new ClassPathResource("static/login.html");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.TEXT_HTML_VALUE);
try (InputStream inputStream = resource.getInputStream();
OutputStream outputStream = response.getOutputStream()) {
IOUtils.copy(inputStream, outputStream);
}
}
1.2 POST请求
/login接口支持的POST请求,需要接受用户输入的凭证(一般来说就是用户名+密码),校验凭证的合法性,然后建立会话和分发service ticket。
代码实现如下:
// 接受表单提交的用户/密码参数
@CrossOrigin(origins = "*", allowedHeaders = "*", exposedHeaders = "*", methods = {RequestMethod.POST, RequestMethod.HEAD})
@PostMapping("login")
public String formLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam(value = "service", required = false) String service,
@RequestParam(value = "redirect", defaultValue = "true") Boolean redirect,
HttpServletResponse response,
HttpServletRequest request) throws IOException {
if (hasLogin(request)) {
// 如果已登录,直接返回
return "You have login successfully!";
}
if (user.equals(username) && pass.equals(password)) {
// 否则分发service ticket并重定向
return grantTicketAndRedirect(service, redirect, response, request, username);
} else {
return "Invalid credentials!";
}
}
// 分发ticket和重定向
private String grantTicketAndRedirect(String service, Boolean redirect,
HttpServletResponse response, HttpServletRequest request,
String user) throws IOException {
HttpSession session = request.getSession(true);
if (!StringUtils.hasText(service)) {
service = (String) session.getAttribute(SERVICE);
request.getSession().removeAttribute(SERVICE);
}
if (redirect == null) {
redirect = Boolean.parseBoolean(session.getAttribute(REDIRECT) + "");
request.getSession().removeAttribute(REDIRECT);
}
// 获取已有的ticket grant ticket
String ticketGrantTicket = getTgc(request);
if (ticketGrantTicket == null) {
ticketGrantTicket = ticketGenerator.generateTicketGrantTicket();
Cookie cookie = new Cookie(TGC, ticketGrantTicket);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
String sessionId = session.getId();
session.setAttribute(SessionConstant.PRINCIPAL, user);
ticketStore.setTicketGrantTicket(sessionId, ticketGrantTicket);
}
if (StringUtils.hasText(service)) {
String serviceTicket = ticketGenerator.generateServiceTicket(ticketGrantTicket);
ticketStore.addServiceTicket(serviceTicket, ticketGrantTicket, service);
if (StringUtils.hasText(user)) {
ticketStore.bindUser(serviceTicket, user);
}
if (redirect) {
redirectToService(service, serviceTicket, response);
return "Redirect successfully: " + service;
} else {
return "Login successfully, ticket=" + serviceTicket;
}
} else {
return "Login successfully!";
}
}
// 校验和获取ticket grant ticket,这个代表着用户在CAS的单点登录会话
private String getTgc(HttpServletRequest request) {
if (request.getSession(false) == null) {
return null;
}
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (Objects.equals(cookie.getName(), TGC)) {
String ticketGrantTicket = ticketStore.getTicketGrantTicket(request.getSession(false).getId());
if (ticketGrantTicket != null && Objects.equals(cookie.getValue(), ticketGrantTicket)) {
return ticketGrantTicket;
}
}
}
return null;
}
// 重定向至用户要访问的应用服务
private void redirectToService(String serviceUrl, String serviceTicket, HttpServletResponse response) throws IOException {
String redirectUrl = serviceUrl;
if (StringUtils.hasText(serviceTicket)) {
redirectUrl = serviceUrl + "?ticket=" + serviceTicket;
}
log.info("send redirect to serviceUrl: {}", redirectUrl);
response.sendRedirect(redirectUrl);
}
其中html代码、ticket生成器类和ticket存储器类的代码可以到 GitHub 上查看,这里就不贴出来了。
2.logout接口
2.1 退出登录
logout接口提供给用户退出单点登录会话,这里的实现就是将登录会话销毁。
接口参数:
参数名称 | 描述 | 是否必须 |
---|---|---|
service | 登出后重定向的地址 | 否 |
如果service参数不为空,需要返回302重定向至该参数值表示的地址;否则显示已登出的信息。
@RequestMapping(path = "logout", method = {RequestMethod.POST, RequestMethod.GET})
public String logout(@RequestParam(value = "service", required = false) String service,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
HttpSession session = request.getSession();
if (session != null) {
session.invalidate();
}
if (StringUtils.hasText(service)) {
redirectToService(service, null, response);
}
return "Logout successfully!";
}
2.2 单点登出
除了销毁CAS会话之外,还可以给这个会话关联的所有service发送单点登出的通知,使这些service的会话也销毁。这里暂不实现。
3.serviceValidate接口
CAS协议的2.0和3.0版本的serviceValidate需要校验service服务发出的请求中携带的ticket的有效性,并且返回用户名和相关属性。
3.1 请求参数
请求参数:
参数名称 | 描述 | 是否必须 |
---|---|---|
service | 应用服务的地址,需跟/login接口调用时的值一致 | 是 |
ticket | CAS服务端认证成功后颁发的service ticket | 是 |
format | 接口响应内容格式,支持XML和JSON,默认值是XML | 否 |
3.2 响应格式
成功响应XML示例:
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>username</cas:user>
<cas:attributes>
<cas:firstname>John</cas:firstname>
<cas:lastname>Doe</cas:lastname>
<cas:title>Mr.</cas:title>
<cas:email>jdoe@example.org</cas:email>
<cas:affiliation>staff</cas:affiliation>
<cas:affiliation>faculty</cas:affiliation>
</cas:attributes>
<cas:proxyGrantingTicket>PGTIOU-84678-8a9d...</cas:proxyGrantingTicket>
</cas:authenticationSuccess>
</cas:serviceResponse>
成功响应JSON示例:
{
"serviceResponse" : {
"authenticationSuccess" : {
"user" : "username",
"proxyGrantingTicket" : "PGTIOU-84678-8a9d...",
"proxies" : [ "https://proxy1/pgtUrl", "https://proxy2/pgtUrl" ],
"attributes" : {
"firstName" : "John",
"affiliation" : [ "staff", "faculty" ],
"title" : "Mr.",
"email" : "jdoe@example.orgmailto:jdoe@example.org",
"lastname" : "Doe"
}
}
}
}
失败响应XML示例:
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationFailure code="INVALID_TICKET">
Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized
</cas:authenticationFailure>
</cas:serviceResponse>
失败响应JSON示例:
{
"serviceResponse" : {
"authenticationFailure" : {
"code" : "INVALID_TICKET",
"description" : "Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized"
}
}
}
3.3 代码实现
@GetMapping(path = {"serviceValidate", "p3/serviceValidate"})
public String serviceValidate(@RequestParam("service") String service,
@RequestParam("ticket") String ticket,
@RequestParam(value = "format", defaultValue = "XML") String format) throws Exception {
String user = ticketStore.retrieveUser(ticket);
String relatedService = ticketStore.getRelatedService(ticket);
ticketStore.invalidateServiceTicket(ticket);
ServiceValidateDTO result;
if (relatedService == null) {
result = ServiceValidateDTO.fail(ErrorCodeConstant.INVALID_TICKET, " Service Ticket is invalid: " + ticket);
} else if (!Objects.equals(relatedService, service)) {
result = ServiceValidateDTO.fail(ErrorCodeConstant.INVALID_SERVICE, " Service is invalid: " + service);
} else {
Map<String, Object> attributes = new HashMap<>(2);
attributes.put("foo", "bar");
attributes.put("username", user);
result = ServiceValidateDTO.success(user, attributes);
}
if (XML.equals(format)) {
String xml = result.serializeAsXml();
log.info("service validate xml response, user:{}, xml:\n {}", user, xml);
return xml;
} else {
jsonMapper.enable(SerializationFeature.INDENT_OUTPUT);
String json = jsonMapper.writeValueAsString(result.getServiceResponse());
log.info("service validate json response, user:{}, json:\n {}", user, json);
return json;
}
}
4.CAS客户端
4.1 依赖引入
CAS客户端服务可以使用官方库来快速集成:
<dependency>
<groupId>org.apereo.cas.client</groupId>
<artifactId>cas-client-support-springboot</artifactId>
<version>4.0.4</version>
</dependency>
4.2 配置指南
首先需要在SpringBoot的启动类中加上注解@EnableCasClient
/**
* @author hundanli
*/
@EnableCasClient
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}
然后在application.properties配置文件中指定cas-server、cas-client的地址等信息:
server.port=8080
server.servlet.session.timeout=24h
server.reactive.session.timeout=24h
# cas-server和cas-client都使用相同域名时设置,防止会话cookie混淆
server.servlet.session.cookie.name=SESSIONID
server.servlet.session.tracking-modes=cookie,url
# cas服务的url前缀,调用serviceValidate接口时用到
cas.server-url-prefix=http://hundanli.com:8000/cas
#cas服务端的登录地址,用于重定向登录页面时使用
cas.server-login-url=http://hundanli.com:8000/cas/login
#当前服务器的地址(客户端),根据你的服务填写
cas.client-host-url=http://hundanli.com:8080
#Ticket校验器使用Cas30ProxyReceivingTicketValidationFilter
cas.validation-type=cas3
# 开启单点登出,当TGT被销毁时,应用会收到cas服务器的通知
cas.single-logout.enabled=false
5.测试单点登录
启动CAS-Server和CAS-Client两个服务,首次访问client地址 hundanli.com:8080 时,会跳转到登录页面,登录成功后,会再重定向回到 hundanli.com:8080 这个地址。
完整代码地址:github.com/hundanLi/ha…
转载自:https://juejin.cn/post/7347617618512232502