(redis实现短信登录)

最近在学习使用redis,实现一个简单的短信登录功能(没使用第三方api发送短信),使用的是黑马点评项目image.png<a name="pf8N1"></a>先用session实现,再用redis代替session

一、基于session实现短信登录的流程

image.png

<a name="N2YCq"></a>

发送短信验证码

根据上边的流程图写

@PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        //发送短信验证码并保存验证码
        return userService.sendCode(phone,session);
    }
/**
     * 发送验证码
     *
     * @param phone   手机号码
     * @param session session
     * @return result
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            Result.fail("手机号格式错误!");
        }
        //生成验证码,使用hutool包的内容
        String code = RandomUtil.randomNumbers(6);
        //保存验证码到session
        session.setAttribute("code", code);
        //发送验证码
        //因为需要使用第三方服务,暂时先不写
        log.debug("验证码:" + code);
        return Result.ok();
    }

<a name="pEkwc"></a>

短信验证码登录,注册

根据上边的流程图写

@PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // 实现登录功能
        return userService.login(loginForm,session);
    }
/**
     * 登录
     *
     * @param loginForm 登录表单
     * @param session   session
     * @return result
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            Result.fail("手机号格式错误!");
        }
        //2.校验验证码
        //session中取出的验证码
        Object cacheCode = session.getAttribute("code");
        //用户提交的验证码
        String code = loginForm.getCode();
        if (code != cacheCode){
            Result.fail("验证码错误");
        }
        //3.根据手机号查询用户
        User user = query().eq("phone", phone).one();
        //4.不存在,创建新用户
        if (user == null) {
            user = creatUserWithPhone(phone);
        }
        //5. 保存用户到session
        session.setAttribute("user", user);
        return Result.ok();
    }

<a name="xjTDb"></a>

登录校验

因为不同的controller都需要校验登录状态,所以把登录校验直接写进拦截器,再把用户信息放进ThreadLocal中,这样都能拿到用户信息<br />image.png<br />ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。 <a name="IxFjI"></a>

写拦截器

public class LoginInterceptor implements HandlerInterceptor {

    //前置拦截,登录校验
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.从session获取用户
        HttpSession session = request.getSession();
        Object user = session.getAttribute("user");
        //2. 判断用户是否存在
        if (user == null){
            //3. 没有就拦截
            response.setStatus(401);
            return false;
        }

        //4. 如果有,保存用户到ThreadLocal
        UserHolder.saveUser((User) user);
        return true;
    }

    //渲染之后,返回给用户之前,销毁对应的用户信息,避免内存泄漏
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //销毁
        UserHolder.removeUser();
    }
}

<a name="XsxfS"></a>

配置拦截器

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    //添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                //这些路径不用拦截
                .excludePathPatterns(
                        "/voucher/**",
                        "/upload/**",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/user/login",
                        "/user/code"
                );
    }
}

<a name="ySv65"></a>

获取用户信息返回给前端

/**
     * 登录校验
     *
     * @return
     */
    @GetMapping("/me")
    public Result me() {
        //  获取当前登录的用户并返回
        User user = UserHolder.getUser();
        return Result.ok(user);
    }

<a name="cYabB"></a>

隐藏用户敏感信息

这是上边返回的用户信息,有用户信息泄露的风险<br />image.png<br />前边我们在登录的时候直接将完整的用户信息存到了session中,为了减少内存资源的占用以及降低用户信息泄露的风险,应对代码做出如下调整:<br />UserServiceImpl中<br />image.png

@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            Result.fail("手机号格式错误!");
        }
        //2.校验验证码
        //session中取出的验证码
        Object cacheCode = session.getAttribute("code");
        //用户提交的验证码
        String code = loginForm.getCode();
        if (code != cacheCode){
            Result.fail("验证码错误");
        }
        //3.根据手机号查询用户
        User user = query().eq("phone", phone).one();
        //4.不存在,创建新用户
        if (user == null) {
            user = creatUserWithPhone(phone);
        }
        //5. 保存用户到session
        //使用hutool包中的BeanUtil.copyProperties()方法将用户的属性复制到userdto中
        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
        return Result.ok();
    }

UserHolder中<br />image.png

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();
    }
}

修改后,用户信息相对安全:<br />image.png <a name="esA7V"></a>

集群的session共享问题

session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不tomcat服务时导致数据丢失的问题,所以接下来使用redis <a name="wCnra"></a>

二、Redis代替session实现短信登录的流程

image.png<br />image.png <a name="LqyMd"></a>

发送短信验证码

@Override
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            Result.fail("手机号格式错误!");
        }
        //生成验证码,使用hutool包的内容
        String code = RandomUtil.randomNumbers(6);
        //保存验证码到redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        //发送验证码
        //因为需要使用第三方服务,暂时先不写
        log.debug("验证码:" + code);
        return Result.ok();
    }

<a name="kjDLB"></a>

短信验证码登录,注册

@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            Result.fail("手机号格式错误!");
        }

        //2.校验验证码
        //redis中取出的验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        //用户提交的验证码
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.toString().equals(code)) {
            Result.fail("验证码错误");
        }

        //3.根据手机号查询用户
        User user = query().eq("phone", phone).one();
        //4.不存在,创建新用户
        if (user == null) {
            user = creatUserWithPhone(phone);
        }


        //5. 保存用户到redis
        //5.1使用hutool包中的BeanUtil.copyProperties()方法将用户的属性复制到userdto中
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        //5.2将userDTO对象转为map
        //把userDTO对象中的所有属性转为string再储存
        Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true).
                        setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
        //5.3生成随机token
        String token = UUID.randomUUID().toString(true);
        String tokenKey=LOGIN_USER_KEY+token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, map);
        //5.4设置token有效期
        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
        return Result.ok(token);
    }

<a name="wy3yG"></a>

登录校验

private StringRedisTemplate stringRedisTemplate;

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

//在MvcConfig中加入注释的内容
// @Autowired
//     private 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)) {
        //3. 没有就拦截
        response.setStatus(401);
        return false;
    }

    //4.用token在redis里查询用户信息
    String tokenKey= RedisConstants.LOGIN_USER_KEY + token;
    Map<Object, Object> entries =
    stringRedisTemplate.opsForHash().entries(tokenKey);
    //查询用户是否存在
    if (entries.isEmpty()) {
        //3. 没有就拦截
        response.setStatus(401);
        return false;
    }
    //5.map转为userDto
    UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);
    //4. 保存用户到ThreadLocal
    UserHolder.saveUser(userDTO);

    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
    return true;
}

<a name="eUkWL"></a>

登录拦截器的优化

需要加一个对一切路径都拦截的拦截器<br />image.png<br />新增一个拦截器:

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.从session获取用户
//        HttpSession session = request.getSession();
//        Object user = session.getAttribute("user");
//        //2. 判断用户是否存在
//        if (user == null){
//            //3. 没有就拦截
//            response.setStatus(401);
//            return false;
//        }
//
//        //4. 如果有,保存用户到ThreadLocal
//        UserHolder.saveUser((UserDTO) user);
//        return true;
//    }
    @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 false;
        }

        //4.用token在redis里查询用户信息
        String tokenKey= RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> entries =
                stringRedisTemplate.opsForHash().entries(tokenKey);
        //查询用户是否存在
        if (entries.isEmpty()) {
            return false;
        }
        //5.map转为userDto
        UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);
        //4. 保存用户到ThreadLocal
        UserHolder.saveUser(userDTO);

        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
        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 {
        if(UserHolder.getUser() ==null){
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

配置拦截器:

@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                //这些路径不用拦截
                .excludePathPatterns(
                        "/voucher/**",
                        "/upload/**",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/user/login",
                        "/user/code"
                ).order(1);
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/voucher/**",
                        "/upload/**",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/user/login",
                        "/user/code"
                ).order(0);

    }
}

注:使用的一些常量

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;

    public static final Long CACHE_NULL_TTL = 2L;

    public static final Long CACHE_SHOP_TTL = 30L;
    public static final String CACHE_SHOP_KEY = "cache:shop:";
    public static final String CACHE_SHOP_TYPE = "shop:type";
    public static final String LOCK_SHOP_KEY = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 10L;

    public static final String SECKILL_STOCK_KEY = "seckill:stock:";
    public static final String BLOG_LIKED_KEY = "blog:liked:";
    public static final String FEED_KEY = "feed:";
    public static final String SHOP_GEO_KEY = "shop:geo:";
    public static final String USER_SIGN_KEY = "sign:";
}