Redis+Cookie实现分布式会话与单点登录


前言

这篇文章熬了两天终于搞定了,最后的单点登录的代码着实有点绕,因为每次都要校验很多内容。 通过写这篇文章,我也解开了我心中的谜团:分布式系统中各个机器到底是怎样交互的???

一、什么是分布式会话

1.会话

在servlet年代,当用户与服务器交互时,服务器tomcat会为用户创建一个session,并且前端会生成一个jsessionId,每次访问服务器都会携带。服务器在接受到请求时,就能接收到这个jsessionId,并根据Id在内存中找到对应的session,当拿到了session会话就可以进行操作了。在会话存活时,就可以认为用户一直在线,当会话超时,就可以认为用户已离开。用户的信息都通过会话来校验。

2.无状态会话

Http是无状态的,即服务端无法区分是哪个用户访问的。而cookie的出现就是为了有状态的记录用户。用户每次访问服务器时,可以携带一个token或者userId,这样就可以让服务器能够识别到用户的信息,变成有状态的。

3.为什么要用无状态会话

  • 有状态会话都是放在服务器内存中的,当用户量过多,就会造成瓶颈。
  • 无状态会话可以采用介质,前端使用cookie存储userId/userToken,后端可以使用redis。这样服务器的压力就小了。

4.集群分布式系统会话

对于阿里云控制台,它内部有许多不同的模块,我们以域名和云服务器为例。对于阿里这样访问量大的网站, 它肯定是把这两个模块放在不同的机器上的。

  • 当我们进入云服务器时,(若之前没登陆过)要我们先去登录。这时就相当于我们上面所说的无状态会话中生成一个token
  • 在登录进入云服务器这个模块后,点击域名模块可以直接跳转,并且不需要再次登录。这个过程就是前端根据cookie中的数据,请求后端,实现单点登录

    (我觉得如果阿里云里面能跳到淘宝,就不用登录了)

集群分布式系统的会话模式:各个系统之间以redis作为介质,用户的token存在共享的redis里,用户浏览器存储cookie,实现有状态的会话。

二、项目中实现用户会话

目标1: 当用户登录/注册成功时,要为用户生成token,并存储在cookie和redis中。
目标2: 当用户要修改个人信息时,由于会涉及到cookie和redis内存储的内容,要更新redis和cookie

	// 生成用户token
    String uniqueToken = UUID.randomUUID().toString().trim();
    // 存入redis会话
    redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(), uniqueToken);

	// 这里读者不用关心,是和业务有关的
	// 由于cookie是浏览器可见的,不能将用户的所有信息都放在cookie中(不安全)
	// 因此,只需要拿出一小部分属性即可,所以使用了一个VO(view object)
    UsersVO usersVO = new UsersVO();
    BeanUtils.copyProperties(userResult, usersVO);
    usersVO.setUserUniqueToken(uniqueToken);

	// 存入cookie中
	CookieUtils.setCookie(request, response, "user", JsonUtils.objectToJson(usersVO), true);

目标3: 当用户退出登录时,要删除cookie和redis

	//清除cookie
    CookieUtils.deleteCookie(request, response, "user");

    // 分布式会话中需要清除用户数据
    redisOperator.del(REDIS_USER_TOKEN + ":" + userId);

三、分布式会话拦截器

1.为什么要加拦截器?

先来看修改用户信息的代码:

	@ApiOperation(value = "修改用户信息", notes = "修改用户信息", httpMethod = "POST")
    @PostMapping("/update")
    public IMOOCJSONResult update(
            @ApiParam(name = "userId", value = "用户Id", required = true)
            @RequestParam String userId,
            @RequestBody @Valid CenterUserBO centerUserBO,
            BindingResult errorResult,
            HttpServletRequest request,
            HttpServletResponse response) {
        //BindingResult是否保存错误的验证信息
        if (errorResult.hasErrors()) {
            Map<String, String> errorMap = getErrors(errorResult);
            return IMOOCJSONResult.errorMap(errorMap);
        }

        Users result = centerUserService.updateUserInfo(userId, centerUserBO);
        // 添加token,整合进redis,分布式会话
        UsersVO usersVO = conventUsersVO(result);
        //token存入cookie
        CookieUtils.setCookie(request, response, "user", JsonUtils.objectToJson(result), true);
        return IMOOCJSONResult.ok();
    }

设想一种情况:当一个非法用户解析到了这个API接口,然后他就有可能能够修改其他用户的信息。
因此我们需要在请求这个接口 之前加一层验证,来确保执行此接口的用户是本人。

2.怎样加拦截器

  • 首先实现HandlerInterceptor接口,然后重写拦截器的三个方法(由于需求是在请求接口之前进行拦截校验,所以只需要使用第一个方法)
  • 拦截原理:前端传来UserId和UserToken,后端用UserId去查找Token。
    1. 若前端UserId和UserToken为空,说明没登录
    2. 若后端从redis中查不到token,说明用户没登陆过
    3. 若后端从redis中查到了token但是和前端传来的不一样,说明后端的token已经更新了,这说明要么是非法用户,要么是用户又登录了一次,导致token刷新。这样可以直接返回用户在其他地方登录。
/**
 * @author runze
 * @create 2020/11/1
 * @description
 */
public class UserTokenInterceptor implements HandlerInterceptor {

    public static final String REDIS_USER_TOKEN = "redis_user_token";

    @Autowired
    private RedisOperator redisOperator;

    /*
     * 拦截请求在访问controller调用之前
     * */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        /*
         * false代表请求被拦截,被驳回,验证出了问题
         * true校验通过,可放行
         * */
        String userId = request.getHeader("headerUserId");
        String userToken = request.getHeader("headerUserToken");
        //先看前端有没有ID和Token
        if (StringUtils.isNotBlank(userId) && StringUtils.isNotBlank(userToken)) {
            //从redis去拿userToken
            String uniqueToken = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
            //拿不到说明没登陆
            if (StringUtils.isBlank(uniqueToken)) {
                returnErrorResponse(response, IMOOCJSONResult.errorMsg("请登录..."));
                return false;
            } else {
                //若拿到了但是和前端传来的不同,说明Token在redis中已经被更新了
                if (!uniqueToken.equals(userToken)) {
                    returnErrorResponse(response, IMOOCJSONResult.errorMsg("账号在异地登录..."));
                    return false;
                }
            }
        } else {
            returnErrorResponse(response, IMOOCJSONResult.errorMsg("请登录..."));
            return false;
        }
//      System.out.println("进入拦截器,被拦截");
        return true;
    }

    public void returnErrorResponse(HttpServletResponse response, IMOOCJSONResult result) {
        OutputStream ous = null;
        try {
            response.setCharacterEncoding("utf-8");
            response.setContentType("text/json");
            ous = response.getOutputStream();
            ous.write(JsonUtils.objectToJson(result).getBytes("utf-8"));
            ous.flush();
        } catch (IOException e) {
            e.printStackTrace();

        } finally {
            try {
                if (ous != null) {
                    ous.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    /*
     * 请求访问controller之后,渲染视图之前
     * */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    }
    /*
     * 请求访问controller之后,渲染视图之后
     * */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }
}

现在仅仅是实现了拦截器的业务逻辑,还要将拦截器注册到系统中

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Bean
    public UserTokenInterceptor userTokenInterceptor() {
        return new UserTokenInterceptor();
    }

    /**
     * 注册拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(userTokenInterceptor())
                .addPathPatterns("/hello")
                .addPathPatterns("/shopcart/add")
                .addPathPatterns("/shopcart/del")
                .addPathPatterns("/address/list")
                .addPathPatterns("/address/add")
                .addPathPatterns("/address/update")
                .addPathPatterns("/address/setDefalut")
                .addPathPatterns("/address/delete")
                .addPathPatterns("/orders/*")
                .addPathPatterns("/center/*")
                .addPathPatterns("/userInfo/*")
                .addPathPatterns("/myorders/*")
                .addPathPatterns("/mycomments/*")
                .excludePathPatterns("/myorders/deliver")
                .excludePathPatterns("/orders/notifyMerchantOrderPaid");

        WebMvcConfigurer.super.addInterceptors(registry);
    }
}

四、单点登录SSO(Single Sign On)

单点登录分为两种:相同顶级域名下的单点登录不同顶级域名下的单点登录

相同域名下的单点登录

  • 考虑上文中阿里云的例子,从阿里云去访问域名服务

  • CSDN中的例子



    可以看到,这三个网站都是CSDN的子网站,在跳转时都不需要进行二次密码输入验证,打开控制台可以看到,两个网址中都存在UserToken这个Cookie键,值是相同的。(这里并不是要强调这个键,可以看到其他的键值对也有许多是相同的,因为他们之间是共享的。)

可以看到,两个网站的顶级域名相同,并且,在我从阿里云跳转到域名时,不需要进行二次验证。
这种情况下可以通过Redis+cookie实现单点登录,它的原理是:

  • 顶级域名下,例如A.aliyun.com和B.aliyun.com是可以共享的,因为他们都在aliyun.com域名下,浏览器在两个页面中可以共享aliyun.com的cookie
  • 而对于A.A.aliyun.com和B.B.aliyun.com是不能共享的,他们的A.aliyun.com和B.aliyun.com下的cookie是不同的。
  • 当cookie可以共享时,可以共享用户登录的token,传给后端,后端在redis找到响应的token,即可以登录成功。

不同域名下的单点登录CAS(Central Authentication Service)

当在两个不同域名下进行单点登录时,由于cookie中的内容不能共享,所以,上面的方法就变成了无状态的会话。
对于下图所示,当用户登录了abc.com这时候他要跳转到123.com,由于没有携带cookie信息,所以要二次登录。

若还要进行单点登录,可以考虑添加一个单独的系统 CAS(Central Authentication Service) 即中央认证服务。(这里的CAS可不是Java并发中的CAS(Compare And Set))

CAS的时序图

在介绍整个流程之前,我们要先了解四个值:

  • redis_user_token:代表用户会话信息,只要用户登录,就在redis中创建一个用户会话
  • redis_user_ticket:给用户创建的一个全局门票,代表用户在CAS登录过。
  • cookie_user_ticket:存在cookie中的一个全局门票,其含义同redis_user_ticket
  • redis_tmp_ticket:给用户创建的一个临时门票,这个票据用来回跳到原来的站点,并且使用完后就要销毁掉

这里门票可以这样理解:我们去一个景区,在进景区的大门时,景区给我们一个大的门票,这个门票就相当于redis_user_ticket,即全局门票,然后当我们进入一个个小的景点,或者坐观光车时,也会有一个临时门票,那个门票就是redis_tmp_ticket,一次性使用,用完后就要销毁掉,就相当于我们访问一个子网站,访问时生成,访问成功了就销毁了。

1.当用户访问时,子系统首先会验证是否登录,携带returnUrl到CAS系统中进行验证。

  • returnUrl是什么?当跳转到验证界面后验证成功后,还要重新跳回到原来页面,returnUrl就是在验证完成后要跳回的地方。
	@GetMapping("/login")
    public String login(String returnUrl,
                        Model model,
                        HttpServletRequest request,
                        HttpServletResponse response) {

        model.addAttribute("returnUrl", returnUrl);

        // 1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时票据并且回跳
        String userTicket = getCookie(request, COOKIE_USER_TICKET);

        boolean isVerified = verifyUserTicket(userTicket);
        //当用户登录过,就可以直接返回
        if (isVerified) {
            String tmpTicket = createTmpTicket();
            return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
        }

        // 2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面
        return "login";
    }

2.若未登陆过,则会进入CAS登录页面进行账号密码登录,输入账号密码后,CAS进行校验,并创建相应票据

/**
     * CAS的统一登录接口
     * 目的:
     * 1. 登录后创建用户的全局会话                 ->  uniqueToken
     * 2. 创建用户全局门票,用以表示在CAS端是否登录  ->  userTicket
     * 3. 创建用户的临时票据,用于回跳回传          ->  tmpTicket
     */
    @PostMapping("/doLogin")
    public String doLogin(String username,
                          String password,
                          String returnUrl,
                          Model model,
                          HttpServletRequest request,
                          HttpServletResponse response) throws Exception {

        model.addAttribute("returnUrl", returnUrl);

        // 0. 判断用户名和密码必须不为空
        if (StringUtils.isBlank(username) ||
                StringUtils.isBlank(password)) {
            model.addAttribute("errmsg", "用户名或密码不能为空");
            return "login";
        }

        // 1. 实现登录,不正确就直接返回登录页面
        Users userResult = userService.queryUserForLogin(username,
                MD5Utils.getMD5Str(password));
        if (userResult == null) {
            model.addAttribute("errmsg", "用户名或密码不正确");
            return "login";
        }

        // 2. 实现用户的redis会话
        String uniqueToken = UUID.randomUUID().toString().trim();
        UsersVO usersVO = new UsersVO();
        BeanUtils.copyProperties(userResult, usersVO);
        usersVO.setUserUniqueToken(uniqueToken);
        // 将REDIS_USER_TOKEN存入redis,这个是代表用户会话,里面存储用户的信息和Token
        redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(),
                JsonUtils.objectToJson(usersVO));

        // 3. 生成ticket门票,全局门票,代表用户在CAS端登录过
        String userTicket = UUID.randomUUID().toString().trim();

        // 3.1 用户全局门票需要放入CAS端的cookie中
        setCookie(COOKIE_USER_TICKET, userTicket, response);

        // 4. userTicket关联用户id,并且放入到redis中,代表这个用户有门票了,可以在各个景区游玩
        redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId());

        // 5. 生成临时票据,回跳到调用端网站,是由CAS端所签发的一个一次性的临时ticket
        //这个临时门票只能用这一次,在跳转后就失效
        String tmpTicket = createTmpTicket();
        
        return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
    }
/**
     * 创建临时票据
     *
     * @return
     */
    private String createTmpTicket() {
        String tmpTicket = UUID.randomUUID().toString().trim();
        try {
        	// 过期时间600s
            redisOperator.set(REDIS_TMP_TICKET + ":" + tmpTicket,
                    MD5Utils.getMD5Str(tmpTicket), 600);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return tmpTicket;
    }

3.若已经登陆过

  1. 从cookie中获取到cookie_user_ticket(全局门票)
  2. 判断cookie_user_ticket是否有效 --> redis中是否存在redis_user_ticket
  3. 验证会话是否存在(能否找到uredis_user_token)
/**
     * 校验CAS全局用户门票co
     *
     * @param userTicket
     * @return
     */
    private boolean verifyUserTicket(String userTicket) {

        // 0. 验证CAS门票不能为空
        if (StringUtils.isBlank(userTicket)) {
            return false;
        }

        // 1. 验证CAS门票是否有效
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
            return false;
        }

        // 2. 验证门票对应的user会话是否存在
        String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userRedis)) {
            return false;
        }

        return true;
    }

4.子系统请求CAS对临时ticket进行校验,CAS校验完成后,删除tmp_ticket

	@PostMapping("/verifyTmpTicket")
    @ResponseBody
    public IMOOCJSONResult verifyTmpTicket(String tmpTicket,
                                           HttpServletRequest request,
                                           HttpServletResponse response) throws Exception {

        // 使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
        // 使用完毕后,需要销毁临时票据
        String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket);
        if (StringUtils.isBlank(tmpTicketValue)) {
            return IMOOCJSONResult.errorUserTicket("tmpTicketValue为空,用户票据异常");
        }

        // 0. 如果临时票据OK,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户会话
        if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) {
            return IMOOCJSONResult.errorUserTicket("票据不一致,用户票据异常");
        } else {
            // 销毁临时票据
            redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket);
        }

        // 1. 验证并且获取用户的userTicket
        String userTicket = getCookie(request, COOKIE_USER_TICKET);
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
            return IMOOCJSONResult.errorUserTicket("userId为空,用户票据异常"+userTicket);
        }

        // 2. 验证门票对应的user会话是否存在
        String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userRedis)) {
            return IMOOCJSONResult.errorUserTicket("Redis中没了,用户票据异常");
        }

        // 验证成功,返回OK,携带用户会话
        return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis, UsersVO.class));
    }

作为一名高尚的后端程序员,我们要善于去看前端逻辑

5.注销登录

  1. 清除cookie、redis的userTicket
  2. 清除用户会话userToken
	@PostMapping("/logout")
    @ResponseBody
    public IMOOCJSONResult logout(String userId,
                                  HttpServletRequest request,
                                  HttpServletResponse response) throws Exception {

        // 0. 获取CAS中的用户门票
        String userTicket = getCookie(request, COOKIE_USER_TICKET);

        // 1. 清除userTicket票据,redis/cookie
        deleteCookie(COOKIE_USER_TICKET, response);
        redisOperator.del(REDIS_USER_TICKET + ":" + userTicket);

        // 2. 清除用户全局会话(分布式会话)
        redisOperator.del(REDIS_USER_TOKEN + ":" + userId);

        return IMOOCJSONResult.ok();
    }

版权声明:本文为qq_44357371原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。