1、 基于session实现登录

发送验证码:

  • 用户提交手机号;
  • 校验手机号是否合法:
  • 如果不合法,则要求用户重新输入手机号;
  • 如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

  • 用户将验证码和手机号进行输入;
  • 后台从session中拿到当前验证码,然后和用户输入的验证码进行校验:
  • 如果不一致,则无法通过校验;
  • 如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库;无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

校验登录状态:

  • 用户在请求时候,会从cookie中携带者sessionId到后台;
  • 后台通过sessionId从session中拿到用户信息:
  • 如果没有session信息,则进行拦截,
  • 如果有session信息,则将用户信息保存到threadLocal中,并且放行

2、session共享的问题分析

每个 tomcat 都有自己的 session,假如用户访问第一台 tomcat ,把自己的信息存放在第一台 tomcat 的 session 中,如果用户下一次访问第二台 tomcat ,第二台 tomcat 的 session 是没有用户信息的,那么登录拦截功能会出问题。

解决方案: 同步所有服务器上的 session 会使得服务器压力过大,并且会有延迟。所以,把 session 换成 redis,redis数据本身就是共享的,就可以避免 session 共享的问题了

3、 Redis代替session的业务流程

  • 问题一:将验证码存储到session改成存储到redis中,redis采用哪种数据结构存储?采用String类型即可;而key不能为SESSIONID,因为SESSIONID是每一台Tomcat服务器独有,当请求切换到不同的Tomcat服务器时,SESSIONID就会改变,也就说明访问redis数据库时不能取出相同的验证码。因此,用手机号Phone作为key;
  • 问题二:将用户信息存储到session改成存储到redis中,redis采用哪种数据结构?采用Hash结构,因为Hash结构可以将对象中的每个字段独立存储,也就是说可以针对单个字段做CRUD,比较灵活,并且内存占用更少(如果用String类型,value是可以采用JSON格式保存,比较直观,但是对象数据一旦很长,就会有产生很多符号,给内存造成额外的消耗);
  • 问题三:存放对象的key应该设置为什么以保证唯一性?不建议采用手机号,建议采用随机token作为key存储用户数据。

4、首先是发送短信验证码,步骤如下:

  • 工具类随机生成6位数的验证码;
  • 放入 redis ,key 为常量前缀+电话号码value 为验证码
  • 后续登录时,将根据 redis 里的验证码判断输入的验证码是否正确;

Service 层,

@Override
public Result sendCode(String phone, HttpSession session) {
   // 1.校验手机号
   if (RegexUtils.isPhoneInvalid(phone)){
       // 2.手机格式不正确
       return Result.fail("手机格式不正确");
   }

   // 3.生成验证码
   String code = RandomUtil.randomNumbers(6);

   // 4.保存验证码到 redis
   stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,
                                           code,
                                           RedisConstants.LOGIN_CODE_TTL,
                                           TimeUnit.MINUTES);

   // 5.模拟发送验证码
   log.debug("发送短信验证码成功,验证码:{}", code);

   return Result.ok();
}

5、登录验证功能:

  • 随机生成 UUID 作为 token;
  • 用户信息转换为 map 放入 redis,key 为常量前缀+ tokenvalue 为转换的用户 map
  • 登录成功,将 token 返回给前端;
@Override
 public Result loginToIndex(LoginFormDTO loginForm, HttpSession session) {
     // 1.校验手机号
     String phone = loginForm.getPhone();
     if (RegexUtils.isPhoneInvalid(phone)){
         // 2.手机格式不正确
         return Result.fail("手机格式不正确!");
     }

     // 2.校验验证码
     String code = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+loginForm.getPhone());
     if (code == null || !code.equals(loginForm.getCode())){
         return Result.fail("验证码出错!");
     }

     // 3.查看用户是否存在
     User user = query().eq("phone", phone).one();

     // 用户不存在则创建用户
     if (user == null){
         user = createUserWithPhone(phone);
     }

     // 4.生成 token
     String token = UUID.randomUUID().toString(true);

     // 4.保存到用户到 redis
     // 将 UserDTO 对象转为 HashMap 存储
     UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
     Map<String, Object> targerMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                 CopyOptions.create()
                 . setIgnoreNullValue(true)
                 .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
     // 存储
     String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
     stringRedisTemplate.opsForHash().putAll(tokenKey, targerMap);
     // 设置 token 有效期
     stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

     return Result.ok(token);
 }

6、登录拦截器

在拦截器中刷新了token的有效期,但是否访问每个页面,也就是每个请求都能刷新token有效期呢,答案是否定的。因此不是每个请求都在拦截器拦截的路径范围中!

解决办法:在已有的拦截器情况下,再添加一个拦截器,拦截路径为全部。

分两种访问内容,一种是登录之后才可以访问;另一种是无需登录即可访问。

所以,可以设置两个拦截器,第一个拦截器只拦截无需登录的内容,

第一个和第二个拦截器拦截访问登录之后才能访问的内容。

第一个拦截器:

  • 从请求头获取 token,无 token 则直接放行;
  • 有 token,以此获取 redis 里的用户信息,若无,则说明 token 过期,放行;
  • 有用户信息,则放入 ThreadLocal ,更新 token 过期时间,放行

第二个拦截器:

  • 判断 ThreadLocal 是否存在用户信息
  • 有则已登录,token未过期,放行;
  • 无则未登录或是 token 过期,返回错误信息
public class RefreshInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取 token
        String token = request.getHeader("authorization");

        // 2.判断有无 token
        if (StrUtil.isBlank(token)){
            return true;
        }

        // 3.保存 UserDto 到 ThreadLocal
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> targetMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        if (targetMap == null || targetMap.isEmpty()){
            return true;
        }
        // 转换
        UserDTO userDTO = BeanUtil.fillBeanWithMap(targetMap, new UserDTO(), false);
        // 保存
        UserHolder.saveUser(userDTO);
        // 刷新有效期
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = UserHolder.getUser();
        if (user == null){
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

拦截器在被添加到mvcconfig后,会被注册为一个InterceptorRegisteration,它有一个默认属性order为0,在不设置order情况下,多个拦截器执行的顺序就是拦截器添加先后的顺序。为了严谨,可以设置order属性。order值越大,执行优先级越低。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/login",
                        "/user/code",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                ).order(1);
        // token 刷新拦截器
        registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);
    }
}