在上一篇中,我们介绍了如何使用 Spring Authorization Server 作为认证服务器进行认证授权,同时也介绍了如何对资源服务器中的资源进行访问保护,以及如何对访问资源服务器携带的令牌(token)进行合法性校验。在校验令牌(token)合法性的过程中,我们采用了连接认证服务器进行远程校验的方式,这种校验方式,需要发起网络请求,给资源服务器和认证服务器都增加了网络 IO 负担。本篇,我们来介绍采用本地认证的方式对令牌(token)合法性进行校验。
1. 算法及流程
1.1. 算法处理
本篇采用 RSA 算法对令牌(token)的生成和校验加以处理。处理方式就是在认证服务器生成 access_token 的时候,给 access_token 增加 rdm 、sign 两个字段,字段说明如下。
rdm:是一串使用 RSA 私钥加密过后的字符串,本篇组装的明文内容为:随机数 + 井号分隔符 + access_token 过期时间 + 井号分隔符 + 当前时间。在实际应用中,也可以根据实际需要,加入其他有意义的字段,例如:userId。
sign:是一串使用 RSA 私钥签名过后的字符串,本篇组装的明文内容为:随机数 + 井号分隔符 + 当前时间。注意:此处的随机数和当前时间,需要与 rdm 的随机数和当前时间保持相同的值。
认证服务器在生成 access_token 的时候,对 rdm 字段的明文使用 RSA 私钥进行加密,对 sign 的明文使用 RSA 私钥进行签名。资源服务器在接收到 access_token 的时候,先使用 JWT 进行解析,然后对 rdm 字段的密文使用 RSA 公钥进行解密,对 access_token 过期时间进行校验,对 sign 的密文使用 RSA 公钥进行验签。
上面的 RSA 密钥对,私钥放在认证服务器上,公钥放在资源服务器上。
1.2. 流程交互
对 access_token 加入 rdm、sign 字段后,前端应用、认证服务器、资源服务器的交互流程如下。
2. 组件工程
上一篇中,我们对资源服务器进行资源保护和令牌(token)认证时,在资源服务工程中引入了 spring-boot-starter-oauth2-resource-server 组件,该组件需要配置认证服务器的地址进行授权委托。在本篇中,我们将去除 spring-boot-starter-oauth2-resource-server 组件,使用 spring-boot-starter-oauth2-client 组件来保护资源服务器中的资源。由于对 access_token 中 rdm、sign 字段的处理,各资源服务器都是同样的处理方法,而且在实际应用中,也经常会用到 SecurityContextHolder.getContext() 来获取认证的上下文信息, 在此,我们将 spring-boot-starter-oauth2-client 与 RSA 的工具类,打包成为一个组件工程,供各资源服务器依赖使用。
2.1. 创建组件工程
在 mall-component 工程下创建子工程,名称为:mall-component-oauth2。
pom.xml 文件内容如下。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.component</groupId>
<artifactId>mall-component</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>mall-component-oauth2</artifactId>
<name>mall-component-oauth2</name>
<description>mybatis组件</description>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<properties>
<spring-boot.version>3.0.2</spring-boot.version>
</properties>
<dependencies>
<!--spring-boot-starter-oauth2-client-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.5.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.19</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>10.1.10</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
2.2. 添加算法工具类
在 mall-component-oauth2 工程中创建 org.example.component.oauth2.util 包目录,在该目录下,创建 RSA 工具类 RSAUtils 和 access_token 签名、验签工具类 AccessTokenUtils。
RSAUtils 内容如下。
public class RSAUtils {
/**
* 数字签名,密钥算法
*/
private static final String RSA_KEY_ALGORITHM = "RSA";
/**
* 数字签名签名/验证算法
*/
private static final String SIGNATURE_ALGORITHM = "MD5withRSA";
/**
* 私钥加密
*/
public static String encryptByPriKey(String text, String privateKey) {
try {
byte[] priKey = Base64Decoder.decode(privateKey);
byte[] enSign = encryptByPriKey(text.getBytes(), priKey);
return Base64Encoder.encode(enSign);
} catch (Exception e) {
throw new RuntimeException("加密字符串[" + text + "]时遇到异常", e);
}
}
/**
* 私钥加密
*/
public static byte[] encryptByPriKey(byte[] data, byte[] priKey) throws Exception {
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(priKey);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return cipher.doFinal(data);
}
/**
* 公钥解密
*/
public static byte[] decryptByPubKey(byte[] data, byte[] pubKey) throws Exception {
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(pubKey);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(x509KeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, publicKey);
return cipher.doFinal(data);
}
/**
* 公钥解密
*/
public static String decryptByPubKey(String data, String publicKey) throws Exception {
byte[] pubKey = Base64Decoder.decode(publicKey);;
byte[] design = decryptByPubKey(Base64Decoder.decode(data), pubKey);
return new String(design);
}
/**
* RSA签名
*/
public static String sign(byte[] data, byte[] priKey) throws Exception {
// 取得私钥
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(priKey);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
// 生成私钥
PrivateKey privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
// 实例化Signature
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
// 初始化Signature
signature.initSign(privateKey);
// 更新
signature.update(data);
return Base64Encoder.encode(signature.sign());
}
/**
* RSA校验数字签名
*/
public static boolean verify(byte[] data, byte[] sign, byte[] pubKey) throws Exception {
// 实例化密钥工厂
KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
// 初始化公钥
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(pubKey);
// 产生公钥
PublicKey publicKey = keyFactory.generatePublic(x509KeySpec);
// 实例化Signature
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
// 初始化Signature
signature.initVerify(publicKey);
// 更新
signature.update(data);
// 验证
return signature.verify(sign);
}
}
AccessTokenUtils 内容如下。
public class AccessTokenUtils {
private final static Log logger = LogFactory.getLog(AccessTokenUtils.class);
/**
* 随机数分隔符
*/
public static final String RDM_SEP = "#";
/**
* AccessToken 签名
*/
public static JwtClaimsSet.Builder signAccessToken(JwtClaimsSet.Builder claims,String privateKey){
String uuIdStr = UUID.randomUUID().toString();
long currentTimeMillis = System.currentTimeMillis();
String rdmSource = uuIdStr + RDM_SEP + claims.build().getExpiresAt().getEpochSecond() + RDM_SEP + currentTimeMillis;
String rdmTarget = RSAUtils.encryptByPriKey(rdmSource,privateKey);
String signSource = uuIdStr + RDM_SEP + currentTimeMillis;;
String signature = "";
try {
signature = RSAUtils.sign(signSource.getBytes(), Base64Decoder.decode(privateKey));
} catch (Exception e) {
throw new RuntimeException(e);
}
claims.claim("rdm",rdmTarget);
claims.claim("sign",signature);
return claims;
}
/**
* 验签
*/
public static boolean verifyAccessToken(String authorizationToken,String publicKey){
String accessToken = authorizationToken.replace("Bearer ","");
JWT jwtToken = JWTUtil.parseToken(accessToken);
JWTPayload jwtPayload = jwtToken.getPayload();
String rdm = jwtPayload.getClaim("rdm")+"";
String sign = jwtPayload.getClaim("sign")+"";
//解密
String rdmSource = "";
try {
rdmSource = RSAUtils.decryptByPubKey(rdm,publicKey);
} catch (Exception e) {
logger.info("rdm解密失败,rdm="+rdm);
}
String[] rdmData =rdmSource.split(RDM_SEP);
long exp = Long.parseLong(rdmData[1]);
long now = Instant.now().getEpochSecond();
if(exp<now){
logger.info("accessToken已过期,exp="+exp);
return false;
}
String signSource = rdmData[0] + RDM_SEP + rdmData[2];;
//验签
boolean verifyResult = false;
try {
verifyResult = RSAUtils.verify(signSource.getBytes(), Base64Decoder.decode(sign),Base64Decoder.decode(publicKey));
} catch (Exception e) {
logger.info("accessToken验签异常,accessToken="+accessToken,e);
}
if(!verifyResult){
logger.info("accessToken验签不通过,accessToken="+accessToken);
return false;
}
return true;
}
}
2.3. 添加鉴权管理器
在 mall-component-oauth2 工程中创建 org.example.component.oauth2.authorization 包目录,在该目录下,分别创建网关服务鉴权管理器 WebFluxAuthorizationManager、应用服务的鉴权管理器 WebMvcAuthorizationManager。
WebFluxAuthorizationManager 内容如下。
public class WebFluxAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Value("${authorization.accesstoken.rsa.key.public}")
private String publicKey;
private final Log logger = LogFactory.getLog(getClass());
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerWebExchange exchange = authorizationContext.getExchange();
String authorizationToken = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); // 从Header里取出token的值
if (!StringUtils.hasText(authorizationToken)) {
logger.warn("当前请求头Authorization中的值不存在");
return Mono.just(new AuthorizationDecision(false));
}
boolean verifyResult = AccessTokenUtils.verifyAccessToken(authorizationToken,publicKey);
if(!verifyResult){
return Mono.just(new AuthorizationDecision(false));
}
return Mono.just(new AuthorizationDecision(true));
}
}
WebMvcAuthorizationManager 内容如下。
public class WebMvcAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
@Value("${authorization.accesstoken.rsa.key.public}")
private String publicKey;
private final Log logger = LogFactory.getLog(getClass());
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
HttpServletRequest request = object.getRequest();
String authorizationToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if(!StringUtils.hasText(authorizationToken)){
return new AuthorizationDecision(false);
}
boolean verifyResult = AccessTokenUtils.verifyAccessToken(authorizationToken,publicKey);
if(!verifyResult){
return new AuthorizationDecision(false);
}
return new AuthorizationDecision(true);
}
}
2.4. 工程结构
mall-component-oauth2 工程结构如下所示。
3. 服务改造
首先,我们先查看一下,改造前的 access_token 字段组成,使用密码模式获取 token,如下所示。
使用 JWT 工具对 access_token 进行解析,结果如下。
然后,生成 RSA 密钥对(认证服务器 AuthorizationServerConfig 有密钥对生成方法) PrivateKey 和 PublicKey,PrivateKey 给认证服务器使用,PublicKey 资源服务器使用。
我们对服务的改造,需要给 access_token 加入 rdm、sign 字段。关于 rdm、sign 字段加密、解密、签名、验签的方法,都封装在 mall-component-oauth2 组件中,认证服务器、资源服务器只需添加如下依赖即可使用。
<dependency>
<groupId>org.example.component</groupId>
<artifactId>mall-component-oauth2</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
3.1. 认证服务改造
添加 mall-component-oauth2 依赖,在 AuthorizationServerConfig 中添加 RSA 私钥注入,如下。
@Value("${authorization.accesstoken.rsa.key.private}")
private String accessTokenRsaPrivateKey;
然后在 AuthorizationServerConfig 的 jwtCustomizer 方法,添加”AccessTokenUtils.signAccessToken(claims,accessTokenRsaPrivateKey);“一行代码即可。 jwtCustomizer 方法代码如下。
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(MyOidcUserInfoService myOidcUserInfoService) {
return context -> {
JwsHeader.Builder headers = context.getJwsHeader();
JwtClaimsSet.Builder claims = context.getClaims();
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
//客户端模式不参与用户权限信息处理
if(!AuthorizationGrantType.CLIENT_CREDENTIALS.equals(context.getAuthorizationGrantType())){
// Customize headers/claims for access_token
claims.claims(claimsConsumer->{
UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
claimsConsumer.merge("scope",userDetails.getAuthorities(),(scope,authorities)->{
Set<String> scopeSet = (Set<String>)scope;
Set<String> cloneSet = scopeSet.stream().map(String::new).collect(Collectors.toSet());
Collection<SimpleGrantedAuthority> simpleGrantedAuthorities = ( Collection<SimpleGrantedAuthority>)authorities;
simpleGrantedAuthorities.stream().forEach(simpleGrantedAuthority -> {
if(!cloneSet.contains(simpleGrantedAuthority.getAuthority())){
cloneSet.add(simpleGrantedAuthority.getAuthority());
}
});
return cloneSet;
});
});
}
//给 AccessToken 添加签名信息
AccessTokenUtils.signAccessToken(claims,accessTokenRsaPrivateKey);
} else if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
// Customize headers/claims for id_token
claims.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()));
StandardSessionIdGenerator standardSessionIdGenerator = new StandardSessionIdGenerator();
claims.claim("sid", standardSessionIdGenerator.generateSessionId());
}
};
}
3.2. 资源服务改造
3.2.1. 网关服务改造
网关服务,移除 spring-boot-starter-oauth2-resource-server 依赖,添加 mall-component-oauth2 依赖,在 AuthorizationClientConfig 中对 WebFluxAuthorizationManager 鉴权管理进行注入并使用即可,如下所示。
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class AuthorizationClientConfig {
@Bean
WebFluxAuthorizationManager webFluxAuthorizationManager(){
return new WebFluxAuthorizationManager();
}
@Bean
public SecurityWebFilterChain authorizationClientSecurityFilterChain(ServerHttpSecurity http) throws Exception {
//uri放行
String[] ignoreUrls = new String[]{"/oauth2/**","/*.html","/favicon.ico","/webjars/**","/v3/api-docs/swagger-config","/*/v3/api-docs**"};
//禁用csrf与cors
http.csrf().disable();
http.cors().disable();
//客户端设置
http
.authorizeExchange(authorize ->
authorize.pathMatchers(ignoreUrls).permitAll()
//.anyExchange().authenticated()
// 鉴权管理器配置
.anyExchange().access(webFluxAuthorizationManager())
);
return http.build();
}
}
3.2.2. 应用服务改造
账户服务、商品服务、订单服务,移除 spring-boot-starter-oauth2-resource-server 依赖,添加 mall-component-oauth2 依赖,在 AuthorizationClientConfig 中对 WebMvcAuthorizationManager 鉴权管理进行注入并使用即可,如下所示。
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationClientConfig {
@Bean
WebMvcAuthorizationManager webMvcAuthorizationManager(){
return new WebMvcAuthorizationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
//uri放行
String[] ignoreUrls = new String[]{"/*.html","/favicon.ico","/webjars/**","/*/v3/api-docs**","/v3/api-docs/**"};
http.authorizeHttpRequests(authorize ->
authorize.requestMatchers(ignoreUrls).permitAll()
// 鉴权管理器配置
.anyRequest().access(webMvcAuthorizationManager())
);
return http.build();
}
}
4. 流程测试
启动认证服务、网关服务、账户服务、商品服务、订单服务,对前后端交互流程进行测试。以账户服务为例,我们来测试一下”根据 id 查询账户信息“接口。
4.1. 检查 access_token
使用密码模式获取 token 如下。
使用 JWT 工具对 access_token 进行解析,结果如下。
可以看到,access_token 已经加入了 rdm、sign 字段。
4.2. 不传 access_token
4.2.1. 通过网关访问
在 postman 中输入地址:http://localhost:7020/web/v1/account/queryById/1693333896005545986,不传 token,向网关服务发起接口请求,结果如下。
可以看到,返回 401 状态。
4.2.2. 直接访问应用
在 postman 中输入地址:http://localhost:7030/web/v1/account/queryById/1693333896005545986,不传 token,向账户服务发起接口请求,结果如下。
可以看到,返回的是 403 状态,也是拒绝访问。
4.3. 传入 access_token
4.3.1. 通过网关访问
在 postman 中输入地址:http://localhost:7020/web/v1/account/queryById/1693333896005545986,传入 token,向网关服务发起接口请求,结果如下。
可以看到,正常返回接口数据。
4.3.2. 直接访问应用
在 postman 中输入地址:http://localhost:7030/web/v1/account/queryById/1693333896005545986,传入 token,向账户服务发起接口请求,结果如下。
可以看到,同样正常返回接口数据。
5. 总结
本篇先介绍了 Token 本地认证的算法和流程。接着介绍了算法工具类和鉴权管理器的组件封装。然后介绍了认证服务、资源服务的改造。最后对前端应用、认证服务器、资源服务器的交互流程进行了测试验证。
基础篇项目代码:链接地址