Redis+Token实现用户注册登录,拦截
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情
具体流程是这样的:
1.前端发送带有电话号码的请求,请求后端返回一个验证码
思路:
- 检验手机是否符合规范
- 生成随机的二维码
- 存入redis缓存中,将phone作为key,code作为value
- 调用第三方服务发送短信验证码到你的手机上
@Override
public Result sendCode(String phone, HttpSession session) {
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号不合规范");
}
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送短信验证码
log.debug("开始发送短信" + code);
return Result.ok();
}
这时候,你也许会好奇,为什么这里我们不用session进行这个验证码的存储呢?
原来,如果我们部署了多个这样的相同的项目,然后利用nginx做负载均衡依次转发的时候,是不是只有一台服务器拿到了session中的值呀?那么第二胎服务器肯定没有第一台服务器存放的session,因此,就出现了,如果第二台服务器需要用到用户信息,那么它将获取不到的情况。
那么,我们如何去解决这个问题呢?
早期的方案是session的拷贝,通过tomcat的session拷贝,实现共享,但是这个对tomcat的内存负担太大,并且数据拷贝的时候,延迟也是肯定会有的
后来,我们就基于redis,我们不把数据放在session里面,因为redis也是可以实现是数据共享的
2.在前端收到了短信验证码以后呢,它肯定会向后端再次发送自己收到的验证码,来向后端确认自己收到的验证码和刚刚保存的验证码一致。如果一致就实现了登录
思路:
-
继续校验手机号
-
从redis中取出验证码,和传过来的code进行校验
-
从数据库中通过phone查有关用户,如果没有说明这是第一次注册,存入数据库,进行下一步。如果有,就进行下一步
-
生成一个唯一token,作为存储用户登录态的一个key,它是乱序的,并将user的信息作为value存入redis
-
设置过期时间
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { // 1.校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)) { // 2.如果不符合,返回错误信息 return Result.fail("手机号格式错误!"); } // 3.从redis获取验证码并校验 String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)) { // 不一致,报错 return Result.fail("验证码错误"); } // 4.一致,根据手机号查询用户 select * from tb_user where phone = ? 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对象转为HashMap存储 UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); 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; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); // 7.4.设置token有效期 stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES); // 8.返回token return Result.ok(token); } private User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10)); save(user); return user; }这里你获取又会有所疑惑,为什么需要一个token作为它的一个key呢?
原来,在前端中。第一次登录完以后,我们会把这个token作为一个请求头放入anxios的请求体中。如果这个请求头中没有这个token,那么我们后端就会把它直接拦截!
3.下面,我们将进行拦截器的书写
思路:
- 在请求头中拿到token,并且进行校验
- 在redis中取到user的信息
- 在UserHolder中存入用户信息
- 设置过期时间
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 { String token = request.getHeader("authorization"); if(StrUtil.isBlank(token)){ return false; } String key = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if (userMap.isEmpty()){ return false; } UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key,LOGIN_CODE_TTL, TimeUnit.MINUTES); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); } }我们这里的StringRedisTemplate用了构造器,我们期待之后再创建这个类的时候,外层先利用
@Resouce注解先注入,再传入。这里的afterCompletion意思是,在拦截器链结束后(spring gateway里面的拦截器链类似),执行的操作,移除当前线程的user对象
这时候,你或许又有疑问,UserHolder又是什么?
我们这里创建了一个UserHolder的类
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(); } }它的本质其实就是TreadLocal,我们对于有token的请求去查询对应的user,并且把它放入当前的本地线程对象中,这是一种基于内存的存储方式。我们在这里只写了一个拦截器,那如果有很多个拦截器呢?难道每次在某个拦截器用到的时候,就还得再查一遍数据库吗?
4.拦截器的放行
同学们思考一个问题,如果说,拦截器这样设计虽然能解决,只有用户登录态的请求能访问后台其它请求,但是初始的注册和登录请求难道也要进行拦截吗?那岂不是永远都没有用户能注册了?
没错,我们需要对拦截器做一些配置,让他放行
@Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RefreshTokenInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ) .order(1); } }但是噢,细心的同学又会有所疑惑,如果我们只在不被拦截的网址里面请求,那么过一段时间,我的用户登录态是不是又会消失了?
没错,是这样的,那么我们怎么改进呢?
我们是不是要一个拦截器,然后这个拦截器每一个请求都需要进行拦截,然后在拦截器里面刷新一下redis存的用户的登录态?
没错,我们再加一个拦截器,来实现每个请求都拦截
@Configuration public class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { // 登录拦截器 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ).order(1); // token刷新的拦截器 registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0); } }我们通过设置order值来决定拦截器的执行拦截的先后,order值越低,优先级越高。
我们通过刚刚设置好的拦截器,查redis,存user对象到当前线程,然后进入下一个loginInterceptor,这个拦截器用来放行和对于一些状态码的设置
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { UserDTO userDTO = UserHolder.getUser(); if(userDTO == null){ response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }