Redis实战之共享session + jwt 实现登录拦截、刷新token

时间:2022-11-30 10:54:03

共享session问题

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?

  • 早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了。
  • 问题1:每台服务器中都有完整的一份session数据,服务器压力过大。
  • 问题2:session拷贝数据时,可能会出现延迟
  • 解决:redis天然满足共享session的条件

Redis实战之共享session + jwt 实现登录拦截、刷新token

设计key的结构

使用哪种结构呢?

  • 由于存入的数据比较简单,我们可以考虑使用String,或者是使用Hash
  • 如果使用String,value 多占用一点空间
  • 如果使用Hash,value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以。

设计key的具体细节

共享session是每个用户都有自己的session,所以要满足:

  • key要具有唯一性
  • key要方便携带

我们在后台使用 jwt 生成一个字符串 token,然后让前端在 Header 带来这个token就能完成我们的整体逻辑了。

整体访问流程

Redis实战之共享session + jwt 实现登录拦截、刷新token

解决状态登录刷新问题

第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

Redis实战之共享session + jwt 实现登录拦截、刷新token

实例代码

pom文件

 <dependency>
    <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
  </dependency>
<!--hutool-->
 <dependency>
    <groupId>cn.hutool</groupId>
     <artifactId>hutool-all</artifactId>
     <version>5.7.17</version>
 </dependency>
 <dependency>
     <groupId>io.jsonwebtoken</groupId>
     <artifactId>jjwt</artifactId>
     <version>0.9.0</version>
 </dependency>

jwt 工具类

/**
 * jwt工具类
 */
public class JwtUtils {

    //加密 解密时的密钥(盐) 用来生成key
    public static final String JWT_KEY = "campus2022";

    /**
     * 生成加密后的秘钥 secretKey
     *
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtils.JWT_KEY);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * 创建jwt密钥
     *
     * @param subject   加密主体
     * @param ttlMillis 过期时间
     * @return String
     */
    public static String createJWT(String subject, long ttlMillis) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //指定签名的时候使用的签名算法,也就是header那部分,jjwt已经将这部分内容封装好了。
        long nowMillis = System.currentTimeMillis();//生成JWT的时间
        Date now = new Date(nowMillis);
        SecretKey key = generalKey();//生成签名的时候使用的秘钥secret,这个方法本地封装了的,一般可以从本地配置文件中读取,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
        JwtBuilder builder = Jwts.builder() //这里其实就是new一个JwtBuilder,设置jwt的body
//                .setClaims(claims)            //如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
                .setId(UUID.randomUUID().toString())                    //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setIssuedAt(now)            //iat: jwt的签发时间
                .setSubject(subject)        //sub(Subject):代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。
                .signWith(signatureAlgorithm, key);//设置签名使用的签名算法和签名使用的秘钥
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);        //设置过期时间
        }
        return builder.compact();            //就开始压缩为xxxxxxxxxxxxxx.xxxxxxxxxxxxxxx.xxxxxxxxxxxxx这样的jwt
    }

    /**
     * 解密
     *
     * @param jwt
     * @return
     */
    public static Claims parseJWT(String jwt) {
        SecretKey key = generalKey();  //签名秘钥,和生成的签名的秘钥一模一样
        Claims claims = Jwts.parser()  //得到DefaultJwtParser
                .setSigningKey(key)         //设置签名的秘钥
                .parseClaimsJws(jwt).getBody();//设置需要解析的jwt
        return claims;
    }

    /**
     * 测试
     *
     * @param args
     */
    public static void main(String[] args) {

        String userId = "1234";
        //加密
        String jwt = createJWT(userId, 3600 * 24);
        System.out.println("加密后:" + jwt);

        //解密
        Claims claims = parseJWT(jwt);
        String subject = claims.getSubject();
        System.out.println("解密后:" + subject);
    }

}

controller

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserServiceImpl userService;


    //刷新token普通请求
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }

    //登录
    @PostMapping("/login")
    public Result login(@RequestBody User user) {
        return userService.login(user);
    }
}

service

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Autowired
    private StringRedisTemplate redisTemplate;

    public Result login(User user) {
        if (user==null || user.getUsername()==null){
            return Result.fail("账号为空");
        }
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(true, User::getUsername, user.getUsername());
        User one = userMapper.selectOne(wrapper);
        if (one==null){
            return Result.fail("账号未注册");
        }
        if (!one.getPassword().equals(user.getPassword())){
            return Result.fail("密码错误");
        }
        //根据用户账号生成token
        String token = JwtUtils.createJWT(one.getUsername(), 24 * 3600);
        //将用户信息转为Map
        Map<String, Object> userMap = BeanUtil.beanToMap(one,
                new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        //将(token,用户信息)存入redis
        redisTemplate.opsForHash().putAll("user:token:"+token,userMap);
        //设置过期时间
        redisTemplate.expire("user:token:"+token, Duration.ofMinutes(30));
        //返回token,登录成功
        return Result.ok(token);
    }
}

ThreadLocal

public class UserHolder {
    private static final ThreadLocal<User> tl = new ThreadLocal<>();

    public static void saveUser(User user){
        tl.set(user);
    }

    public static User getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

拦截器

登录拦截
/**
 * 登录拦截
 */
public class LoginInterceptor implements HandlerInterceptor {

    //目标资源执行前执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断是否需要拦截(ThreadLocal中是否有用户)
        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();
    }
}
请求拦截,刷新 token 有效期
/**
 * 请求拦截,刷新 token 有效期
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.redisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于token获取redis中的用户
        String key  = "user:token:" + token;
        Map<Object, Object> userMap = redisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 4.将查询到的hash数据转为User
        User user = BeanUtil.fillBeanWithMap(userMap, new User(), false);
        // 5.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(user);
        // 6.刷新token有效期
        redisTemplate.expire(key, Duration.ofMinutes(30));
        // 7.放行
        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 StringRedisTemplate redisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns("/user/login","/user/hello") //排除拦截路径
                .order(1); //拦截器优先级,值越大优先级越低

        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(redisTemplate))
                .addPathPatterns("/**") //拦截所有路径,用于token刷新
                .order(0); //拦截器优先级,值越小优先级越高
    }
}

结果图

登录,并返回token

Redis实战之共享session + jwt 实现登录拦截、刷新token

刷新token普通请求

Redis实战之共享session + jwt 实现登录拦截、刷新token

redis,token及用户信息,只要有请求就会刷新 TTL存活时间

Redis实战之共享session + jwt 实现登录拦截、刷新token