单点登陆——CAS流程

在单点登陆——Session共享中,我们介绍了使用Session共享的方式来实现单点登录,但是最后我们发现其也是存在一些弊端的,其最大的问题就是必须要求所有的系统在同级域名下。其实就是利用了Cookie顶域共享的特性。


那么如果是不同域呢?不同域之间Cookie是不共享的,怎么办?这里我们就要说一说CAS流程了,这个流程是单点登录的标准流程。
单点登陆——CAS流程_第1张图片
下面对上图简要描述,如下:

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数(用于登录后跳转返回)
  2. sso认证中心发现用户未登录,将用户引导至登录页面
  3. 用户输入用户名密码提交登录申请
  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
  5. sso认证中心带着令牌跳转会最初的请求地址(系统1)
  6. 系统1拿到令牌,去sso认证中心校验令牌是否有效
  7. sso认证中心校验令牌,返回有效,注册系统1
  8. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源

  1. 用户访问系统2的受保护资源
  2. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  3. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
  4. 系统2拿到令牌,去sso认证中心校验令牌是否有效
  5. sso认证中心校验令牌,返回有效,注册系统2
  6. 系统2使用该令牌创建与用户的局部会话,返回受保护资源

上述就是我们CAS中的主要两种情况,看着步骤比较的繁杂,其实非常的简单,主要你明白了我们之前介绍的单点登陆——Session共享,那么对于上述的流程其实非常的好理解,说白了就是原来每个系统中都有一个登录页面,现在我们将所有系统的登录模块独立出来,做一个SSO认证中心。


下面我们就直接来看代码,结合代码来理解上述的CAS流程,首先看一下整体项目结构,相比较之前,这里多了一个登录模块,其他模块全部继承于 cas_support 模块,如下:
单点登陆——CAS流程_第2张图片

看到上述 cas_support 模块,发现东西还是几乎还是和原来差不多的,这里我们就来快速过一下代码,先看下用到的主要依赖,如下:

<dependency>
    <groupId>javax.servletgroupId>
    <artifactId>javax.servlet-apiartifactId>
    <version>3.1.0version>
    <scope>providedscope>
dependency>
<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-thymeleafartifactId>
dependency>

然后看下用户User类,其实和原来的内容及作用都是一样的,用于前端登录请求的传值,其中backUrl字段用于保存原请求路径(如未登录状态下访问系统1,被强制跳转至SSO认证中心,用户登录后需要跳转至用户原来想要访问的系统1),如下:

public class User implements Serializable{

    private static final long serialVersionUID = -3266830420902121735L;

    private String username;
    private String password;
    private String backUrl;

	//省略相关Getter、Setter方法
}

其中 MySession 类就是继承了 HttpSession 实现的自己的 Session 类,继承了该接口需要实现很多方法,我们这里就主要实现了下面两个方法,其余的暂不实现,还是和之前一样,如下:

public class MySession implements HttpSession, Serializable {

    private static final long serialVersionUID = 243457112279446716L;

    private String id;
    private Map<String, Object> attrs;

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    public Map<String, Object> getAttrs() {
        return attrs;
    }

    public void setAttrs(Map<String, Object> attrs) {
        this.attrs = attrs;
    }

    @Override
    public Object getAttribute(String s) {
        return this.attrs.get(s);
    }

    @Override
    public void setAttribute(String s, Object o) {
        this.attrs.put(s, o);
    }
	
	//省略其余继承接口需要实现的方法
	//...
}

有关 MySession 类的作用,以及其中的两个参数id、attr的作用,我们在单点登陆——Session共享都详细介绍过,可以先了解一下。



然后看一下 MyRequestWrapper 类,这个类就和之前介绍的有一定的变动了,其中我们新增了一个 sessionId 的参数字段,以及其 set 赋值方法
单点登陆——CAS流程_第3张图片
在这里插入图片描述

这个字段主要的作用,就是在上述MyRequestWrapper类获取MySession对象时,如果不存在的话,之前会直接尝试从Cookie获取携带的sessionId,然后判断是否存在,创建MySession对象。这里加入了sessionId字段,就是在从从Cookie获取携带的sessionId之前,加了一步判断,如下:
在这里插入图片描述

为什么会先获取该值?这个sessionId又是在什么地方进行赋值的呢?这个是因为我们在登录模块被独立出去提供服务了,在请求被重定向至登录模块进行登录,登录成功后,会生成了一个授权令牌ticket,并且登录模块会将该ticket和sessionId作为关系存入Redis中(为了安全起见,一般ticket存入Redis设置几秒后会过期),然后再将ticket作为重定向的参数返回给子系统。(下截图为登录模块cas_login部分的Controller代码,后续会介绍)
单点登陆——CAS流程_第4张图片

然后子系统拿到ticket就可以从Redis中获取sessionId,再通过sessionId就可以获取Redis中的已登录用户信息(登录模块和之前一样,Filter过滤器最后会将登录的用户信息存入Redis),子系统获取到当前登录信息后,就可以自己种Cookie了,这样访问其他页面就无需再次经过登录模块认证了。(下截图为cas_support模块Filter部分代码,后续会介绍)
单点登陆——CAS流程_第5张图片

其 MyRequestWrapper 类的完整代码,如下:

public class MyRequestWrapper extends HttpServletRequestWrapper {

    private volatile boolean committed = false;
    private String sessionId = null;

    private MySession session;
    private RedisTemplate redisTemplate;

    public MyRequestWrapper(HttpServletRequest request, RedisTemplate redisTemplate) {
        super(request);
        this.redisTemplate = redisTemplate;
    }

    //是否已登陆
    public boolean isLogin() {
        Object user = getSession().getAttribute(CookieUtil.SESSION_USER_INFO);
        return null != user;
    }

    //取session
    public MySession getSession() {
        if (null != session) {
            return session;
        }
        return this.createSession();
    }

    //创建新session
    public MySession createSession() {
        String mySessionId = null != sessionId ? sessionId : CookieUtil.getRequestedSessionId(this);

        Map<String, Object> attr;

        if (!StringUtils.isEmpty(mySessionId)) {
            attr = redisTemplate.opsForHash().entries(mySessionId);
        } else {
            mySessionId = UUID.randomUUID().toString();
            attr = new HashMap<>();
        }

        //session成员变量持有
        session = new MySession();
        session.setId(mySessionId);
        session.setAttrs(attr);

        return session;
    }

    //提交session内值到Redis
    public void commitSession() {
        if (committed) {
            return;
        }
        committed = true;

        MySession session = this.getSession();
        if (session != null && null != session.getAttrs()) {
            redisTemplate.opsForHash().putAll(session.getId(), session.getAttrs());
        }
    }

    public void setSessionId(String sessionId) {
        this.sessionId = sessionId;
    }
}

上述 MyRequestWrapper 类中用到的CookieUtil工具类,用于获取Cookie中携带的sessionId,以及种Cookie,其内容和之前几乎一样,唯一区别就是我们在种Cookie的时候,将 .setDomain() 方法注释了,因为这里就不需要,如下:

public class CookieUtil {

    public static final String SESSION_NAME = "mysession";
    public static final String SESSION_USER_INFO = "user";

    public static String getRequestedSessionId(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return null;
        }

        for (Cookie cookie : cookies) {
            if (cookie == null) {
                continue;
            }

            if (!SESSION_NAME.equalsIgnoreCase(cookie.getName())) {
                continue;
            }

            return cookie.getValue();
        }
        return null;
    }

    public static void onNewSession(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession();
        String sessionId = session.getId();
        Cookie cookie = new Cookie(SESSION_NAME, sessionId);
        cookie.setHttpOnly(true);
        //cookie.setDomain("bxs.com");
        cookie.setPath(request.getContextPath() + "/");
        cookie.setMaxAge(7 * 24 * 60 * 60);
        response.addCookie(cookie);
    }
}

最后我们看一下 cas_support 模块还剩下的 Filter 过滤器,之前我们在单点登陆——Session共享中,我们将Filter都是分别放在不同的子系统中的,因为其每个系统都有自己的登录页面,其跳转的url地址也是不同的。


但是这里我们将登录模块独立出来,做成了一个SSO认证中心,那么我们就将Filter过滤器也放入了cas_support模块中,因为这里跳转登录的地址现在是同一的了,如下:

//@WebFilter(filterName = "sessionFilter", urlPatterns = "/*")
public class SessionFilter implements Filter {

    private RedisTemplate redisTemplate;

    public void setRedisTemplate(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;

        //包装request对象
        MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request, redisTemplate);

        String requestUrl = request.getServletPath();

        //如果未登陆状态,进入下面逻辑
        if (!"/login".equals(requestUrl) && !"/toLogin".equals(requestUrl) && !myRequestWrapper.isLogin()) {

            String ticket = request.getParameter("ticket");

            //ticket为空,或无对应sessionid为空,表明不是自动登陆请求,则直接强制到登陆页面
            if (null == ticket || null == redisTemplate.opsForValue().get(ticket)) {
                HttpServletResponse response = (HttpServletResponse) servletResponse;
                response.sendRedirect("http://login.bxs.com:8080/login?backUrl=" + request.getRequestURL().toString());
                return;
            }

            //是自动登陆请求,则种cookie值进去,目的是重定向后的下次请求,自带本cookie,将直接是登陆状态
            myRequestWrapper.setSessionId((String) redisTemplate.opsForValue().get(ticket));
            myRequestWrapper.createSession();

            //种cookie
            CookieUtil.onNewSession(myRequestWrapper, (HttpServletResponse) servletResponse);

            //重定向自流转一次,原地跳转重向一次(给浏览器反馈刚刚种的cookie,避免中cookie后续失效)
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            response.sendRedirect(request.getRequestURL().toString());
            return;
        }

        try {
            filterChain.doFilter(myRequestWrapper, servletResponse);
        } finally {
            //提交session到Redis
            myRequestWrapper.commitSession();
        }
    }

    @Override
    public void destroy() {

    }
}

这里就无法直接通过cookie-session来判断是否登录了,这里我们就需要通过登录模块返回的ticket参数来判断(借助Redis),然后获取到登录信息后,在子系统中自己进行种Cookie,需要注意的是,这里我们种完Cookie后,需要立即自身重定向一次,主要目的就是为了给浏览器种Cookie,否则往下走,可能会导致种的Cookie丢失。


另外这里需要注意的是,所有Filter现在都是在cas_support模块中了,该Filter在我们子系统中肯定也是需要用到的,有关Filter注入项目中,我们在单点登陆——Session共享中介绍了两种方法,这里我们最好使用 @Bean 的形式手动注入,否则可能出现问题。


(注:在测试中,使用了@WebFilter@ServletComponentScan注解来完成时,测试发现在重定向时,登录模块登录认证后,进行携带令牌ticket重定向时,中间总会夹杂 /favicon.ico 请求,导致后续处理空指针异常,按照网上的设置,总是禁止不了该请求,改为@Bean测试成功,被坑好久…)




然后我们再来介绍下 cas_login 登录模块,如下:
单点登陆——CAS流程_第6张图片

首先我们就需要按上述介绍的,使用 @Bean 来添加过滤器了,如下:

@Configuration
public class SessionConfig {

    @Bean
    public SessionFilter sessionFilter(RedisTemplate redisTemplate){
        SessionFilter sessionFilter = new SessionFilter();
        sessionFilter.setRedisTemplate(redisTemplate);
        return sessionFilter;
    }

    @Bean
    public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(sessionFilter);
        registration.addUrlPatterns("/*");
        registration.setName("sessionFilter");
        registration.setOrder(1);  //值越小,Filter越靠前。
        return registration;
    }
}

那么在 cas_login 登录模块,因为这里又不用拦截请求跳转,那么我们不添加 Filter 过滤器可以么?当然不行的呀,因为我们的Filter过滤器不仅仅帮我们拦截非法请求,进行跳转至登录模块。而且在Filter中,还帮助我们把HttpServletRequest转换成了我们自己定义的MyRequestWrapper类。
在这里插入图片描述


接下来看一看 cas_login 模块的Controller方法,如下:

@Controller
public class IndexController {

    @Autowired
    private RedisTemplate redisTemplate;

    @GetMapping("/login")
    public String toLogin(Model model, MyRequestWrapper request) {
        if (request.isLogin()) {
            String ticket = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set(ticket, request.getSession().getId(), 20, TimeUnit.SECONDS);
            return "redirect:" + request.getParameter("backUrl") + "?ticket=" + ticket;
        }

        User user = new User();
        user.setUserName("");
        user.setPassWord("");
        user.setBackUrl(request.getParameter("backUrl"));
        model.addAttribute(CookieUtil.SESSION_USER_INFO, user);
        return "login";
    }

    @PostMapping("/toLogin")
    public void login(@ModelAttribute User user, MyRequestWrapper request, HttpServletResponse response) throws IOException {
            request.getSession().setAttribute(CookieUtil.SESSION_USER_INFO, user);

            String ticket = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set(ticket, request.getSession().getId(), 20, TimeUnit.SECONDS);

            CookieUtil.onNewSession(request, response);

            response.sendRedirect(user.getBackUrl() + "?ticket=" + ticket);
    }
}

其中 /login 方法,若用户未登录,则返回 login.html 页面;若用户已登录,则返回授权令牌ticket。而另一个方法/toLogin是用户在登录页面输入账号密码进行登录,登录后直接携带授权令牌重定向返回。


其中登录页面和之前一致,如下:


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>cas_logintitle>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
head>
<body>
<div text-align="center">
    <h1>请登陆h1>
    <form action="#" th:action="@{/toLogin}" th:object="${user}" method="post">
        <p>用户名: <input type="text" th:field="*{userName}"/>p>
        <p>码: <input type="text" th:field="*{passWord}"/>p>
        <input type="text" th:field="*{backUrl}" hidden="hidden"/>
        <p><input type="submit" value="submit"/>p>
    form>
div>
body>
html>

最后就是一个yml配置文件了,这个和之前的也是差不多,这里我们配置的是8080端口,然后cas_a模块配置的是8081端口,cas_b模块配置的是8082端口,其余一致,如下:

server:
  port: 8080
spring:
  redis:
    host: 127.0.0.1
    port: 6379



然后至于 cas_a 及 cas_b 模块,其内容这里都是一样的了,用于模拟两个子系统,如下:
单点登陆——CAS流程_第7张图片

@Configuration
public class SessionConfig {

    @Bean
    public SessionFilter sessionFilter(RedisTemplate redisTemplate){
        SessionFilter sessionFilter = new SessionFilter();
        sessionFilter.setRedisTemplate(redisTemplate);
        return sessionFilter;
    }

    @Bean
    public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(sessionFilter);
        registration.addUrlPatterns("/*");
        registration.setName("sessionFilter");
        registration.setOrder(1);  //值越小,Filter越靠前。
        return registration;
    }
}

这个 SessionConfig 其实和 cas_login 模块中的也是一样的,就是为了配置Filter过滤器。


然后我们在看下cas_a和cas_b模块的Controller,其中就是用于登录后访问的页面,其实这里我们发现就是将其中的Controller中用于登录的部分拆分了出去,然后在子系统中就不用处理登录相关的业务了,如下:

@Controller
public class IndexController {

    @GetMapping("/index")
    public ModelAndView index(MyRequestWrapper request) {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("index");
        modelAndView.addObject("user", request.getSession().getAttribute(CookieUtil.SESSION_USER_INFO));
        return modelAndView;
    }
}

最后的登陆成功的展示页面 index.html ,也是和之前的一样,内容如下:


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>cas_btitle>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
head>
<body>
    <div>
        <h1>你好,<span th:text="${user.userName}"/>h1>
    div>
body>
html>



最后我们就来进行测试,在测试前,我们先去本地的hosts文件中将 127.0.0.1 指向不同的域名,如下:
单点登陆——CAS流程_第8张图片

然后我们还需要先启动Redis服务,然后再启动cas_login,cas_a,cas_b模块,分别访问 a、b 的 /index 路径,即 a.bxs1.com:8081/index ,b.bxs2.com:8082/index ,如下:
单点登陆——CAS流程_第9张图片

上述我们可以看到,两个系统的域名是完全不一致的,然后我们登陆其中一个,然后另一个直接刷新进行查看,结果如下:
单点登陆——CAS流程_第10张图片

你可能感兴趣的:(分布式架构)