欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

单点登陆——CAS流程

程序员文章站 2022-05-05 15:09:24
...

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


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

  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流程

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

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</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流程
单点登陆——CAS流程

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

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

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

其 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流程

首先我们就需要按上述介绍的,使用 @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流程


接下来看一看 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是用户在登录页面输入账号密码进行登录,登录后直接携带授权令牌重定向返回。


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

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>cas_login</title>
    <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>&emsp;码: <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流程

@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 ,也是和之前的一样,内容如下:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>cas_b</title>
    <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流程

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

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

相关标签: 分布式架构