1. 前言
我目前的项目是前后端分离的项目,前后端分离时我们会因为同源策略而无法设置cookie和sessionid,自然就用到了JWT(Json Web Token)来解决身份认证,相对于其他方式有很多难以媲美的优点。
但是呢,每一个技术都不是完美的,有优点则必有缺点,我们学习每一项技术的时候都要了解随之而来的不足!token实际上是一个拥有完整身份信息和过期时间的加密字符串,一经生成,就不可控。
2. 需求场景
- 如何刷新token但同时有效的只有一个?
- 如何在超时后自动刷新,而不是直接跳转登录页影响用户体验?
3.解决策略
- jwt的vo包装类
package com.smart.gateway.vo;
import lombok.Builder;
import lombok.Getter;
import java.io.Serializable;
@Builder
@Getter
public class JwtVo implements Serializable {
//token令牌
private String jwt;
//刷新token,过期时间比jwt的长一点,用于判断用户是否活跃,并动态刷新token
private String refreshJwt;
//jwt的过期时间
private Long expireTime;
}
简要说明:
- jwt:是正常的token
- refreshJwt:和jwt唯一不同的就是过期时间会延后点,根据实际情况进行调整,时间的差值即为判断用户是否活跃的一个阈值
- expireTime:jwt的过期时间,可减少jwt的解析时间,减小服务器性能消耗,提高效率
由于项目使用了springsecurity来进行鉴权,因此使用了spring security的全局过滤器
package com.smart.gateway.filter;
import com.alibaba.fastjson.JSON;
import com.smart.gateway.service.ImmunityPathService;
import com.smart.pojo.Result;
import com.smart.pojo.StatusCode;
import com.smart.util.JwtUtil;
import com.smart.vo.JwtVo;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.util.Base64Utils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* description:
* creator: 蝠鲼
* create_time: 2020/7/22
* version: v1.0
*/
@Component
@Slf4j
public class AuthorizeFilter implements GlobalFilter, Ordered {
public static final String API_URI = "/v2/api-docs";
public static final String LOGIN_URl = "/login";
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private ImmunityPathService immunityPathService;
@Value("${jwt.tokenSign}")
private String tokenSign;
@Value("${token.header}")
private String header;
@Value("${doc.visualization.enable}")
private Boolean visualization;
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String url = request.getURI().getPath();
List<String> immuPaths = immunityPathConfig.getPaths();
if (immuPaths.contains(url)){
return chain.filter(exchange);
}
if (visualization && (url.indexOf(API_URI) >= 0)){
return chain.filter(exchange);
}
String authorization = request.getHeaders().getFirst(tokenSign);
//没有特定的请求头以及请求头不是以特定字符串开头的直接返回
if (StringUtils.isBlank(authorization)){
Result result = Result.failure(StatusCode.OPERATION_ERROR_WITHOUT_TOKEN);
byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json; charset= UTF-8");
return response.writeWith(Mono.just(buffer));
}
//判断字符串头部
if (!authorization.startsWith(header)){
Result result = Result.failure(StatusCode.OPERATION_ERROR_TOKEN);
byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json; charset= UTF-8");
return response.writeWith(Mono.just(buffer));
}
//将字符串base64解密
String jwtVoStr = authorization.substring(7);
byte[] jwtVoStrByte = Base64Utils.decodeFromString(jwtVoStr);
JwtVo jwtVo = JSON.parseObject(new String(jwtVoStrByte), JwtVo.class);
//判断jwt是否为空
if (StringUtils.isBlank(jwtVo.getJwt())){
Result result = Result.failure(StatusCode.OPERATION_ERROR_WITHOUT_TOKEN);
byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json; charset= UTF-8");
return response.writeWith(Mono.just(buffer));
}
//判断jwt是否过期
//先通过expireTime快速判断第一遍
//若过期
Date date = new Date();
if (date.after(new Date(jwtVo.getExpireTime()))){
//再进入token判断一下,防止篡改
//过期则继续后续判断
if (jwtUtil.isTokenExpired(jwtVo.getJwt())){
//判断refreshjwt是否为空
if (StringUtils.isBlank(jwtVo.getRefreshJwt())){
Result result = Result.failure(StatusCode.OPERATION_ERROR_WITHOUT_TOKEN);
byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json; charset= UTF-8");
return response.writeWith(Mono.just(buffer));
}
//再判断refreshJwt是否也过期
//若refreshjwt过期,直接重定向到登录页
if (jwtUtil.isTokenExpired(jwtVo.getRefreshJwt())){
//303状态码表示由于请求对应的资源存在着另一个URI,应使用GET方法定向获取请求的资源
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set(HttpHeaders.LOCATION, LOGIN_URl);
return response.setComplete();
}
//若refreshjwt没过期,需要刷新jwt并返回给前端
JwtVo jv = jwtUtil.refreshToken(jwtVo.getJwt());
byte[] bytes = JSON.toJSONString(jv).getBytes();
String enStr = Base64Utils.encodeToUrlSafeString(bytes);
Map<String, String> map = new HashMap<>();
map.put("token",enStr);
Result result = Result.failure(StatusCode.TOKEN_NEEDS_TO_BE_REFRESHED,map);
byte[] bits = JSON.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json; charset= UTF-8");
return response.writeWith(Mono.just(buffer));
}
}
//未过期直接放行
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
简化一下逻辑如下:
- 从请求头中取出自定义的鉴权包装token
- 包装token采用base64加密,因此需要再解密
- 先判断jwt是否过期,未过期直接放行
- 在判断refresh是否过期,也过期的话直接重定向到登录页
- jwt过期但refreshJwt未过期,这里直接生成新的包装token,base64加密后返回
4.总结
巧妙的用了一个jwt的包装类,实际上就是第二个refreshJwt只是比jwt过期时间长些,且只调用一次,它被调用的时候必然jwt已经过期了,但用户还属于在活跃区间,这时后台刷新token,用户完全是没有感觉的,并且刷新token是在jwt过期之后,也不存在同一个用户同时存在多个有效token存在的问题!可以说是兼顾了用户体验和安全性;