共享session问题
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?
- 早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了。
- 问题1:每台服务器中都有完整的一份session数据,服务器压力过大。
- 问题2:session拷贝数据时,可能会出现延迟
- 解决:redis天然满足共享session的条件
设计key的结构
使用哪种结构呢?
- 由于存入的数据比较简单,我们可以考虑使用String,或者是使用Hash
- 如果使用String,value 多占用一点空间
- 如果使用Hash,value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以。
设计key的具体细节
共享session是每个用户都有自己的session,所以要满足:
- key要具有唯一性
- key要方便携带
我们在后台使用 jwt 生成一个字符串 token,然后让前端在 Header 带来这个token就能完成我们的整体逻辑了。
整体访问流程
解决状态登录刷新问题
第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
实例代码
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); //拦截器优先级,值越小优先级越高
}
}