第三章 Session共享&单点登陆笔记

一、传统Session机制及身份认证方案

 

第三章 Session共享&单点登陆笔记

1Cookie与服务器的交互

如上图,http是无状态的协议,客户每次读取web页面时,服务器都打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?session就是一种保存上下文信息的机制,它是针对每一个用户的,变量的值保存在服务器端,通过SessionID来区分不同的客户。session是以cookie或URL重写为基础的,默认使用cookie来实现,系统会创造一个名为JSESSIONID的值输出到cookie。

注意JSESSIONID是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie禁止,则web服务器会采用URL重写的方式传递Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65之类的字符串。 通常JSESSIONID是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。

2、服务器端的session的机制

session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息。但程序需要为某个客户端的请求创建一个session的时候,服务器首先检查这个客户端的请求里是否包含了一个JSESSIONID标识的sessionid,如果已经包含一个session id则说明以前已经为此客户创建过session,服务器就根据sessionid把这个session检索出来使用(如果检索不到,可能会新建一个,这种情况可能出现在服务端已经删除了该用户对应的session对象,但用户人为地在请求的URL后面附加上一个JSESSION的参数)。

如果客户请求不包含 session id,则为此客户创建一个session并且生成一个与此session相关联的session id,这个session id将在本次响应中返回给客户端保存。对每次http请求,都经历以下步骤处理:服务端首先查找对应的cookie的值(sessionid)。 根据sessionid从服务器端session存储中获取对应id的 session数据,进行返回。 如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。

3、基于session的身份认证

看下图:

第三章 Session共享&单点登陆笔记

 

因为http请求是无状态请求,所以在Web领域,几乎所有的身份认证过程,都是这种模式。

二、集群下Session困境及解决方案

 

第三章 Session共享&单点登陆笔记

如上图,随着分布式架构的流行,单个服务器已经不能满足系统的需要了,通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为session是保存在服务器上的,那么很有可能第一次请求访问的A服务器,创建了session,但是第二次访问到了B服务器,这时就会出现取不到session的情况。我们知道,Session一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。要在集群环境下使用,最好的的解决办法就是使用session共享:

 

第三章 Session共享&单点登陆笔记

1Session共享方案

传统的session由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上session信息能够共享呢?两种实现思路:session集中存储(redis,memcached,hbase等)。不同服务器上session数据进行复制,此方案延迟问题比较严重。 我们一般推荐第一种方案,基于session集中存储的实现方案,见下图:

 

第三章 Session共享&单点登陆笔记

具体过程如下:

新增Filter拦截请求,包装HttpServletRequest(使用HttpServletRequestWrapper) 改写getSession方法,从第三方存储中获取session数据(若没有则创建一个),返回自定义的HttpSession实例在http返回response时,提交session信息到第三方存储中。

2、需要考虑的问题

2.1、需要考虑以下问题:

session数据如何在Redis中存储?

session属性变更何时触发存储?

2.2、实现:

考虑到session中数据类似map的结构,采用redis中hash存储session数据比较合适如果使用单个value存储session数据,不加锁的情况下,就会存在session覆盖的问题,因此使用hash存储session,每次只保存本次变更session属性的数据,避免了锁处理,性能更好。如果每改一个session的属性就触发存储,在变更较多session属性时会触发多次redis写操作, 对性能也会有影响,我们是在每次请求处理完后,做一次session的写入,并且写入变更过的属性。如果本次没有做session的更改,是不会做redis写入的,仅当没有变更的session超过一个时间阀值(不变更session刷新过期时间的阀值),就会触发session保存,以便session能够延长有效期。

3、代码实战

3.1、新建项目springboot-session-bussiness

主要实现正常的登录拦截功能,为后面项目改造提供参考。

代码参见:springboot-platform------>springboot-session-bussiness模块

代码git地址:https://gitee.com/hankin_chj/springboot-platform.git

3.2、新建springboot-session-bussiness模块

该模块为公共模块,主要用于为其他服务提供统一的session拦截、分装等功能,主要对系统的request和session对象进行重写,其他服务需要用到request的时候就使用封装好的MyRequestWrapper来获取session相关内容。

1)重写HttpSession

public class MySession implements Serializable,HttpSession {
    private static final long serialVersionUID = -3923541488767125713L;
    private String id;
    private Map<String,Object> attrs;
    .....

2)request封装代码实现如下:

public class MyRequestWrapper extends HttpServletRequestWrapper {
    private volatile boolean committed = false;
    private String uuid = UUID.randomUUID().toString();
    private MySession session;
    private RedisTemplate redisTemplate;
    public MyRequestWrapper(HttpServletRequest request,RedisTemplate redisTemplate) {
        super(request);
        this.redisTemplate = redisTemplate;
    }
    // 提交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());
        }
    }
    //创建新session
    public MySession createSession() {
        String sessionId = CookieBasedSession.getRequestedSessionId(this);//从页面传来的
        Map<String,Object> attr ;
        if (null != sessionId){
            attr = redisTemplate.opsForHash().entries(sessionId);
        } else {
            System.out.println("create session by rId:"+uuid);
            sessionId = UUID.randomUUID().toString();
            attr = new HashMap<>();
        }
        //session成员变量持有
        session = new MySession();
        session.setId(sessionId);
        session.setAttrs(attr);
        return session;
    }
    //或取session
    public MySession getSession() {
        return this.getSession(true);
    }
    // 取session
    public MySession getSession(boolean create) {
        if (null != session){
            return session;
        }
        return this.createSession();
    }
    //是否已登陆
    public boolean isLogin(){
        Object user = getSession().getAttribute(SessionFilter.USER_INFO);
        return null != user;
    }
}

3)包装request对象提交session到redisSessionFilter代码实现:

public class SessionFilter implements Filter {
    public static final String USER_INFO = "user";
    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;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        //包装request对象
        MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request,redisTemplate);
        //如果未登陆,则拒绝请求,转向登陆页面
        String requestUrl = request.getServletPath();
        if (!"/toLogin".equals(requestUrl)//不是登陆页面
                && !requestUrl.startsWith("/login")//不是去登陆
                && !myRequestWrapper.isLogin()) {//不是登陆状态
            request.getRequestDispatcher("/toLogin").forward(myRequestWrapper,response);
            return ;
        }
        try {
            filterChain.doFilter(myRequestWrapper,servletResponse);
        } finally {
            //提交session到redis
            myRequestWrapper.commitSession();
        }
    }
    @Override
    public void destroy() { }
}

4)工具类CookieBasedSession通过cookie获取sessionID:

public class CookieBasedSession{
    public static final String COOKIE_NAME_SESSION = "psession";
    public static String getRequestedSessionId(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return null;
        }
        for (Cookie cookie : cookies) {
            if (cookie == null) {
                continue;
            }
            if (!COOKIE_NAME_SESSION.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(COOKIE_NAME_SESSION, sessionId);
        cookie.setHttpOnly(true);
        cookie.setPath(request.getContextPath() + "/");
        // 设置一级域名
        cookie.setDomain("dev.com");
        cookie.setMaxAge(Integer.MAX_VALUE);
        response.addCookie(cookie);
    }
}

3.3、新建springboot-sessionAspringboot-sessionB项目

SessionA和sessionB项目代码基本一致,知识配置不一样,模拟两个业务服务登录场景

1)pom文件配置

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>com.chj</groupId>
        <artifactId>springboot-session-support</artifactId>
        <version>1.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!--使用swagger2-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.6.1</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.6.1</version>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.1.3.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

2)application.yml配置

spring:
  redis:
      host: 127.0.0.1
      port: 6379
  devtools:
      restart:
        enabled: true                       #开启
        additional-paths: src/main/java     #监听目录
swagger:
  host: local.dev.com

3)controller层代码实现,注意接口参数MyRequestWrapper代替了HttpServletRequest

@Controller
public class IndexController {
    @GetMapping("/toLogin")
    public String toLogin(Model model,MyRequestWrapper request) {//仅限本次会话
        UserForm user = new UserForm();
        user.setUsername("hankin");
        user.setPassword("hankin");
        user.setBackurl(request.getParameter("url"));
        model.addAttribute("user", user);
        return "login";
    }
    @PostMapping("/login")
    public void login(@ModelAttribute UserForm user,MyRequestWrapper request,HttpServletResponse response) throws IOException, ServletException {
        request.getSession().setAttribute(SessionFilter.USER_INFO,user);
        //TODO 种cookie
        CookieBasedSession.onNewSession(request,response);
        //重定向
        response.sendRedirect("/index");
    }

    @GetMapping("/index")
    public ModelAndView index(MyRequestWrapper request) {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("index");
        modelAndView.addObject("user", request.getSession().getAttribute(SessionFilter.USER_INFO));
        request.getSession().setAttribute("test","123");
        return modelAndView;
    }
}

4)session配置将SessionFilter注入到servlet容器

/**
 * 把SessionFilter 注册掉servlet容器里面
 */
@Configuration
public class SessionConfig {
    //配置filter生效
    @Bean
    public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(sessionFilter);
        registration.addUrlPatterns("/*");
        registration.addInitParameter("paramName", "paramValue");
        registration.setName("sessionFilter");
        registration.setOrder(1);
        return registration;
    }
    //定义过滤器组件
    @Bean
    public SessionFilter sessionFilter(RedisTemplate redisTemplate){
        SessionFilter sessionFilter = new SessionFilter();
        sessionFilter.setRedisTemplate(redisTemplate);
        return sessionFilter;
    }
}

注意:

SessionA和sessionB两个服务的登录方法里面都会讲讲session信息放到一级域名cookie里面,所以一旦一个系统登录,另外一个系统也会从这个一级域名下面获取到登录信息。

@PostMapping("/login")
public void login(@ModelAttribute UserForm user, MyRequestWrapper request, HttpServletResponse response) throws IOException, ServletException {
    request.getSession().setAttribute(SessionFilter.USER_INFO,user);
    //种cookie
    CookieBasedSession.onNewSession(request,response);
    //重定向
    response.sendRedirect("/index");
}

3.4、启动服务访问测试:

http://a.dev.com:8090/index

http://b.dev.com:8091/index

A、B服务刚开始访问都是未登录状态如下:

 

第三章 Session共享&单点登陆笔记

当a登录以后刷新b服务,发现已经是登录状态:

 

第三章 Session共享&单点登陆笔记

三、多服务下的登陆困境及SSO方案

1、SSO的产生背景

较大的企业内部,一般都有很多的业务支持系统为其提供相应的管理和IT服务。通常来说,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好。那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?

“单点登录”就是专为解决此类问题的。其大致思想流程如下:通过一个ticket进行串接各系统间的用户信息

 

第三章 Session共享&单点登陆笔记

2、SSO的底层原理CAS

2.1起点

1)对于完全不同域名的系统,cookie是无法跨域名共享的,因此sessionId在页面端也无法共享。

2)cas方案,直接启用一个专业的用来登陆的域名(比如:cas.com)来供所有的系统的sessionId。

3)当业务系统(如 b.com)被打开时,借助cast系统来登陆,具体流程参见2.2

4)cas.com的登陆页面被打开时,如果此时cas.com本来就是登陆状态的,则自动返回生成ticket给业务系统。整个单点登陆的关键部位,是利用cas.com的cookie保持cas.com是登陆状态,此后任何第三个系统跳入,都将自动完成登陆过程。

5)本示例中,使用了redis来做cas的服务接口,根据工作情况,自行替换为合适的服务接口(主要是根据sessionid来判断用户是否已登陆)。

6)为提高安全性,ticket应该使用过即作废(本例中,会用有效期机制)。

2.2cas登陆的全过程:

1)b.com打开时,发现自己未登陆,于是跳转到cas.com去登陆。 

2)cas.com登陆页面被打开,用户输入帐户/密码登陆成功。

3)cas.com登陆成功,种cookie到cas.com域名下,把sessionid放入后台redis《ticket,sessionId》然后页面跳回b.com。

4)b.com重新被打开,发现仍然是未登陆,但是有了一个ticket值。

5)b.com用ticket值,到redis里查到sessionid,并做session同步,种cookie到自己域名下面,页面原地重跳。

6)b.com打开自己页面,此时有了cookie,后台校验登陆状态,成功。

3、代码实战:

新建项目:springboot-cas-login,springboot-cas-website,springboot-cas-website

3.1、springboot-cas-login项目核心代码配置如下:

1)pom配置

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.3.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>springboot-cas-login</artifactId>
<name>springboot-cas-login</name>
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <java.version>1.8</java.version>
</properties>
<dependencies>
    <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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

2)application.yml配置,注意本地host配置域名cas.com

#启动: http://cas.com:8080/index
server:
  port: 8080
  compression:
    enabled: true
  connection-timeout: 3000
spring:
  redis:
      host: 127.0.0.1
      port: 6379

3)登录拦截过滤处理类代码

public class LoginFilter implements Filter {
    public static final String USER_INFO = "user";
    @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;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        Object userInfo = request.getSession().getAttribute(USER_INFO);;
        //TODO 如果未登陆,则拒绝请求,转向登陆页面
        String requestUrl = request.getServletPath();
        if (!"/toLogin".equals(requestUrl)//不是登陆页面
                && !requestUrl.startsWith("/login")//不是去登陆
                && null == userInfo) {//不是登陆状态
            request.getRequestDispatcher("/toLogin").forward(request,response);
            return ;
        }
        filterChain.doFilter(request,servletResponse);
    }
    @Override
    public void destroy() {
    }
}

4)配置filter生效

@Configuration
public class LoginConfig {
    //配置filter生效
    @Bean
    public FilterRegistrationBean sessionFilterRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new LoginFilter());
        registration.addUrlPatterns("/*");
        registration.addInitParameter("paramName", "paramValue");
        registration.setName("sessionFilter");
        registration.setOrder(1);
        return registration;
    }
}

5)接口访问类代码

@Controller
public class IndexController {
    @Autowired
    private RedisTemplate redisTemplate;
    @GetMapping("/toLogin")
    public String toLogin(Model model,HttpServletRequest request) {
        Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
        //不为空,则是已登陆状态
        if (null != userInfo){
            String ticket = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
            return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
        }
        UserForm user = new UserForm();
        user.setUsername("hankin");
        user.setPassword("hankin");
        user.setBackurl(request.getParameter("url"));
        model.addAttribute("user", user);
        return "login";
    }
    @PostMapping("/login")
    public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
        System.out.println("backurl:"+user.getBackurl());
        request.getSession().setAttribute(LoginFilter.USER_INFO,user);
        //TODO 登陆成功,创建用户信息票据
        String ticket = UUID.randomUUID().toString();
        redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
        //TODO 重定向,回原url---a.com
        if (null == user.getBackurl() || user.getBackurl().length()==0){
            response.sendRedirect("/index");
        } else {
            response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
        }
    }
    @GetMapping("/index")
    public ModelAndView index(HttpServletRequest request) {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("index");
        modelAndView.addObject("user", request.getSession().getAttribute(LoginFilter.USER_INFO));
        request.getSession().setAttribute("test","123");
        return modelAndView;
    }
}

3.2、springboot-cas-website项目代码配置

其他配置基本同cas-login项目一样,唯一不同的就是拦截过滤代码部分:

1)如果未登陆,则拒绝请求,转向登陆页面;

2)有票据,则使用票据去尝试拿取用户信息;

3)无法得到用户信息,则去登陆页面;

4)将用户信息,加载进session中。

public class SSOFilter implements Filter {
    private RedisTemplate redisTemplate;
    public static final String USER_INFO = "user";
    public SSOFilter(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;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        Object userInfo = request.getSession().getAttribute(USER_INFO);;
        //TODO 如果未登陆,则拒绝请求,转向登陆页面
        String requestUrl = request.getServletPath();
        if (!"/toLogin".equals(requestUrl)//不是登陆页面
                && !requestUrl.startsWith("/login")//不是去登陆
                && null == userInfo) {//不是登陆状态
            String ticket = request.getParameter("ticket");
            //TODO 有票据,则使用票据去尝试拿取用户信息
            if (null != ticket){
                userInfo = redisTemplate.opsForValue().get(ticket);
            }
            //TODO 无法得到用户信息,则去登陆页面
            if (null == userInfo){
                response.sendRedirect("http://cas.com:8080/toLogin?url="+request.getRequestURL().toString());
                return ;
            }
            // TODO 将用户信息,加载进session中
            request.getSession().setAttribute(SSOFilter.USER_INFO,userInfo);
            redisTemplate.delete(ticket);
        }
        filterChain.doFilter(request,servletResponse);
    }
    @Override
    public void destroy() { }
}

3.3、springboot-cas-website2项目代码

springboot-cas-website2与springboot-cas-website代码一样,除了application.yml配置文件。

3.4、启动项目测试

访问地址:

http://cas.com:8080/index

http://cas.com:8080/toLogin?url=http://a.com:8090/index

http://a.com:8090/index?ticket=1ef7a91b-ea3b-4990-a46d-eaeeaaf37a3b

http://b.com:8091/index?ticket=38089aee-6dc9-498c-8284-ef4b95fb2368

开始没登录的时候三个地址访问进去都是跳转到登录页面,需要登录当有一个地方登录以后,再次访问其他两个地址,发现都已经登录成功,结果如下所示:

 

第三章 Session共享&单点登陆笔记