【Redis学习02】基于session和基于redis实现登录功能

前言

本专栏基于redis的学习将会通过实现一个类似大众点评项目进行redis相关知识点的学习。该项目将会通过短信登录,商户缓存,优惠券秒杀,好友关注等多个模块开发来熟悉,使用以及掌握redis,话不多说,开始第一个模块,短信登录功能。

1. 基于session实现短信登录功能

如下图,该界面就是我们的短信登录界面,首先,我们填写正确的手机号格式,点击发送验证码,客户端接收验证码后,填写验证码进行登录。登录成功后跳转到我们的首页。
在这里插入图片描述

接下来我们梳理一下基于session实现短信登录的流程图,分别有发送验证码,短信验证码登录注册以及检验登录状态。
在这里插入图片描述

1.1 发送短信验证码

发送短信验证码还是比较简单的,我们获取验证码后将验证码存在session中,在登录时取出来进行比较。由于我们个体如果真的要实现短信验证码发送功能,需要开通阿里云或者腾讯云短信服务,我们就不开通,获取的验证码在控制台打印,输入的时候瞄一眼即可。

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private IUserService userService;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 发送验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1. 校验手机号
        boolean flag = RegexUtils.isPhoneInvalid(phone);
        if(flag){
            //2. 手机号有误直接返回
           return Result.fail("手机号有误");
        }
        //3. 生成验证码
        String code = RandomUtil.randomNumbers(4);

        //4. 将验证码保存到session
        session.setAttribute("code",code);

        //5. 模拟发送验证码
        log.info("验证码为:{}",code);

        return Result.ok();
    	}
    }

这里有通用的类需要声明一下:

  1. 结果集类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}
  1. UserDto类
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

1.2 短信验证登录

/**
     * 用户登录
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1. 校验手机号
        String phone = loginForm.getPhone();
        boolean flag = RegexUtils.isPhoneInvalid(phone);
        if(flag){
            return Result.fail("手机号有误");
        }

        //2. 校验验证码
        String code = loginForm.getCode();
        String codeInSession = session.getAttribute("code").toString();

        //3. 不一致报错
        if(codeInSession==null||!code.equals(codeInSession)){
            return Result.fail("验证码错误");
        }

        //4. 一致,根据手机号查询用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone,phone);
        User user = userService.getOne(queryWrapper);

        //5. 判断用户是否存在
        if(user==null){
            //6. 不存在就创建新的用户
            user = new User();
            user.setPhone(phone);
            user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
            userService.save(user);
        }

        //7. 将用户信息存到session
        //dto对象只有用户基本信息,不会暴露其他信息,保证隐私安全
        session.setAttribute("userDto", BeanUtil.copyProperties(user, UserDTO.class));     
        return Result.ok();
    }

在这里插入图片描述

1.3 登录校验功能

登录校验功能就是为了检验用户是否登录,如果用户没有登录,在涉及需要用户信息的操作功能上就需要跳转到用户登录界面,如果用户登录,就可以对应用需要用户信息的功能进行操作。

如何对用户是否登录进行判断呢?

我们在用户登录的时候将用户信息存到session中,用户访问我们应用的时候就会携带sessonID,我们就从中获取session信息,如果获取到就证明用户已经登录,如果获取的session为空就表明没有登录。

在这里插入图片描述

判断用户是否登录后,我们如何对没有进行登录的用户请求进行拦截,对已经登录的用户进行放行呢?

很好,这个答案就是拦截器,也可以是过滤器。这里我们采用拦截器进行拦截。如下图所示:
在这里插入图片描述

定义一个拦截器需要实现HandlerInterceptor,并重载HandlerInterceptor的两个方法。

public class LoginInterceptor  implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取session
        HttpSession session = request.getSession();
        //2. 从session中获取用户信息
        UserDTO userDto =(UserDTO) session.getAttribute("userDto");
    
        if (Strings.isBlank(token)) {
            response.setStatus(401);
            return false;
        }
        //3. 检查用户是否存在
        if(userDto==null){
            //4. 不存在就拦截
            //401:未授权
            response.setStatus(401);
            return false;
        }

        //5. 存在就保存到threadLocal中,调用工具类UserHolder中的方法
        UserHolder.saveUser(userDto);

        //6. 存在,保存到threadLocal中
        UserHolder.saveUser(userDto);

        //7. 放行
        return true;

    }

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

    }
}

定义了拦截器我们就需要去配置一下我们的拦截器webMvcConfig。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/user/login",
                "/user/code",
                "/blog/hot",
                "/upload/**",
                "/shop-type/**",
                "/shop/**",
                "/voucher/**"
        );

    }
}

这样,我们设置好不需要拦截的路径,用户不登录便可以进行访问,而其他需要用户信息的请求用户不进行登录就无法访问。我们的拦截器就设置好了。

在上述实现中使用了一个ThreadLocal线程变量,ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。感兴趣可以单独去了解一下。

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

2. 集群的Session问题

session共享问题:多台tomcat服务器并不共享session的存储空间,当请求切换到不同的tomcat服务时导致数据丢失的问题。因此需要将session中的数据放到内存中存储。

在这里插入图片描述

session的替代方案应该满足以下几点

  • 数据共享
  • 内存存储
  • key,value结构

符合条件的就是我们今天要讲的redis

3. 基于redis实现共享session登录

基于redis实现发送短信验证码和短信验证码登录注册的流程图如下所示,我们这里简要介绍一下。

发送短信验证码:将生成的验证码以key,value的结构存储到redis中,这里使用手机号码作为key,使用验证码作为value。

短信验证码登录注册:将页面提交的手机号作为key从redis中取出相对应的code,校验与用户输入的验证码是否一致。校验通过之后将用户信息存储到redis中。这里需要注意的是,用户的信息不仅仅是一个字段,有多个字段以及对于的值,因此这里我们使用集合来存储用户信息
在这里插入图片描述
登录之后需要进行校验操作,具体流程如下所示:

校验登录状态:我们在存储用户信息时,会随机生成一个token,将这个token作为key,用户信息作为value存储到集合Map中。用户登录成功后将token返回给客户端。校验时,从请求头中获取携带的token,将其作为key从redis中获取用户信息。如果用户信息为空就拦截,不为空就调用ThreadLocal保存用户信息,然后放行。
在这里插入图片描述

3.1 发送短信验证码

@Override
    public Result sendCode(String phone, HttpSession session) {
        //1. 校验手机号
        boolean flag = RegexUtils.isPhoneInvalid(phone);
        if(flag){
            //2. 手机号有误直接返回
           return Result.fail("手机号有误");
        }
        //3. 生成验证码
        String code = RandomUtil.randomNumbers(4);

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

        //5. 模拟发送验证码
        log.info("验证码为:{}",code);

        return Result.ok();
    }

3.2 短信验证登录

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

        //2. 校验验证码
        String code = loginForm.getCode();   
        String codeInRedis = redisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone).toString();

        //3. 不一致报错
        if(codeInRedis==null||!code.equals(codeInRedis)){
            return Result.fail("验证码错误");
        }

        //4. 一致,根据手机号查询用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone,phone);
        User user = userService.getOne(queryWrapper);

        //5. 判断用户是否存在
        if(user==null){
            //6. 不存在就创建新的用户
            user = new User();
            user.setPhone(phone);
            user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
            userService.save(user);
        }

        //7. 将用户信息存到redis
        //7.1 随机生成一个token作为我们的key,即登录令牌
        String token = UUID.randomUUID().toString(true);
        token = LOGIN_USER_KEY+token;

        //7.2 将user对象转为hashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);

        //7.3 保存
        redisTemplate.opsForHash().putAll(token,userMap);

        //7.4 设置redis有效期
        redisTemplate.expire(token,LOGIN_USER_TTL,TimeUnit.MINUTES);
        //7.5 返回token
        return Result.ok(token);
    }

3.3 登录校验功能

这里需要注意的是:我们token设置的有效期是三十分钟,用户每次通过拦截器都会刷新token的有效期。目前这样设置会有点小问题,我们在拦截器优化会进行完善。

	 @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  
        //1. 从请求头中获取token
        String token = request.getHeader("authorization");
        if (Strings.isBlank(token)) {
            response.setStatus(401);
            return false;
        }
        //2. 根据token获取用户信息
        Map userMap = redisTemplate.opsForHash().entries(token);
        //3. 检查用户是否存在
        if(userMap.isEmpty()){
            //4. 不存在就拦截
            //401:未授权
            response.setStatus(401);
            return false;
        }

        //5. 将查询到的对象转为userDto对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6. 存在,保存到threadLocal中
        UserHolder.saveUser(userDTO);

        //7. 刷新token的有效期
        //每次用户访问通过这个拦截器,就刷新redis的有效时间
        redisTemplate.expire(token,LOGIN_USER_TTL, TimeUnit.MINUTES);

        //8. 放行
        return true;
    }

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

4. 拦截器优化

4.1 分析原因

我们思考一下,我们目前设置的token的有效期是三十分钟,只有用户请求那些需要拦截器操作的请求才会刷新token,但是,我们想一想,用户会不会在不需要拦截器的功能下逗留超过三十分钟呢?

显然,这是可能的。比如我们逛淘宝,我们是不是不需要登录就能去搜索商品,那假如我们登录后浏览了半个小时商品后,突然想加入购物车,这时候发现我们token过期了,用户登录信息失效了,我们是不是很崩溃。

因此,我们刷新token的条件不能是仅仅根据需要拦截的路径,而是判断用户是否在我们的应用程序当中,如果在,用户提交一次请求我们就需要刷新token时间,也就是对所有请求都进行拦截,然后根据情况进行放行操作。

如何实现呢?那就是再添加一个拦截器拦截所有请求。

原始拦截器图示
在这里插入图片描述
优化拦截器
在这里插入图片描述

4.2 代码实现

新增拦截器RefreshTokenInterceptor

/**
 * 这个拦截器拦截所有的用户请求,但都会放行
 * 设置这个拦截器就是避免用户长时间不访问那些需要拦截的请求,导致redis中的数据过期。
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    //因为这个拦截器不是spring管理的bean,因此我们需要手动去注入这个bean,采用构造方法注入
    //谁用我们这个拦截器谁就帮我们注入这个对象---webmvcConfig类中使用了
    //因此,webmvcConfig  new一个拦截器就必须带参数
    private RedisTemplate redisTemplate;

    public RefreshTokenInterceptor(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //1. 从请求头中获取token
        String token = request.getHeader("authorization");
        if (Strings.isBlank(token)) {
            return true;
        }
        //2. 根据token获取用户信息
        Map userMap = redisTemplate.opsForHash().entries(token);
        //3. 检查用户是否存在
        if(userMap.isEmpty()){
            return true;
        }

        //5. 将查询到的对象转为userDto对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6. 存在,保存到threadLocal中
        UserHolder.saveUser(userDTO);

        //7. 刷新token的有效期
        //每次用户访问通过这个拦截器,就刷新redis的有效时间
        redisTemplate.expire(token,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 {
     
        //1. 从threadLocal中获取用户,存在就放行,不存在就拦截
        UserDTO user = UserHolder.getUser();
        if(user==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 {

    @Autowired
    private RedisTemplate redisTemplate;

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

        //token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(redisTemplate)).addPathPatterns("/**").order(0);
    }
}

4.3 拦截器优化总结

  1. RefreshTokenInterceptor这个拦截器不是spring管理的bean,因此我们需要手动去注入这个bean,采用构造方法注入

    而谁用我们这个拦截器谁就帮我们注入这个对象—webmvcConfig类中使用了,因此,webmvcConfig new一个拦截器就必须带参数
    在这里插入图片描述

  2. 配置两个拦截器就会存在先后问题,因此,我们在拦截器后添加order属性,以此将拦截所有路径的拦截器放在前面。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值