单点登陆——Session共享

在介绍Session共享之前,我们先来了解一下session-cookie机制,该机制出现的根源,主要是因为http连接是无状态的连接,浏览器的每一次请求,服务器会独立处理,不与之前或之后的请求产生关联,即同一浏览器向服务端发送多次请求,服务器无法识别,哪些请求是同一个浏览器发出的。


所以这就意味着,任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器请求;要限制浏览器请求,必须鉴别浏览器请求,响应合法请求,忽略非法请求;要鉴别浏览器请求,必须清楚浏览器请求状态。既然http协议无状态,那就需要服务器和浏览器共同维护一个状态,其实就是我们常说的会话机制


浏览器第一次请求服务器,服务器创建一个会话,并将会话的id作为响应的一部分发送给浏览器,浏览器存储会话id,并在后续第二次和第三次请求中带上会话id,服务器取得请求中的会话id就知道是不是同一个用户了,那么服务器在内存中保存会话对象,浏览器怎么保存会话id呢?你可能会想到两种方式,如下:

  1. 直接在url里加一个请求(标识)参数(对前端开发有侵入性),如: token
  2. http请求时,自动携带浏览器的cookie(对前端开发无知觉),如:jsessionid=xxx

当然上述两种方法其实都是不安全的,因为浏览器标识在网络上的传输,是明文的,可通过一些抓包工具获取其参数,如果想要提高其安全性,可改为https协议。


Cookie与Session的交互

这里我们主要来看看Cookie机制的方式,Cookie是浏览器用来存储少量数据的一种机制,数据以”key/value“形式存储,浏览器发送http请求时自动附带cookie信息,其主要过程如下:

  1. 服务器通过response.addCookie(Cookie cookie)向Response Header中设置Cookie。
    如: Set-Cookie: jsession=08e0456d-afa3-45f3-99ef-a42e2fc977d3; Domain=bxs.com; Path=/;
  2. 浏览器接受Response Header的cookie值并保存。在下次请求的Request Header中,携带此cookie信息。
    如:Cookie: jsession=08e0456d-afa3-45f3-99ef-a42e2fc977d3
  3. sessionid被服务器设置到cookie中,以后浏览器的请求皆携带此sessionid,服务器据此检索对应session
  4. 当程序请求session时,首先根据cookie中携带的session id检索session(检索不到,会新建一个)

Tomcat会话机制当然也实现了cookie机制,访问Tomcat服务器时,浏览器中可以看到一个名为“JSESSIONID”的cookie,这就是Tomcat会话机制维护的会话id,使用了cookie的请求响应具体过程如下:
单点登陆——Session共享_第1张图片

上述通过Cookie与Session的交互,服务器就可以判断出来自同一浏览器的请求,但是如果在分布式环境下就会存在问题了,如果将系统分割为多个子系统,独立部署后,不可避免的会遇到会话管理的问题。


当单系统发展成多系统组成的应用群,面对如此众多的系统,用户难道要一个一个登录、然后一个一个注销吗?无论系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问系统的整个应用群与访问单个系统一样,登录/注销只要一次就够了。
单点登陆——Session共享_第2张图片单点登陆——Session共享_第3张图片



那么这里我们怎么解决呢?这里我们可以通过Redis来解决,我们在第一次 request.getSession() 时,去Redis内拉取值,Response返回数据时,推送session到Redis中,这样我们就可以实现在分布式下,各个子系统的单点登陆了。


但是我们知道其 request.getSession() 方法我们是没法修改的,但是我们可以来继承它,来实现自己的业务逻辑,如下:
单点登陆——Session共享_第4张图片

这里我们主要来看下项目的结构,这里分为三个模块,session_support模块以及session_asession_b模块,其中session_a和session_b模块都继承于session_support模块,session_support模块主要提供了一些公共的方法,用于对session的处理,如下:
单点登陆——Session共享_第5张图片

首先User类中,就是一个简单的用户Model,用于前端登录请求的传值,其中backUrl字段用于保存原请求路径(如未登录状态下访问首页,被强制跳转至登录页面,用户登录后需要跳转至用户原来想要访问的界面)

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就是后续我们使用UUID生成的sessionId,而attrs里面会存储用户登录成功的用户信息,其是个Map结构,其中的key我们事先会定义好的,如user。(当然也可以存储其它你想要的的信息,这里我们存储用户信息,是以便其他系统可以从这获取当前登录用户的信息)


有了我们自己的MySession类,这样我们就可以每次有请求访问时,我们就通过Filter将其拦截,如果含有sessionId,我们会去Redis中查询,然后组成一个MySession返回,我们通过判断其中的attrs中 key=user 是否有值即可,该 key=user 值只有当用户登录成功后,我们才会将其设值,并存入Redis中。

(下图部分代码是用户登录验证代码,后续会介绍,这里未做验证处理,直接就算登录成功了,其中CookieUtil.SESSION_USER_INFO就是一个常量字符串user)
单点登陆——Session共享_第6张图片


然后我们就看上述提到的 MyRequestWrapper 类,其实是我们继承了 HttpServletRequestWrapper 来的,并且上述介绍的部分逻辑代码,也在该类中实现,如判断是否登录,获取MySession等,具体如下:

/**
 * Request包装类
 */
public class MyRequestWrapper extends HttpServletRequestWrapper {

    private volatile boolean committed = false;

    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
    private MySession createSession() {
        String sessionId = CookieUtil.getRequestedSessionId(this);
        Map<String, Object> attr;

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

        //session成员变量持有
        session = new MySession();
        session.setId(sessionId);
        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());
        }
    }
}

我们在访问服务器时,服务器会拦截请求,去获得一个MySession,若无MySession会去创建一个,通过Cookie中是否携带了sessionId,无携带直接UUID生成一个,其余参数为空Map返回,若携带了sessionId,则通过该值去Redis中查询组成MySesion。


上述其实主要有两种情况:

  1. 第一种所有系统未登录情况,我们Cookie肯定是没有携带sessionId的,这里肯定会组装成一个空的MySession,判断结果为用户未登录
  2. 第二种情况,存在系统已登录情况,那么请求Cookie中就会携带sessionId,然后通过sessionId查找Redis获取当前用户信息,判断为已登录

至于为什么只要存在系统已登录,Cookie中就会携带sessionId,这里后续会在登录成功后,种Cookie的时候介绍到。(至于上述类中最有一个commitSession方法,提交session内值到Redis,这里其实会在过滤器中介绍的,在过滤器链最后就会进行提交)



然后我们来看看上述中提到的从Cookie获取请求携带的sessionId,如下第一个方法就是我们从Cookie中获取sessionId的方法,另一个方法是我们用来种Cookie的方法,是我们在用户登录成功后进行种Cookie

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.setPath(request.getContextPath() + "/");
        cookie.setDomain("bxs.com");
        cookie.setMaxAge(7 * 24 * 60 * 60);
        response.addCookie(cookie);
    }
}

至于为什么未登录系统中请求时,Cookie中携带sessionId,因为我们在种Cookie的时候设置了.setDomain,这样Cookie就可以实现跨域共享了,但是需要注意,这里必须位于上述设置的同一个顶级域名下才可以实现Cookie共享,后续会详细介绍。


最后我们提一下在该项目中所需要用的的依赖,主要如下:

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



上述我们理清楚了共同模块session_support,然后我们再来看看session_a模块,该模块继承于上述模块,模拟了一个子系统,如下:
单点登陆——Session共享_第7张图片

其中,我们集成了 thymeleaf 模板语言,这里其实主要用引入依赖,然后再 resources/templates 路径下放置展示的页面文件,其中共有两个页面,一个是用于登录的页面login.html,另一个则是登录后的页面 index,其中我们就简单显示了用户的用户名


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>session_atitle>
    <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>

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

然后我们可以看下如果我们直接访问 /index 页面,然后我们后台会设置了Filter过滤器,会将其拦截,判断是否已登录,若未登录则重定向至登录页面,如下:

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) && myRequestWrapper.isLogin()){
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            response.sendRedirect("http://a.bxs.com:8080/index");
            return;
        }

        //如果未登陆,则拒绝请求,转向登陆页面,并记录原请求路径backUrl
        if (!"/login".equals(requestUrl) && !"/toLogin".equals(requestUrl) && !myRequestWrapper.isLogin()) {
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            response.sendRedirect("http://a.bxs.com:8080/login?backUrl="+request.getRequestURL().toString());
            return ;
        }

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

    @Override
    public void destroy() {

    }
}

其中重定向登录页面 a.bxs.com:8080 网址,是我们在本机 host 文件中修改的,用于测试如下:
单点登陆——Session共享_第8张图片


然后我们是如何将其Filter加入项目中的呢,这里是我们在Configuration配置类中,手动将使用@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;
    }
}

这里主要注意的是,之前我们在web.xml中配置 Filter 的时候,是有先后顺序的,在配置时需要注意先配什么后配什么,这里我们如果想要指定Filter的执行顺序,可以使用上述的 .setOrder() 方法。


至于上述Filter中判断用户是否登录,这点我们在之前也就详细介绍过了,这里我们直接来看其被拦截后,请求的Controller,在未登录的情况下,会将其拦截至 login() 方法中,让其去登录,会展示 login.html,如下:

@Controller
public class IndexController {

    @GetMapping("/login")
    public String toLogin(Model model, MyRequestWrapper request) {
        User user = new User();
        user.setUserName("");
        user.setPassWord("");
        user.setBackUrl(request.getParameter("backUrl"));
        model.addAttribute("user", 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);
        CookieUtil.onNewSession(request, response);

        response.sendRedirect(user.getBackUrl());
    }

    @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;
    }
}

然后看其第二个方法 /toLogin 方法,就是我们用户在前端输入的账号密码进行校验,然后通过后会进行种Cookie,并在Session中添加了登录的用户信息,并重定向至用户原请求页面。


那么登录完成后,我们如何将其设置到Redis中呢,这里我们就要回到上述的Filter中了,在Filter中最后我们在 finally 方法内进行了向Redis中保存,所以这个Filter我们将其优先级设置为1,最好保证其为第一个Filter。
单点登陆——Session共享_第9张图片


然后最后第三个方法就是登录后可以访问的 index 页面,其中我们就是展示了从session中获取之前保存的用户信息。




接下来我们就剩下最后一个session_b模块了,其功能和session_a类似,模拟的另一个子系统,就是用于最后的演示,如下:
单点登陆——Session共享_第10张图片

其中的类和session_a模块几乎一致,我们就不细看了,这里我们主要看下session_b的过滤器Filter,其内容其实还是类似的,只不过重定向的url是 b.bxs.com 而已,这些都很好理解。


我们主要看下其注入的项目中的方式变了,这里我们直接通过了 @WebFilter 注解来注入了,就不用使用 @Bean 的方式手工注入了,如下:

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

    @Autowired
    private 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()) {
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            response.sendRedirect("http://b.bxs.com:8081/toLogin");
            return;
        }

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

    @Override
    public void destroy() {

    }
}

其代码内容还是一样,没什么太大区别,那么我们这种方式我们又该如何来指定其Filter的顺序呢,这里就比较尴尬了,这里是通过类的命名来分先后顺序的,所以起名很重要。

另外有介绍说可以在类上加上 @Order(1) 类似的注解来指定Filter顺序,不过好像并没有生效。


还有一点比较重要,就是使用 @WebFilter 注解的方式,我们需要在启动类上加上 @ServletComponentScan() 注解,如下:
单点登陆——Session共享_第11张图片
(其实有关这两种使用方式,我们在基于Zookeeper实现服务注册与发现中使用 @WebListen 的时候也介绍过,和上述两个方式类似。)


最后我们来看下session_b模块的yml配置(session_a模块之前没有看,其实都是一样的,只不过端口号不同而已,session_a模块的端口号为8080)

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



最后我们就来进行测试,需要先启动Redis服务,然后再启动session_a项目及session_b项目,分别访问其项目index页面,即 a.bxs.com:8080/index 和 b.bxs.com:8081/index ,但是都被过滤器拦截,跳转至 login 方法,最后显示了 login.html 页面,如下:
单点登陆——Session共享_第12张图片


然后注意了,我们在 a.bxs.com:8080/toLogin 页面输入BXS(密码随便输入,演示根本没校验),然后进行submit提交登录,登录成功后就自动跳转至 a.bxs.com:8080/index 页面了,如下
单点登陆——Session共享_第13张图片


然后我们在另一个未登录页面,刷新一下页面就可以访问 /index 页面,无需再次登录,如下:
单点登陆——Session共享_第14张图片


为什么呢?因为 b.bxs.com:8081 虽然没有进登录,但是我们在访问服务器时,是携带了 sessionId 的,服务器通过这个sessionId在Redis中找到了相关信息,并且从找到的session中取得了登录的用户信息。


那么为什么访问 b.bxs.com 也会携带Cookie中记录的sessionId呢?这个就需要看下登录成功后,种Cookie的过程了,其中我们设置了 .setDomain() 方法,如下:
单点登陆——Session共享_第15张图片

这样我们就可以使Cookie进行跨域了,这样在我们设置的根域名下的所有子域名进行访问时,都会携带上我们种的Cookie了。


当然这样方式也是有缺点的,就是对于完全不同域名的系统,cookie是无法跨域名共享的,这里如果我们在本机的host文件将两个的顶级域名改为不一致的话,就无法做到Cookie共享了,如下:
单点登陆——Session共享_第16张图片

单点登陆——Session共享_第17张图片

(上述测试时,需要将Filter的重定向地址同步修改下,那么有关该问题如何解决,我们在后续会进行介绍)




最后提个小插曲,就是在上述的测试中,如果登录后向重试,可以将Redis中的记录直接全部清空掉,或者我们可以在浏览器中将服务器种的Cookie给清除掉,这里介绍个非常实用的小插件—— EditThisCookie
单点登陆——Session共享_第18张图片

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