短信登录

空~2022年10月25日
  • Redis
大约 7 分钟

短信登录

基本流程

发送验证码:

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

短信验证码登录、注册:

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

校验登录状态:

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

image-20221025152917501

session 共享问题:

每个 tomcat 中都有一份属于自己的 session,假设用户第一次访问第一台 tomcat,并且 把自己的信息存放到第一台服务器的 session 中,但是第二次这个用户访问到了第二台 tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的 session,所以此时整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?

早期的方案是 session 拷贝,就是说虽然每个 tomcat 上都有不同的 session,但是每当任意一台服务器的 session 修改时,都会同步给其他的 Tomcat 服务器的 session,这样的话,就可以实现 session 的共享了

但是这种方案具有两个大问题

  1. 每台服务器中都有完整的一份 session 数据,服务器压力过大。

  2. session 拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于 redis 来完成,我们把 session 换成 redis,redis 数据本身就是共享的,就可以避免 session 共享的问题了

image-20221025200916265

Redis 代替 session

设计 key 的结构

由于存入的数据比较简单,我们可以考虑使用 String,或者是使用哈希

image-20221025201352710

设计 key 的具体细节

session 他是每个用户都有自己的 session,但是 redis 的 key 是共享的, 咱们就不能使用 code 了在设计这个 key 的时候,需要满足两点:

  1. key 要具有唯一性
  2. key 要方便携带

如果我们采用 phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数 据存储到 redis 中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串 token,然后让前端带来这个 token 就能完成我们的整体逻辑了

整体访问流程

当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致, 则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到 redis,并且生成 token 作为 redis 的 key,当我们校验用户是否登录时,会去携带着 token 进行访问,从 redis 中取出 token 对应的 value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到 threadLocal 中,并且放行。

image-20221025201650113

基于 Redis 实现短信登录

生成验证码

  1. 校验手机号是否正确
    1. 错误则返回错误信息
  2. 生成验证码
  3. 将验证码保存到 redis 中, key 的设计 login:code: + phone,并设置过期 时间为 2 分钟
  4. 发送验证码
@Override
public Result senCode(String phone, HttpSession session) {
    // 1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2. 如果不合法, 返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3. 符合, 生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4. 保存验证码到redis 过期时间 2 分钟
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 5. 发送验证码
    log.debug("验证码: {}", code);
    // 返回 ok
    return Result.ok();
}

登录

  1. 校验手机号
  2. 校验验证码, 从 redis 中取出验证码
  3. 根据手机号查询用户
  4. 若不存在,插入新用户
  5. 将 用户信息 保存到 redis 中,key login:user: + token,设置过期过期时 间为 30 分钟
  6. 返回 token
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();
    // 1. 校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2. 如果不合法, 返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3. 校验验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    // 4. 不一致, 返回错误信息
    if (cacheCode == null || !cacheCode.equals(code)) {
        return Result.fail("验证码错误!");
    }
    // 5. 一致, 查询用户是否存在
    User user = query().eq("phone", phone).one();
    // 6. 不存在, 创建新用户并保存
    if (user == null) {
        user = createUserWithPhone(phone);
    }
    // 将用户信息保存到 redis 中,设置过期过期时间为 30 分钟
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    String token = UUID.randomUUID().toString(true);
    Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
            // 将 值 转为 String 类型
            .setFieldValueEditor((field, value) -> value.toString()));
    stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, map);
    stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY +
            token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 返回token
    return Result.ok(token);
}

拦截

  1. 从 request 中 获取 token
  2. 从 redis 中获取用户信息
  3. 判断用户是否存在
    1. 不存在则拦截
  4. 把用户信息放在 ThreadLocal(线程隔离) 中,放行
  5. 刷新用户信息过期时间
public class LoginInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 因为这个类没有交给 Spring 管理 * @param redisTemplate
     */
    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从 request 头中获取token
        String token = request.getHeader("authorization");
        // 1.从 redis 中获取用户信息
        Map<Object, Object> entries =
                stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        // 2. 判断用户是否存在
        if (entries.isEmpty()) {
            // 不存在, 拦截, 返回 401
            response.setStatus(401);
            return false;
        }
        // 3.把用户信息放在 ThreadLocal 中,放行
        UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        // 4.刷新用户信息过期时间
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY +
                token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户,避免信息泄露
        UserHolder.removeUser();
    }
}

解决状态登录刷新问题

假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,也就不会刷新用户信息过期时间。

我们可以再添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了 threadLocal 的数据,所以此时第二个拦截器只需要判断拦截器中的 user 对象是否存在即可,完成整体刷新功能。

刷新 token 的拦截器:RefreshTokenInterceptor

public class RefreshTokenInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
// 2.基于TOKEN获取redis中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap =
                stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        } // 5.将查询到的hash数据转为UserDT
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new
                UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL,
                TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

登录拦截:LoginInterceptor

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

可以在配置类中添加一些配置, 例如执行顺序和和拦截全部路径

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
            "/shop/**", "/shop-type/**", "/voucher/**", "/upload?**", "/blog/hot", "/user/code", "/user/login"
    ).order(1);
    registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}