Spring boot-手把手教你使用Token
在进行接口开发的过程中,为了保证接口调用的安全性,需要对客户端的请求进行鉴权,此文将介绍在Spring boot项目中如何使用拦截器和JWT进行token校验和获取。
背景
- 最近遇到一个新项目,需要和第三方进行接口交互,包括调用第三方接口,以及第三方调用我方的回调接口,在接口交互中需要遵守行业内的一套标准,包括Token(令牌)校验,数据加密,签名验证等。这些与业务层面无关的逻辑,一般都会提取出来统一处理,博主接触的这个模块比较特殊,没有公共性,只是一个很小的模块需要和第三方进行交互,因此就在本模块中使用拦截器来进行统一处理。
概念
- Http协议是无状态的,客户端的每次请求,服务端都不知道客户端是哪位。为了区别不同客户端的请求,服务端会为每个客户端分配一个唯一标示,客户端每次请求的时候带上这个标示,服务端就知道这个客户端是谁了。Token是一个字符串,是客户端访问服务端的一个令牌,这个令牌会存储客户端的信息,可以不用每次都从数据库中获取客户端的信息。这个令牌也会有失效期,过了期限需要重新获取。具体流程如下:
- 客户端向服务端请求Token,并上送自己的clientId和clientSecret(客户端和服务端双方约定)。
- 服务端校验客户端上送的clientId和clientSecret是否合法,如果合法则返回Token,并赋予Token一个有效期,比如2个小时。
- 客户端将Token放到本地本地缓存中,比如浏览器中的cookie中。
- 客户端向客户端发起新的业务请求,并在请求头中带上Token。
- 服务端校验请求头中的Token是否正确,如果正确则进行业务逻辑处理并返回结果,如果失败,则拒绝访问。
- 如果Token失效,则客户端重新请求Token。
JWT
- JWT(json web token) 是网络应用中资源拥有者(服务端)和资源使用者(客户端)之间用来传递身份信息的一种工具。json是一种语言无关性的数据传输格式,因此经常用于应用间的数据传输。而token(令牌)是一种加密后的权限信息,服务端通过token识别客户端是否有访问的权限。
组成
JWT由三部分组成,由类型和加密算法的head(头部),包含公共信息和自定义信息的playboard(负载),以及signature(签名)组成。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIzOTU4MTU4MDEiLCJleHAiOjE2MDYxMDc5NTEsImlhdCI6MTYwNjEwMDc1MX0.xai-cVqrbfFeb3TNgVkHXYiT47sXUK76D47QLZecsqg
头部
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 就是头部信息,这是由base64加密后的密文,base64是一种对称加密算法,解密后的json格式如下。头部信息由type(类型)和 alg(加密算法)组成。类型就是"JWT",加密算法一般使用 HMAC SHA256加密算法。
{
"typ":"JWT",
"alg":"HS256"
}
负载
eyJhdWQiOiIzOTU4MTU4MDEiLCJleHAiOjE2MDYxMDc5NTEsImlhdCI6MTYwNjEwMDc1MX0 就是负载信息,加密后的json格式如下。负载信息一般由标准申明,公共声明,私有声明组成。
{
"aud":"395815801",
"exp":1606107951,
"iat":1606100751
}
- 标准声明有以下组成
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明和私有的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
签名
第三部分签名是由base64加密后的头部信息和负载信息以及secret组成的签名,签名算法是有头部信息定以的加密算法,一般是HMAC SHA256。然后头部,负载,签名三部分组成了token。
获取Token
- 获取第三方Token,获取成功后将Token放入到内存中,以便下次使用。下次进行业务请求将Token放请求头Header中,并在下次Token 即将失效的时候,刷新Token,主动去获取Token。
// token-有效时间 键值对
private static MutablePair<String, Long> authPair = MutablePair.of(null, 0L);
/**
* 获取authorization
* @return
*/
private static String getAuthorization(){
String cacheToken = authPair.getLeft();
Long expiresMill = authPair.getRight();
long currentTimeMillis = System.currentTimeMillis();
// 过期 或 token为空 刷新
String token = null;
if (currentTimeMillis >= expiresMill || StringUtils.isEmpty(cacheToken)) {
TokenDTO tokenDTO = queryToken();
if (ObjectUtil.isNotNull(tokenDTO)) {
// 比第三方的过期时间早30s刷新
int availableTime = tokenDTO.getTokenAvailableTime() - 30;
long newExpiresMill = currentTimeMillis + (availableTime * 1000);
authPair.setLeft(tokenDTO.getAccessToken());
authPair.setRight(newExpiresMill);
token = authPair.getLeft();
}
} else {
token = cacheToken;
}
return StrUtil.isEmpty(token) ? null : "Bearer " + token;
}
提供Token
- 提供给第三方token,在第三方进行回调接口的调用时,需要调用我方token,此时使用到了JWT包,使用JWT进行token管理和校验。
pom.xml引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
新增controller,提供token接口
@Autowired
CallbackService callbackService;
/**
* 获取token
* @param requestDTO
* @return
*/
@PostMapping("/api/callback/query_token")
public ResultDataDTO queryToken(@RequestBody RequestDTO<String> requestDTO) {
ResultDataDTO resultDataDTO = callbackService.queryToken(request.getData());
return resultDataDTO;
}
CallbackService 生成token
/**
* token有效时间默认2小时,单位(ms)
*/
private static final Integer tokenAvailableTime = 60 * 60 * 1000 * 2;
@Override
public ResultDataDTO queryToken(OperatorDTO operatorDTO) {
String token = null;
// 单位(s)
Integer availableTime = tokenAvailableTime / 1000;
long endTimeMill = System.currentTimeMillis() + tokenAvailableTime;
// 有效起始
Date validStartDate = new Date();
// 有效结束
Date validEndDate = new Date(endTimeMill);
token = JWT.create()
.withAudience(operatorDTO.getClientID())
.withIssuedAt(validStartDate)
.withExpiresAt(validEndDate)
.sign(Algorithm.HMAC256(operatorDTO.getClientID()));
TokenDTO tokenDTO = TokenDTO.builder()
.AccessToken(token)
.OperatorID(operatorDTO.getClientID())
.TokenAvailableTime(availableTime)
.build();
return ResultDataDTO.success(tokenDTO);
}
返回的示例如下
{
"TokenAvailableTime":7200,
"OperatorID":"395815801",
"AccessToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIzOTU4MTU4MDEiLCJleHAiOjE2MDYxMDc5NTEsImlhdCI6MTYwNjEwMDc1MX0.xai-cVqrbfFeb3TNgVkHXYiT47sXUK76D47QLZecsqg"
}
校验Token
- 校验第三方token,我们可以放在拦截器中进行统一处理,对方法进行拦截,如果被拦截的方法有自定义注解,则校验请求头中的token,否则放过执行。
定义自定义注解,在需要token校验的方法上加上即可
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckToken {
boolean required() default true;
// 组织机构代码
String clientId();
}
新增AuthenticationInterceptor对第三方请求进行拦截,实现HandlerInterceptor接口
@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {
@Autowired
Environment environment;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
String token = StrUtil.sub(authorization, StrUtil.length("Bearer "), StrUtil.length(authorization));
// 如果不是映射方法直接通过
if(!(handler instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 判断是否需要进行校验token
if(method.isAnnotationPresent(CheckToken.class)){
CheckToken checkToken = method.getAnnotation(CheckToken.class);
if(checkToken.required()){
if(ObjectUtil.isNull(token)){
throw Exceptions.fail(ErrorMessage.errorMessage(RetCodeEnum.TOKEN_ERROR.getCode(),
RetCodeEnum.TOKEN_ERROR.getName()));
}
// 验证token
String clientId = environment.resolvePlaceholders(checkToken.clientId());
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(clientId)).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e){
throw Exceptions.fail(ErrorMessage.errorMessage(RetCodeEnum.TOKEN_ERROR.getCode(),
RetCodeEnum.TOKEN_ERROR.getName()));
}
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
注册AuthenticationInterceptor拦截器,对指定请求路径进行拦截
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(authenticationInterceptor()).addPathPatterns("/api/callback/**");
}
@Bean
public AuthenticationInterceptor authenticationInterceptor(){
return new AuthenticationInterceptor();
}
}
- controller新增方法提供回调接口给第三方,并加上自定义注解
@CheckToken(clientId = "${client.id}")
@PostMapping("/api/callback/notification_result")
public ResultDataDTO notifyStartChargeResult(@RequestBody RequestDTO<String> requestDTO){
return callbackService.notifyResult(request.getData());
}
- 至此,我们已经完成了获取第三方token,并提供token和校验的功能,在此过程中,我们使用到了Spring的拦截器对指定请求进行拦截,并在有自定义注解的方法的时候才去校验token,灵活地对校验粒度进行了控制。
总结
在此文中,我们大致了解了Token的定义,获取,校验等方法。此外,Token 的无状态,可扩展性,多平台跨域等特性,也让Token广泛应用在安全校验领域中。在接下来的几篇文章中,我将介绍如何使用Spring AOP进行加密,解密,验签等操作。