Redis+Token实现用户注册登录,拦截

630 阅读6分钟

Redis+Token实现用户注册登录,拦截

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情

具体流程是这样的:

1.前端发送带有电话号码的请求,请求后端返回一个验证码

思路:

  • 检验手机是否符合规范
  • 生成随机的二维码
  • 存入redis缓存中,将phone作为key,code作为value
  • 调用第三方服务发送短信验证码到你的手机上
@Override
    public Result sendCode(String phone, HttpSession session) {
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号不合规范");
        }
​
        String code = RandomUtil.randomNumbers(6);
    
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
​
        //发送短信验证码
        log.debug("开始发送短信" + code);
​
        return Result.ok();
    }

这时候,你也许会好奇,为什么这里我们不用session进行这个验证码的存储呢?

原来,如果我们部署了多个这样的相同的项目,然后利用nginx做负载均衡依次转发的时候,是不是只有一台服务器拿到了session中的值呀?那么第二胎服务器肯定没有第一台服务器存放的session,因此,就出现了,如果第二台服务器需要用到用户信息,那么它将获取不到的情况。

那么,我们如何去解决这个问题呢?

早期的方案是session的拷贝,通过tomcat的session拷贝,实现共享,但是这个对tomcat的内存负担太大,并且数据拷贝的时候,延迟也是肯定会有的

后来,我们就基于redis,我们不把数据放在session里面,因为redis也是可以实现是数据共享的

image-20221020183549256

2.在前端收到了短信验证码以后呢,它肯定会向后端再次发送自己收到的验证码,来向后端确认自己收到的验证码和刚刚保存的验证码一致。如果一致就实现了登录

思路:

  • 继续校验手机号

  • 从redis中取出验证码,和传过来的code进行校验

  • 从数据库中通过phone查有关用户,如果没有说明这是第一次注册,存入数据库,进行下一步。如果有,就进行下一步

  • 生成一个唯一token,作为存储用户登录态的一个key,它是乱序的,并将user的信息作为value存入redis

  • 设置过期时间

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.从redis获取验证码并校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)) {
            // 不一致,报错
            return Result.fail("验证码错误");
        }
    ​
        // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();
    ​
        // 5.判断用户是否存在
        if (user == null) {
            // 6.不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }
    ​
        // 7.保存用户信息到 redis中
        // 7.1.随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 7.2.将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 7.3.存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 7.4.设置token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.返回token
        return Result.ok(token);
    }
    ​
      private User createUserWithPhone(String phone) {
            User user = new User();
            user.setPhone(phone);
            user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
            save(user);
            return user;
        }
    

    这里你获取又会有所疑惑,为什么需要一个token作为它的一个key呢?

    原来,在前端中。第一次登录完以后,我们会把这个token作为一个请求头放入anxios的请求体中。如果这个请求头中没有这个token,那么我们后端就会把它直接拦截!

    3.下面,我们将进行拦截器的书写

    思路:

    • 在请求头中拿到token,并且进行校验
    • 在redis中取到user的信息
    • 在UserHolder中存入用户信息
    • 设置过期时间
    public class RefreshTokenInterceptor implements HandlerInterceptor {
    ​
         private StringRedisTemplate stringRedisTemplate;
    ​
        public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    ​
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String token = request.getHeader("authorization");
            if(StrUtil.isBlank(token)){
                return false;
            }
            String key = LOGIN_USER_KEY + token;
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    ​
            if (userMap.isEmpty()){
                return false;
            }
            UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
    ​
            UserHolder.saveUser(userDTO);
    ​
            stringRedisTemplate.expire(key,LOGIN_CODE_TTL, TimeUnit.MINUTES);
    ​
            return true;
        }
    ​
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserHolder.removeUser();
        }
    }
    

    我们这里的StringRedisTemplate用了构造器,我们期待之后再创建这个类的时候,外层先利用@Resouce注解先注入,再传入。

    这里的afterCompletion意思是,在拦截器链结束后(spring gateway里面的拦截器链类似),执行的操作,移除当前线程的user对象

    这时候,你或许又有疑问,UserHolder又是什么?

    我们这里创建了一个UserHolder的类

    public class UserHolder {
        private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
    ​
        public static void saveUser(UserDTO user){
            tl.set(user);
        }
    ​
        public static UserDTO getUser(){
            return tl.get();
        }
    ​
        public static void removeUser(){
            tl.remove();
        }
    }
    

    它的本质其实就是TreadLocal,我们对于有token的请求去查询对应的user,并且把它放入当前的本地线程对象中,这是一种基于内存的存储方式。我们在这里只写了一个拦截器,那如果有很多个拦截器呢?难道每次在某个拦截器用到的时候,就还得再查一遍数据库吗?

    4.拦截器的放行

    同学们思考一个问题,如果说,拦截器这样设计虽然能解决,只有用户登录态的请求能访问后台其它请求,但是初始的注册和登录请求难道也要进行拦截吗?那岂不是永远都没有用户能注册了?

    没错,我们需要对拦截器做一些配置,让他放行

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    ​
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    ​
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new RefreshTokenInterceptor())
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login"
                    )
                    .order(1);
        }
    }
    

    但是噢,细心的同学又会有所疑惑,如果我们只在不被拦截的网址里面请求,那么过一段时间,我的用户登录态是不是又会消失了?

    没错,是这样的,那么我们怎么改进呢?

    我们是不是要一个拦截器,然后这个拦截器每一个请求都需要进行拦截,然后在拦截器里面刷新一下redis存的用户的登录态?

    没错,我们再加一个拦截器,来实现每个请求都拦截

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
    ​
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    ​
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            // 登录拦截器
            registry.addInterceptor(new LoginInterceptor())
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login"
                    ).order(1);
            // token刷新的拦截器
            registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
        }
    }
    

    我们通过设置order值来决定拦截器的执行拦截的先后,order值越低,优先级越高。

    我们通过刚刚设置好的拦截器,查redis,存user对象到当前线程,然后进入下一个loginInterceptor,这个拦截器用来放行和对于一些状态码的设置

    public class LoginInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    ​
            UserDTO userDTO = UserHolder.getUser();
            if(userDTO == null){
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                return false;
            }
            return true;
        }
    }