基于redis实现登录校验

25 阅读5分钟

实现流程

  1. 发送短信验证码,并将验证码保存在redis。

  2. 根据用户登录数据完成登录。

    1. 如果是新用户还要创建用户。
    2. 如果查询到则转化为DTO保存。
      将用户数据被保存到redis,并将token保存到redis,同时返回给前端。
  3. 创建登录校验拦截器。
    注册登录校验拦截器。

发送验证码接口

请求参数:手机号phone
无结果对象

service层实现

@Override  
public Result sendCode(String phone) {  
    //1.校验手机号  
    if (RegexUtils.isPhoneInvalid(phone)) {  
        return Result.fail("手机号格式错误!");  
    }  
    //2.生成验证码  
    String code = RandomUtil.randomNumbers(6);  
    //3.将验证码保存在redis  
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);  
    //4.模拟发送验证码  
    log.debug("发送短信验证码成功,验证码:{}", code);  
    return Result.ok();  
}

接口介绍

  1. 根据正则表达式检验手机号格式。
  2. 通过hutool生成6位随机数作为验证码。
  3. 将验证码保存在redis中。
  4. 将验证码发送给客户端(此处应使用相关云服务完成,笔者使用日志只为记录数据)。

登录接口

请求参数:已经封装好的LoginFormDTO对象
结果对象:token

service层实现

@Override  
public Result login(LoginFormDTO loginForm) {  
    //1.校验手机号格式  
    String phone = loginForm.getPhone();  
    if (RegexUtils.isPhoneInvalid(phone)) {  
        return Result.fail("手机号格式错误!");  
    }  
    //2.校验验证码  
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);  
    String code = loginForm.getCode();  
    if (cacheCode == null || !cacheCode.equals(code)) {  
        return Result.fail("验证码错误!");  
    }  
    //3.根据手机号查询用户  
    User user = query().eq("phone", phone).one();  
    //4.判断用户是否存在  
    if (user == null) {  
        //5.没有用户则创建新用户  
        user = createUserWithPhone(phone);  
    }  
    //6.保存用户信息到redis,(以哈希储存)  
        //1.通过UUID拼装tokenKey  
    String token = UUID.randomUUID().toString(true);  
    String tokenKey = LOGIN_USER_KEY + token;  
        //2.将UserDTO对象转为HashMap存储, 由于使用的是stringRedisTemplate,要保证所有的值都是字符串类型,要把lang转换为string  
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);  
  
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),  
            CopyOptions  
                    .create()//创建默认数据拷贝选项  
                    .setIgnoreNullValue(true) //忽略null值  
                    /*字段值修改器,接收实现修改的方法*/  
                    .setFieldValueEditor(  
                            (fieldName, fieldValue) -> fieldValue.toString()  
                    )  
            );  
  
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);  
        //3.设置token的有效期  
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);  
    //7.返回token  
    return Result.ok(tokenKey);  
}

接口介绍

  1. 校验手机号格式。
  2. 校验验证码。
  3. 根据手机号查询用户信息(使用MybatisPlus)。
  4. 通过UUID生成随机token,所为key,将查询后得到的UserDTO(将User转换为UserDTO,避免暴漏敏感数据),封装为hash(使用hutool相关API),存入redis,并设置有效期。
  5. 返回token。

手动创建拦截器

在实现了HandlerInterceptor的类中重写preHandle和afterCompletion来实现对每一次请求的处理。

public class LoginInInterceptor implements HandlerInterceptor {  
  
    private StringRedisTemplate stringRedisTemplate;  
  
    public LoginInInterceptor(StringRedisTemplate stringRedisTemplate) {  
        this.stringRedisTemplate = stringRedisTemplate;  
    }  
      
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
  
        //获取请求头中的token  
        String token = request.getHeader("authorization");  
        if (token == null) {  
            response.setStatus(401);  
            return false;  
        }  
        //根据token从redis中获取用户信息  
        String key = LOGIN_USER_KEY + token;  
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);  
        if (userMap.isEmpty()) {  
            response.setStatus(401);  
            return false;  
        }  
  
        //将map转换为UserDTO对象  
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);  
  
        //保存用户信息到ThreadLocal  
        UserHolder.saveUser(userDTO);  
        //刷新token有效期  
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);  
        return true;  
    }  
      
    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {  
        UserHolder.removeUser();  
    }  
}

用前注意:
该自定义拦截器未交给Spring容器管理,不能使用@Autowired或者@Resource自动注入依赖,此处使用构造器注入stringRedisTemplate。

执行逻辑:
preHandle执行拦截校验

  1. getHeader获取请求头中的token,并进行非空判断。
  2. 根据token从redis中获取userMap,再次使用hutool包的API将map集合转换为实体类。
  3. 使用工具类将userDTO保存到ThreadLocal,实现跨层级数据共享。
  4. 刷新token有效期,提升用户体验。
    afterCompletion执行清理ThreadLocal
    请求完成后清理ThreadLocal中的数据,防止内存泄漏和数据污染。

注册拦截器

在含有@Configuration 且实现了WebMvcConfigurer 的配置类中添加自定义拦截器

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

执行逻辑:

  1. 使用@Resource注解注入stringRedisTemplate
  2. 重写addInterceptors方法
  3. 调用registry的addInterceptor方法添加拦截器(形参中new出LoginInInterceptor,并将正确注入的stringRedisTemplate传递给LoginInInterceptor,完成自定义拦截器中的依赖注入)。
  4. 调用excludePathPatterns指明不拦截的请求

拦截器的优化

由于将校验登录状态和刷新token有效期合并在了一个拦截器中,所以在用户访问一些不拦截登录的接口时无法刷新token有效期,为此,我们将它拆分为两个拦截器

刷新拦截器:

只为刷新token有效期

  • 如果无token直接放行让用户进行登录
  • 如果有token则将用户保存在ThreadLoacl(便于拦截登录)并刷新token有效期
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 {  
  
        //获取请求头中的token  
        String token = request.getHeader("authorization");  
        if (token == null) {  
            return true;  
        }  
        //根据token从redis中获取用户信息  
        String key = token;  
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);  
        if (userMap.isEmpty()) {  
            return true;  
        }  
  
        //将map转换为UserDTO对象  
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);  
  
        //保存用户信息到ThreadLocal  
        UserHolder.saveUser(userDTO);  
        //刷新token有效期  
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);  
        return true;  
    }  
  
    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {  
        UserHolder.removeUser();  
    }  
}

登录拦截器:

只为拦截未登录用户

public class LoginInInterceptor implements HandlerInterceptor {  
  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
        //判断是否需要拦截(ThreadLocal中是否有用户)  
        if (UserHolder.getUser() == null) {  
            //无用户,进行拦截  
            response.setStatus(401);  
            return false;  
        }  
        //有用户,放行  
        return true;  
    }  
  
    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {  
        UserHolder.removeUser();  
    }  
  
}

配置拦截顺序

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

order值低的优先进行拦截