likes
comments
collection
share

Single Sign-On CAS协议实现

作者站长头像
站长
· 阅读数 30

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接口调用时的值一致
ticketCAS服务端认证成功后颁发的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
评论
请登录