基于Redis的短信登录设计

593 阅读5分钟

登录

1. 发送短信验证码

  1. 获取到前端发送过来的手机号

  2. 判断手机号是否正确

    • 错误,返回错误信息
    • 正确,进行第三步
  3. 生成验证码

  4. 将验证码保存到Redis中,并且设置验证码储存时长

  5. 发送验证码

  6. 返回信息给前端

flowchart TD
A([开始]) --> B(获取手机号) --> C{验证手机号} --> |不符合|B; C --> |符合|D(生成验证码) --> E(储存验证码) --> F(发送验证码) --> G(返回信息) --> H([结束])
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(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 5. 发送验证码
    log.debug("验证码发送成功,验证码: {}", code);
    // 返回
    return Result.ok();
}

这里我是用存储验证码的键为login:code: + 手机号组成,设置的时间为2分钟

2. 登录,注册

  1. 从前端获取到手机号和验证码

  2. 校验手机号是否正确

    • 错误,返回错误信息
    • 正确,进行第三步
  3. 校验验证码

    1. 从Redis获取验证码信息
    2. 将获取的验证码和Redis中的验证码进行比对
      • 错误,返回错误信息
      • 正确,进行第四步
  4. 根据手机号查询用户,并判断用户是否存在

    • 不存在,进行注册,并将查询到的用户信息进行更新
  5. 将用户信息储存到Redis中

    1. 生成随机的token,作为登录令牌,前端每次请求的时候都将携带这个token
    2. 将用户对象转化成Hash数据
    3. 将生成的token作为键,Hash数据作为值存储在Redis中
  6. 将token返回给前端

public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();
    // 1.校验手机号
    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)) {
        // 3. 不一致,返回错误信息
        return Result.fail("验证码错误");
    }
    // 4. 一致,根据手机号查询用户
    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对象转为hash存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // setIgnoreNullValue:忽略一些空的值
    // setFieldValueEditor:修改一些字段值
    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;  // "login:token:" + token
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 设置时效为30分钟
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 8. 返回token
    return Result.ok(token);
}

private User createUserWithPhone(String phone) {
    // 1. 创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2. 保存用户信息
    save(user);
    return user;
}

注意:

  1. 这里用手机号查询出来的User对象是获取了User的所有字段,而有一些比较隐秘字段不需要展示,例如密码,所以我们需要将User对象进行处理,只保留一些需要返回的信息,所以这里我建了一个UserDTO类,只将id,昵称和头像返回

  2. 使用stringRedisTemplate.opsForHash().putAll()时,这里传的值为一个map,所以这里需要将UserDTO对象转化为一个map,但是这里的id是一个long类型,stringRedisTemplate保存的String类型,所以这里的map需要进一步处理,将所有字段都转成String类型

3. 校验登录状态

  1. 获取请求头中的token

    • token不存在,直接返回
    • 存在,进行下一步
  2. 根据token从Redis中获取用户信息

    • 用户信息不存在,直接返回
    • 存在,下一步
  3. 将查询到的Hash数据转化成UserDTO对象

  4. 将用户信息保存到ThreadLocal中

  5. 刷新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 {
        // 1. 获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlankIfStr(token)) {
            return true;
        }
        // 2. 基于token获取Redis中的用户
        String key = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3. 判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5. 将查询到的hash数据转成UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6. 存在,将用户信息保存到ThreadLocal中
        UserHolder.saveUser(userDTO);
        // 7. 刷新token的有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8. 放行
        return true;
    }

    @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 {
        // 判断是否需要拦截
        if (UserHolder.getUser() == null) {
            // 没有,拦截
            response.setStatus(401);
            return false;
        }
        // 有用户,放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, 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 LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                ).order(1);  // order设置优先级
        // 刷新拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

注意:

  1. 这里为什么需要设置两个拦截器呢?是因为我们设置了token的时效刷新在拦截器中,而在登录拦截器中有些请求不需要拦截,例如主页,当我们一直停留在主页是,这时我们的请求就不会经过拦截器,而token当然也不会刷新,所以这时我们就需要设置一个不管我们访问什么页面,都会将token时效进行刷新的拦截器,这就是刷新拦截器,他会将携带了用户信息的token的请求进行刷新时效,而其他的直接放行到登录拦截器进行拦截

  2. 为什么需要用ThreadLocal来储存用户信息?ThreadLocal可以将用户信息保存到线程中,当请求结束时,我们再将保存的信息给清除。使用ThreadLocal可以再同一线程中很方便的获取用户信息,不需要频繁的从Redis中获取。

4. 退出登录

  1. 从请求头中获取token

  2. 判断token是否存在

    • 不存在,返回提示
    • 存在,下一步
  3. 从Redis中删除token信息

  4. 返回结果

public Result logout(HttpServletRequest request) {
    // 1. 获取请求头中的token
    String token = request.getHeader("authorization");
    // 判断是否存在token
    if (StrUtil.isBlankIfStr(token)) {
        // 不存在,返回提示
        return Result.ok("当前未登录");
    }
    // 存在,删除Redis存储的token
    stringRedisTemplate.delete(LOGIN_USER_KEY + token);
    return Result.ok("退出成功");
}