【Redis】Redis实战:黑马点评之短信登录

Redis实战:黑马点评之短信登录

1 黑马点评项目导入

黑马点评所需的资料文件上传到了链接:https://pan.baidu.com/s/1L2eISvE2tztmGvUBx0wNYQ
提取码:1234

1.1 导入sql文件

首先将资料中提供的sql文件导入到数据库:

【Redis】Redis实战:黑马点评之短信登录_第1张图片

【Redis】Redis实战:黑马点评之短信登录_第2张图片

1.2 导入后端项目

在资料中提供了一个项目源码:

【Redis】Redis实战:黑马点评之短信登录_第3张图片

1.3 导入前端工程

【Redis】Redis实战:黑马点评之短信登录_第4张图片

1.3 运行前端项目

【Redis】Redis实战:黑马点评之短信登录_第5张图片


2 基于sesssion实现用户登录

2.1 Session实现登录流程梳理

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

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

校验登录状态:

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

【Redis】Redis实战:黑马点评之短信登录_第6张图片

2.2 发送短信验证码

页面流程梳理如下:

【Redis】Redis实战:黑马点评之短信登录_第7张图片

具体代码如下:

UserController

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone,session);
    }

UserServiceImpl

    /**
     * 获取验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号是否正确,手机号格式前后端都需要进行校验
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式不正确");
        }

        //生成验证码,这里使用的是hutool工具类的随机数生成方法,参数表示生成6位数验证码
        String code = RandomUtil.randomNumbers(6);

        //将验证码存放在session中
        session.setAttribute("code",code);

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

        return Result.ok();
    }

2.3 完成登录功能

UserController

    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm,session);
    }

UserServiceImpl

    /**
     * 登录验证
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        String phone = loginForm.getPhone();

        //判断手机号是否为空
        if(phone == null){
            return Result.fail("手机号不能为空");
        }

        //校验手机号是否合法
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式不正确");
        }

        //判断验证码是否正确
        String code = (String)session.getAttribute("code");
        if(!code.equals(loginForm.getCode())){
            return Result.fail("验证码不正确");
        }

        //判断用户是否存在
        User user = query().eq("phone", phone).one();
        if(user == null){
            log.info("用户不存在,创建新用户:{}",phone);
            //调用创建用户方法
            user =  createNewUser(phone);
        }

        //将已登录的用户信息保存在session中,但是不能将用户的所有信息都保存,这里选择保存UserDto而非保存User
        session.setAttribute("user" , BeanUtil.copyProperties(user,UserDTO.class));

        return Result.ok();
    }

    /**
     * 创建新用户
     * @param phone
     * @return
     */
    public User createNewUser(String phone){
        User user = new User();
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
        user.setPhone(phone);
        save(user);
        return user;
    }

登录功能完成后,依旧无法登录,因为前端会发送一个请求来获取当前已登陆的用户信息,然后在每次访问服务器时都会携带这个用户信息,服务端需要拿到当前用户信息,已便在后续功能中使用。上述这一流程我们还未完成

我们可以使用ThreadLocal来完成在服务端保存当前已登录用户信息的功能,ThreadLocal可以针对每一个socket连接做到线程隔离,适合用来保存用户信息

在基础代码中就已经提供了关于ThreadLocal的工具类

【Redis】Redis实战:黑马点评之短信登录_第8张图片

在UserController中完成以下方法的代码

    /**
     * 获取当前登录的用户信息
     * @return
     */
    @GetMapping("/me")
    public Result me(){
        // 通过ThreadLocal获取当前登录的用户信息并返回
        return Result.ok(UserHolder.getUser());
    }

当我们登录之后,前端会发送请求到这个方法,然后获取当前已登录用户信息,当然目前还是获取不了,因为我们并未在ThreadLocal中保存当前已登录用户信息。

那么在那里保存用户信息比较合适呢?答案是拦截器

2.4 实现登录拦截功能

登录拦截功能的实现图示如下:

【Redis】Redis实战:黑马点评之短信登录_第9张图片

新建一个LoginInterceptor,编写代码如下:

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        
        String uri = request.getRequestURI();
        log.info("拦截到请求:{}",uri);
        
        //判断当前请求是否携带session
        UserDTO user = (UserDTO)request.getSession().getAttribute("user");
        
        if(user == null){
            //user为空说明未登录,请求不放行直接返回
            log.info("用户未登录,请求{}已被拦截",uri);
            response.setStatus(401);
            return false;
        }
        
        log.info("用户已登录,id为{}",user.getId());
        
        //存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(user);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //在请求结束后销毁ThreadLocal中的用户信息
        UserHolder.removeUser();
    }
}

新建一个MvcConfig配置类,让拦截器生效

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    //addInterceptors 添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                );
    }
    
}

再次尝试登录,发现登录后已经可以正常跳转到用户主页了

2.5 session共享问题

使用session实现验证登录在单体项目中可行,但是在分布式项目中就会出现问题。

在分布式项目中,每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了,但是这种方案具有两个大问题:

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

2、session拷贝数据时,可能会出现延迟,在session拷贝期间如果有用户进行访问的话还是会出现session不存在的情况

【Redis】Redis实战:黑马点评之短信登录_第10张图片

由于session登录存在上述问题,因此我们会用redis来替代session,由于redis是是单独部署在其他服务器上的,所有的tomcat都可以对其进行访问,这样就可以解决数据共享的问题了。


3 基于Redis实现用户登录

3.1 key的设计

既然我们要利用redis存储数据,那么到底使用哪种结构呢?如果存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,需要注意的是由于String存放的是json串而Hash是直接存放字段和值的,因此String会比Hash占用更多的内存空间

【Redis】Redis实战:黑马点评之短信登录_第11张图片

我们需要保存在redis中的数据一共有两种,第一种是验证码,第二种是用户信息。那么针对这两种不同的信息,我们应该分别设计怎样的key呢?Redis中的key应该满足两点,第一点是唯一性,第二点是方便携带。

针对验证码,我们可以用手机号来做key,这样的话就可以很好的保证key的唯一性

针对用户信息,我们同样可以使用手机号作为key,但是有一个问题需要考虑,就是此时我们已经不用session进行用户校验了,那么服务器在做登录拦截时使用什么作为校验凭证呢?最好的方案就是使用redis中用户信息的key,前端在访问时通过访问头携带key来访问,如果通过key能在redis中找到数据,说明用户已登录。那么这种情况下我们最好不要使用手机号作为key,这毕竟属于用户比较隐私的信息,我们在后台生成一个随机串token,用这个token来作为key就比较合适了。

3.2 整体访问流程

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

【Redis】Redis实战:黑马点评之短信登录_第12张图片

3.3 代码实现

UserContorller不用修改,直接修改UserService

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

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 获取验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号是否正确
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式不正确");
        }

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

        //将验证码存放在redis中
        //这里使用String类型,key使用手机号码,值为验证码
        redisTemplate.opsForValue().set(phone,code);

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

        return Result.ok();
    }

    
    /**
     * 登录验证
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        String phone = loginForm.getPhone();

        //从redis中获取验证码
        String code = redisTemplate.opsForValue().get(phone);

        //判断手机号是否为空
        if(phone == null){
            return Result.fail("手机号不能为空");
        }

        //判断验证码是否为空
        if(code == null){
            return Result.fail("验证码不能为空");
        }

        //校验手机号是否合法
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式不正确");
        }

        //判断验证码是否正确
        if(!code.equals(loginForm.getCode())){
            return Result.fail("验证码不正确");
        }

        //判断用户是否存在
        User user = query().eq("phone", phone).one();
        if(user == null){
            log.info("用户不存在,创建新用户:{}",phone);
            //调用创建用户方法
           user =  createNewUser(phone);
        }

        //使用redis保存用户信息
        
        //这里使用uuid随机生成redis的key
        String userToken = UUID.randomUUID().toString();

        //这里为了避免不同业务的key冲突,给key加上前缀
        String userTokenKey = RedisConstants.LOGIN_USER_KEY+userToken;

        //使用Hash类型存储用户信息,存储数据前,需要先将对象转换成map集合
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

        //转换成map集合的过程中还需要做处理,因为StringRedisTemplate只能只针对字符串进行序列化,因此我们要将userDTO中每个 属性都转换成字符串
        Map<String, Object> map = BeanUtil.beanToMap(
                userDTO,
                new HashMap<>(),
                CopyOptions.create() //自定义拷贝选项
                        .ignoreNullValue() //允许属性为null
                        .setFieldValueEditor((fileName,fileValue)->fileValue.toString()) //对属性值进行编辑,把所有属性值转换成字符串
        );

        //保存数据到redis
        redisTemplate.opsForHash().putAll(userTokenKey,map);

        //设置redis有效期
        redisTemplate.expire(userTokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        //这里需要将token信息返回给前端,前端需要token令牌来访问
        return Result.ok(userToken);
    }

    /**
     *
     * @param phone
     * @return
     */
    public User createNewUser(String phone){
        User user = new User();
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
        user.setPhone(phone);
        save(user);
        return user;
    }

}

LoginInterceptor修改如下:

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    
     private StringRedisTemplate redisTemplate;

    //这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
    public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        
        String uri = request.getRequestURI();
        log.info("拦截到请求:{}",uri);
        
        //前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
        String token = request.getHeader("authorization");

        //判断token是否为空
        if(StrUtil.isBlank(token)){
            //token为空直接返回
            log.info("用户未登录,请求已被拦截:{}",uri);
            response.setStatus(401);
            return false;
        }
        
        //组装key
        String key = RedisConstants.LOGIN_USER_KEY + token;

        //通过key获取redis中的用户信息
        Map<Object, Object> map = redisTemplate.opsForHash().entries(key);

        if(map == null||map.size()==0){
            //map为null说明令牌是瞎编的,直接返回
            log.info("key不正确,请求已被拦截:{}",uri);
            response.setStatus(401);
            return true;
        }
        
        //将map集合转化为userDto对象
        UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);

        //保存用户信息到ThreadLocal
        UserHolder.saveUser(user);

        //刷新token有效期,用户每访问一次服务器都需要刷新一次token有效期,避免用户在一直活跃的情况下令牌失效
        redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        log.info("用户已登录,id为{}",user.getId());

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //在请求结束后销毁ThreadLocal中的用户信息
        UserHolder.removeUser();
    }
}

MvcConfig代码修改如下:

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //在创建对象时将stringRedisTemplate传给拦截器使用
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                );
    }
}

3.4 解决状态登录刷新问题

在上述代码中,我们为了避免出现活跃用户token失效的情况,在LoginInterceptor中编写了刷新令牌存活时间的代码,但是这样仍然可能出现令牌失效的情况,因为LoginInterceptor并没有拦截所有的访问路径,如果用户登陆了,但是在令牌过期时间内一直访问一些不需要拦截的路径,那么这个拦截器就不会生效,令牌刷新的动作也不会被执行,因此这个方案实际上时有问题的

【Redis】Redis实战:黑马点评之短信登录_第13张图片

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,这个拦截器用于拦截器中所有的路径,并负责刷新令牌和进行token的校验,如果判断用户登录了,就将用户信息存放在threadLocal中。无论用户登不登陆,该拦截器都会放行所有的请求,由LoginInterceptor进行未登录请求拦截的处理

【Redis】Redis实战:黑马点评之短信登录_第14张图片

整体代码如下:

新建一个RefreshTokenInterceptor拦截器,负责刷新令牌和保存用户信息等工作

@Slf4j
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    //这里stringRedisTemplate并不能直接在ioc容器中获取,因为本类并没有交给spring容器管理。但是MvcConfig会创造本类的对象,我们只需要通过构造器让MvcConfig传入即可
    public RefreshTokenInterceptor(StringRedisTemplate redisTemplate){
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String uri = request.getRequestURI();
        log.info("RefreshTokenInterceptor拦截到请求:{}",uri);

        //前端是通过请求头"authorization"携带token令牌的,我们需要先判断token令牌是否携带
        String token = request.getHeader("authorization");

        //判断token是否为空
        if(StrUtil.isBlank(token)){
            //令牌不存在是不需要刷新的,直接放给下一个拦截器处理
            log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
            return true;
        }

        //组装key
        String key = RedisConstants.LOGIN_USER_KEY + token;

        //通过key获取redis中的用户信息
        Map<Object, Object> map = redisTemplate.opsForHash().entries(key);

        if(map == null||map.size()==0){
            //用户不存在的话也不需要刷新令牌,直接放行给下一个拦截器处理
            log.info("令牌不存在,RefreshTokenInterceptor已放行请求:{}",uri);
            return true;
        }

        //将map集合转化为userDto对象
        UserDTO user = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);

        //保存用户信息到ThreadLocal
        UserHolder.saveUser(user);

        //刷新token有效期
        redisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        log.info("用户已登录,RefreshTokenInterceptor已放行请求,用户为{}",user.getId());

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //在请求结束后销毁ThreadLocal中的用户信息
        UserHolder.removeUser();
    }
}

修改LoginInterceptor的代码,因为很多工作我们已经在RefreshTokenInterceptor中做了,因此在LoginInterceptor我们只需要判断ThreadLocal中有没有用户信息即可

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

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

        String uri = request.getRequestURI();
        log.info("LoginInterceptor拦截到请求:{}",uri);

        if(UserHolder.getUser() == null){
            //说明用户未登录,直接拦截
            log.info("用户未登录,LoginInterceptor未放行请求{}",uri);
            response.setStatus(401);
            return false;
        }
        
        log.info("LoginInterceptor已放行请求:{}",uri);
        //说明用户已登录,直接放行
        return true;
    }
}

还需要在MvcConfig中修改拦截器配置

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // 配置拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器并排除不需要拦截的路径,即不用登录也可以访问的页面
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/user/login",
                        "/user/code",
                        "/shop-type/**",
                        "/upload/**"
                ).order(1);//
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    }
}

这样就大功告成了

你可能感兴趣的:(#,Redis,redis,数据库,java)