抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

分布式会话与单点登录SSO

  • 分布式会话
  • 拦截器
  • 单点登录, 一个公司可能有好多产品线产品系对于用户来说只要登录一次公司的其它产品就可以不需要二次登录, sessions跨域和共享

分布式会话

什么是会话

会话 Session 代表的是客户端与服务器的一次交互过程, 这个过程可以是连续也可以是时断时续的.曾经的 Servlet 时代(jsp), 一旦用户与服务端交互, 服务器 tomcat 就会为用户创建一个 session, 同时前端会有一个 jsessionid, 每次交互都会携带.

如此一来, 服务器只要在接到用户请求时候, 就可以拿到 jsessionid ,并根据这个 ID 在内存中找到对应的会话 session, 当拿到 session 会话后, 那么就可以操作会话了.

会话存活期间, 就能认为用户一直处于正在使用着网站的状态, 一旦 session 超期过时, 那么就可以认为用户已经离开网站, 停止交互了.用户的身份信息, 可以通过 session 来判断的, 在session中可以保存不同用户的信息.

@GetMapping("/setSession")
public Object setsession(Http servletrequest request){
    Httpsession session = request.getSession();
    session.setAttribute("userInfo","newuser");
    session.setMaxInactiveInterval(3600);
    session.getAttribute("userInfo");
    session.removeAttribute("userInfo");
    return "ok";
}

无状态会话

HTTP请求是无状态的, 用户向服务端发起多个请求, 服务端并不会知道这多次请求都是来自同一用户, 这个就是无状态的. cookie 的出现就是为了有状态的记录用户.

常见的 iOS/Android 与服务端交互, 前后端分离, 小程序与服务端交互, 他们都是通过发起 http 来调用接口数据的, 每次交互服务端都不会拿到客户端的状态, 但是可以通过手段去处理, 比如每次用户发起请求的时候携带一个 userid 或者 user-token, 如此一来, 就能让服务端根据用户 idtoken 来获得相应的数据.每个用户的下一次请求都能被服务端识别来自同一个用户.

有状态会话

Tomcat中的会话, 就是有状态的, 一旦用户和服务端交互, 就有会话, 会话保存了用户的信息, 这样用户就“有状态”了, 服务端会和每个客户端都保持着这样的一层关系, 这个由web容器来管理(也就是 tomcat ), 这个session会话是保存到内存空间里的, 如此一来, 当不同的用户访问服务端, 那么就能通过会话知道谁是谁了.如果用户不再和服务端交互, 那么会话则消失, 结束了他的生命周期.如此一来, 每个用户其实都会有一个会话被维护, 这就是有状态会话.

场景: 在传统项目或者 jsp项目 中是使用的最多的session都是有状态的, session的存在就是为了弥补 http 的无状态.

注: tomcat会话可以通过手段实现多系统之间的状态同步, 但是会损耗一定的时间, 一旦发生同步那么用户请求就会等待, 这种做法不可取.

为何使用无状态会话

有状态会话都是放在服务器的内存中的, 一旦用户会话量多, 那么内存就会出现瓶颈.而无状态会话可以采用介质, 前端可以使用 cookie (app可以使用缓存)保存用户 id 或 token, 后端比如 Redis, 相应的用户会话都能放入 redis 中进行管理, 如此, 对应用部暑的服务器就不会造成内存压力.用户在前端发起http请求, 携带 id 或 token, 如此服务端能够根据前端提供的 id 或 token 来识别用户了, 可伸缩性就更强了.

Tomcat 单个会话

先来看一下单个 tomcat 会话, 这个就是有状态的, 用户首次访问服务端, 这个时候会话产生, 并且会设置 jsessionid 放入 cookie 中, 后续每次请求都会在cookie中携带 jsessionid 以保持用户状态.

动静分离会话

用户请求服务端, 由于动静分离, 前端发起http请求, 不携带任何状态, 当用户第一次请求以后, 手动设置一个token, 作为用户会话, 放入redis中, 如此作为 redis-session, 并且这个 token 设置后放入前端 cookie 中(app或小程序可以放入本地缓存), 如此后续交互过程中, 前端只需要传递 token 给后端, 后端就能识别这个用户请求来自谁了.

集群分布式系统会话

集群或分布式系统本质都是多个系统, 假设这个里有两个服务器节点, 分别是AB系统, 他们可以是集群, 也可以是分布式系统, 一开始用户和A系统交互, 那么这个时候的用户状态, 可以保存到 redis 中, 作为A系统的会话信息, 随后用户的请求进入到了B系统, 那么B系统中的会话也同样和 redis 关联, 如此AB系统的 session 就统一了. 当然 cookie 是会随着用户的访问携带过来的. 这个就是分布式会话, 通过 redis 来保存用户的状态.

集成SpringSession

添加依赖

<!-- SpringSession -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- SpringBootSecurity Spring安全框架 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

修改配置文件

spring:
  session:
    # 会话保存介质
    store-type: redis

修改启动类.添加@EnableRedisHttpSession注解.

开启之后打开localhost:8088/hello.

默认用户名:user 密码会在控制台打印出来:e348b3f7-d194-4e87-86f8-e7f168471257

关掉登录验证修改@SpringBootApplication(exclude = {SecurityAutoConfiguration.class})

建议使用自己Redis的方式实现. 使用此方式的实现会有耦合不利于其它语言的整合使用.

分布式会话拦截器

创建一个类集成HandlerInterceptor重写需要用到的方法.

public class UserTokenInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisOperator redisOperator;
    private static final String REDIS_USER_TOKEN = "redis_user_token";
    private static final Logger log = LoggerFactory.getLogger(UserTokenInterceptor.class);

    /**
     * 拦截请求在访问controller之前
     *
     * @param request  HttpServletRequest
     * @param response HttpServletResponse
     * @param handler  Object
     * @return 是否拦截
     * @throws Exception 异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // token对比
        // 放在header可以降低耦合度
        // headerUserId
        // headerUserToken
        String userId = request.getHeader("headerUserId");
        String userToken = request.getHeader("headerUserToken");
        if (StringUtils.isBlank(userId) || StringUtils.isBlank(userToken)) {
            log.info("接收到数据为空!");
            this.returnErrorResponse(response, FoodieJsonResult.errorMsg("请登录!"));
            return false;
        }

        // 根据用户id获取token
        String uniqueToken = this.redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(uniqueToken)) {
            log.info("用户token查询不到");
            this.returnErrorResponse(response, FoodieJsonResult.errorMsg("请登录!"));
            return false;
        }

        if (!StringUtils.equals(uniqueToken, userToken)) {
            // token不一致
            this.returnErrorResponse(response, FoodieJsonResult.errorMsg("账号可能在异地登录了!"));
            return false;
        }

        // false 请求被拦截被驳回验证出现问题
        // true 请求经过验证校验之后是ok的可以放行
        return true;
    }


    private void returnErrorResponse(HttpServletResponse response, FoodieJsonResult result) {
        // 直接写出去
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json");
        try (ServletOutputStream out = response.getOutputStream()) {
            out.write(JsonUtils.objectToJson(result).getBytes(StandardCharsets.UTF_8));
            out.flush();
        } catch (Exception exception) {
            log.error("拦截异常:{}", exception.getMessage());
        }
    }

    /**
     * 请求访问controller之后渲染视图之前(数据的渲染)
     *
     * @param request
     * @param response
     * @param handler
     * @param modelAndView
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    /**
     * 请求访问controller之后在渲染视图之后
     *
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }

}

在配置类中添加拦截器, WebMvcConfig.java.

    @Bean
    public HandlerInterceptor userTokenInterceptor() {
        return new UserTokenInterceptor();
    }

    /**
     * 注册拦截器
     *
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(userTokenInterceptor())
                // 要拦截的路由
                .addPathPatterns("/hello");
        WebMvcConfigurer.super.addInterceptors(registry);
    }

CAS单点登录

相同顶级域名单点登录SSO

单点登录又称之为 Single Sign On, 简称SSO, 单点登录可以通过基于用户会话的共享, 可以分为两种.

先来看第一种, 那就是他的原理是分布式会话来实现. 比如说现在有个一级域名为 www.imooc.com, 是教育类网站, 但是慕课网有其他的产品线, 可以通过构建二级域名提供服务给用户访问, 比如: music.imooc.com, shop.imooc.com, blog.imooc.com等等, 分别为慕课音乐, 慕课电商以及慕课博客等, 用户只需要在其中一个站点登录, 那么其他站点也会随之而登录.

也就是说, 用户自始至终只在某一个网站下登录后, 那么他所产生的会话, 就共享给了其他的网站, 实现了单点网站登录后, 同时间接登录了其他的网站, 那么这个其实就是单点登录, 他们的会话是共享的, 都是同一个用户会话.

之前所实现的分布式会话后端是基于 redis 的, 如此会话可以流窜在后端的任意系统, 都能获取到缓存中的用户数据信息, 前端通过使用 cookie , 可以保证在同域名的一级二级下获取, 那么这样一来, cookie中的信息 userid 和 token 是可以在发送请求的时候携带上的, 这样从前端请求后端后是可以获取拿到的, 这样一来, 用户在某一端登录注册以后, 其实 cookie 和 redis 中都会带有用户信息, 只要用户不退出, 那么就能在任意一个站点实现登录了.

  • 原理主要也是 cookie 和 网站 的依赖关系, 顶级域名 www.imooc.com 和 *.imooc.com的cookie值是可以共享的, 可以被携带至后端的, 比如设置为 .imooc.com, .t.imooc.com, 这样是OK的.
  • 二级域名自己的独立 cookie 是不能共享的, 不能被其他二级域名获取, 比如: music.imooc.com的 cookie 是不能被 mtv.imooc.com共享, 两者互不影响, 要共享必须设置为*.imooc.com.

Cookie共享测试

修改前端项目中app.js中的 cookieDomain: “t.foodie.com”.

不相同顶级域名单点登录SSO

使用CAS, Central Authentication Service, 中央认证服务. 具体步骤:

  1. 初次访问
  2. 验证是否登录
  3. 携带returnUrl跳转至CAS
  4. 验证未登录
  5. 跳转到CAS登录页面
  6. 用户名密码登录
  7. 登录成功
  8. 创建用户会话
  9. 创建用户全局门票
  10. 全局门票与用户信息相关联
  11. 创建临时票据
  12. 回跳并携带临时票据
  13. 客户端请求校验临时票据
  14. 校验并且成功
  15. 用户会话回传
  16. 保存用户会话
  17. 显示登录成功

评论