参考文档 spring-authorization-server官网 【版本1.2.2】、 JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants规范。
针对spring-authorization-server官网在Core Model / Components部分提到的RegisteredClient对象中涉及到clientAuthenticationMethods(客户端身份验证方法)属性支持的几种身份验证方法:【 client_secret_basic, client_secret_post, private_key_jwt, client_secret_jwt, and none (public clients)】。此次单独针对private_key_jwt这种方式来了解是如何达到客户端验证目的的。
介绍
通过官网以及参考其他网站,了解到基于private_key_jwt的身份验证方法的处理流程是客户端拥有自己的密钥对(公钥和私钥)。私钥由客户端自己保管,而公钥可以通过端点的方式向任何人公开。客户端使用算法(例如:RSA)通过私钥对客户端相关信息进行签名然后生成JWT(JSON Web Token),然后基于JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants 规范中提到Using JWTs for Client Authentication用法向授权服务器提交相关数据,例如:
POST /token.oauth2 HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=n0esc3NRze7LTCu7iYzS6a5acc3f0ogp4&
client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3A
client-assertion-type%3Ajwt-bearer&
client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0.
eyJpc3Mi[...omitted for brevity...].
cC4hiUPo[...omitted for brevity...]
参数 | 描述 |
---|---|
grant_type | authorization_code (固定值) |
client_assertion_type | urn:ietf:params:oauth:client-assertion-type:jwt-bearer(固定值) |
code | 授权码,需要访问授权服务器的授权码端点(/oauth2/authorize)来获取 |
client_assertion | 上面提到的通过私钥对客户端相关信息进行签名然后生成JWT |
除了上面的数据外,在spring-authorization-server中还需要提供其他的数据:
参数 | 描述 |
---|---|
client_id | 客户端标识符 |
redirect_uri | 客户端信息中已注册重定向URI(对应RegisteredClient对象的redirectUris),例如接收授权码的重定向的URI |
授权服务器在接收到上面的数据后主要会验证client_assertion(对应客户端生成的JWT),具体体现是请求经过授权服务器的过滤器链,被OAuth2ClientAuthenticationFilter拦截到,然后把请求的信息由JwtClientAssertionAuthenticationConverter转换为OAuth2ClientAuthenticationToken对象,然后交由JwtClientAssertionAuthenticationProvider来验证。验证方式是,授权服务器会根据client_id找到已在授权服务器注册的客户端信息,然后根据客户端注册时提供的jwkSetUrl【settings.client.jwk-set-url】拿到客户端暴露的公钥JWK(JSON Web Key)集端点地址,然后请求此地址拿到对应的公钥集。然后通过公钥对client_assertion进行验证签名,最后来达到客户端身份验证的目的。
项目搭建
为了验证上面的流程,我们需要创建两个SpringBoot项目,一个客户端,一个授权服务器。
客户端
创建SpringBoot项目,可参考我创建的项目client-endpoints。
然后我们需要为客户端生成密钥对,在cmd窗口执行如下命令:
keytool -genkeypair -keystore my.jks -storepass 123456 -alias my-key -keyalg RSA -keysize 2048 -sigalg SHA256withRSA -validity 365 -v
输入完命令后,窗口会让我们输入一些信息,例如:单位、组织等等,可随便填入一些信息,最后输入确认Y,会在对应的磁盘下生成一个my.jks文件,把文件赋值到项目的src/main/resources文件夹下。
我们需要加载自定义密钥对,如下:
public static KeyPair loadRsaKey() {
KeyPair keyPair;
try {
ClassPathResource resource = new ClassPathResource("my.jks");
KeyStore ks = KeyStore.getInstance("jks");
ks.load(resource.getInputStream(), "123456".toCharArray());
PrivateKey priKey = (PrivateKey) ks.getKey("my-key", "123456".toCharArray());
PublicKey pubKey = ks.getCertificate("my-key").getPublicKey();
keyPair = new KeyPair(pubKey, priKey);
} catch (Exception e) {
throw new IllegalStateException(e);
}
return keyPair;
}
创建两个端点,一个用来暴露公钥集,一个用来接收授权码,分别对应JwksController和Oauth2CodeController。
@RestController
public class JwksController {
@GetMapping("client/jwks")
public String jwkSet() {
KeyPair keyPair = JwtUtil.loadRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return jwkSet.toString();
}
}
@RestController
public class Oauth2CodeController {
/**
* 获取授权码
*
* @return 授权码
*/
@GetMapping("client/oauth2/code")
public String getCode(@RequestParam("code") String code) {
return code;
}
}
然后我们把客户端项目启动,访问地址http://127.0.0.1:8089/client/jwks(我的项目端口为8089)会返回公钥(JWK)集,如下:
{"keys":[{"kty":"RSA","e":"AQAB","kid":"12cc5247-86f6-4bdd-a7f3-1e77c8e62acb","n":"9HD79HaQ8DXOkNLZ7N-gcn8ruHuULDa6yUNYDCYFIwZXSdSYzRUMZGijkRUJXBHCRbDsa2GsleGLI4O7OWQCYmvNEWcrvy5zSDs-GJn5w7JjTqvEUrKVlFgDu8ASOF0B3YP0AYfzUjlZ7rgAVMKESZUxoAImRjj7mjj9TkTAGeBWRnlbbEYplSenlKbu3bLlVKb9UdUIH4IhCs6rTPkMf4UJLX4eWYJR6SPXFmLnKJF8kvGThTKtgU8R90O0jBrRoa8I_mXLaa1zV8tbMpYeOefmkX2RAaRU_yZRFoN2MUFmPE0BSQq0AJ08nxR8FHJSvw40-eVbhi47Ol10kxCbbw"}]}
授权服务器
创建SpringBoot项目,可参考我创建的项目protocol-endpoints-demo。当然你也可参考spring-authorization-server官网提供的Getting Started来搭建。唯一的区别是我们要自定义RegisteredClientRepository,如下:
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oidc-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 返回授权码的回调客户端地址
.redirectUri("http://127.0.0.1:8089/client/oauth2/code")
.scope("client.create")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true)
.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256)
// 配置公钥获取地址,需要获取公钥集来验证jwt(验签)
.jwkSetUrl("http://127.0.0.1:8089/client/jwks")
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(4l)).build())
.build();
return new InMemoryRegisteredClientRepository(oidcClient);
}
这里有几个属性我们需要注意下,我们需要设置对应private_key_jwt的clientAuthenticationMethod,redirectUri中指定接收授权码的重定向URI,还有jwkSetUrl来指定客户端暴露公钥集的端点。
启动项目,此项目我的端口为:6004。
生成JWK
此时我们需要根据授权服务器已注册的客户端信息生成JWK,如下:
public static JWTClaimsSet getJWTClaimsSet() {
String clientId = "oidc-client";
List<String> aud = new ArrayList<>();
aud.add("http://127.0.0.1:6004");
aud.add("http://127.0.0.1:6004/oauth2/token");
aud.add("http://127.0.0.1:6004/oauth2/introspect");
aud.add("http://127.0.0.1:6004/oauth2/revoke");
// 前四个属性是必须的(iss、sub、aud、exp),参考JwtClientAssertionDecoderFactory#defaultJwtValidatorFactory
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
// 发行者:固定clientId
.issuer(clientId)
// 主体:固定clientId
.subject(clientId)
// 授权服务器的相关地址
.audience(aud)
// 过期时间 24h
.expirationTime(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24))
// 访问时间
.issueTime(new Date())
// 范围
.claim("scope", new String[]{"client.create"})
.claim("jwk-set-url", "http://127.0.0.1:8089/client/jwks")
.build();
return claimsSet;
}
这里需要注意下:issuer、subject、audience、expirationTime这四个属性是必须的,而且audience只能指定对应授权服务器对应的端点,如上所示,否则授权服务器验证不通过。可具体可参考JwtClientAssertionDecoderFactory#defaultJwtValidatorFactory来了解授权服务器是如何对上面四个属性进行规则校验的。
/**
* 使用RSA算法加签生成JWT(JSON WEB TOKEN)
*/
public static String rsaSign(JWTClaimsSet claimsSet) throws JOSEException {
KeyPair keyPair = loadRsaKey();
RSASSASigner signer = new RSASSASigner(keyPair.getPrivate());
SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet);
signedJWT.sign(signer);
String token = signedJWT.serialize();
return token;
}
通过上面的rsaSign方法返回的值就是客户端所需要的JWK,如下:
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJvaWRjLWNsaWVudCIsImF1ZCI6WyJodHRwOi8vMTI3LjAuMC4xOjYwMDQiLCJodHRwOi8vMTI3LjAuMC4xOjYwMDQvb2F1dGgyL3Rva2VuIiwiaHR0cDovLzEyNy4wLjAuMTo2MDA0L29hdXRoMi9pbnRyb3NwZWN0IiwiaHR0cDovLzEyNy4wLjAuMTo2MDA0L29hdXRoMi9yZXZva2UiXSwiandrLXNldC11cmwiOiJodHRwOi8vMTI3LjAuMC4xOjgwODkvY2xpZW50L2p3a3MiLCJzY29wZSI6WyJjbGllbnQuY3JlYXRlIl0sImlzcyI6Im9pZGMtY2xpZW50IiwiZXhwIjoxNzEwNDc0MDYzLCJpYXQiOjE3MTAzODc2NjN9.Epx4rjjHfs-pwLWYfdukXAm_C-TQaCT9mBlMDN6RLuJJFDsBsluSXNda5-g8i01-rEhsKfvqf4y7aqgIl_YHRoRmYVgZDepvpsoqJ1AOgKgOZOQGNTpQGxV4eQZk-x3ZOGjhHqNdSp3cxjERE4aFcfp0SYYEen-_hEU6MN6AUJS1CauLPnJADTSlRer0A4qfeqMcAvEqF73AhUgcnHjVLqNjBdVhIkzc365dUXlVID51sZP4jfKSorz-LEr1Sv9iIw5ooKiSgRYCDP0-3e0hF97UOrUojO2FI_ObH4q2FpjaE5GjI3j6Gt-C6MyHoY9L0Rm-DAuYGzhG4jtaF9tP2A
流程演示
获取授权码
请求地址:
http://127.0.0.1:6004/oauth2/authorize?response_type=code&client_id=oidc-client&scope=client.create&redirect_uri=http%3A%2F%2F127.0.0.1%3A8089%2Fclient%2Foauth2%2Fcode
此时会跳转到登录端点,输入用户名:user,密码:password,进行登录。
授权码会追加在链接http://127.0.0.1:8089/client/oauth2/code后,code参数为授权码。
客户端身份验证
请求端点:http://127.0.0.1:6004/oauth2/token。我是通过Fetch API方式在浏览器控制台请求的:
fetch("http://127.0.0.1:6004/oauth2/token", {"headers": {"content-type": "application/x-www-form-urlencoded; charset=UTF-8",},"method": "POST","body":"grant_type=authorization_code&code=SXv7dM9svhSWyvKuIj2ED_TjwP3ZycOYVPS1eZrZ4tLnMvu9PSkxfcyZD2DAw6CaUX0tsqIrHCEZCCEYOW_UpOAJ73wzekFhIz2InZFIF1jox4SeBH10gUYXzGLne_QI&redirect_uri=http%3A%2F%2F127.0.0.1%3A8089%2Fclient%2Foauth2%2Fcode&client_id=oidc-client&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJvaWRjLWNsaWVudCIsImF1ZCI6WyJodHRwOi8vMTI3LjAuMC4xOjYwMDQiLCJodHRwOi8vMTI3LjAuMC4xOjYwMDQvb2F1dGgyL3Rva2VuIiwiaHR0cDovLzEyNy4wLjAuMTo2MDA0L29hdXRoMi9pbnRyb3NwZWN0IiwiaHR0cDovLzEyNy4wLjAuMTo2MDA0L29hdXRoMi9yZXZva2UiXSwiandrLXNldC11cmwiOiJodHRwOi8vMTI3LjAuMC4xOjgwODkvY2xpZW50L2p3a3MiLCJzY29wZSI6WyJjbGllbnQuY3JlYXRlIl0sImlzcyI6Im9pZGMtY2xpZW50IiwiZXhwIjoxNzEwNDc0MDYzLCJpYXQiOjE3MTAzODc2NjN9.Epx4rjjHfs-pwLWYfdukXAm_C-TQaCT9mBlMDN6RLuJJFDsBsluSXNda5-g8i01-rEhsKfvqf4y7aqgIl_YHRoRmYVgZDepvpsoqJ1AOgKgOZOQGNTpQGxV4eQZk-x3ZOGjhHqNdSp3cxjERE4aFcfp0SYYEen-_hEU6MN6AUJS1CauLPnJADTSlRer0A4qfeqMcAvEqF73AhUgcnHjVLqNjBdVhIkzc365dUXlVID51sZP4jfKSorz-LEr1Sv9iIw5ooKiSgRYCDP0-3e0hF97UOrUojO2FI_ObH4q2FpjaE5GjI3j6Gt-C6MyHoY9L0Rm-DAuYGzhG4jtaF9tP2A"}).then(res=>res.json()).then(json=>console.log(json));
JWT验证通过,然后在经过授权服务器一系列过滤器的处理最后返回访问token等相关数据,如上图标红所示。