- 前言
- 一、OAuth2TokenEndpointConfigurer
- 1、关于authenticationProvider和authenticationProviders自定义的注意
- 二、Token的生成
- 1、OAuth2TokenGenerator的初始化
- 1.1、JwtGenerator的初始化
- 1.2、定义JWT的header和claims
- 1.3、claimsOAuth2AccessTokenGenerator的初始化
- 1.4、OAuth2RefreshTokenGenerator的初始化
- 1.4、DelegatingOAuth2TokenGenerator
- 2、小结
- 三、OAuth2TokenEndpointFilter-token端点过滤器
- 1、OAuth2TokenEndpointFilter的类结构
- 2、DelegatingAuthenticationConverter参数提取
- 2.1、授权码模式
- 2.2、刷新token模式
- 2.3、客户端凭据模式
- 四、Token的认证
- 1、token认证的三个方式
- 2、认证成功处理
- 2.1 DefaultOAuth2AccessTokenResponseMapConverter
- 3、小结
- 五、AuthenticationProvider
- 1、OAuth2AuthorizationCodeAuthenticationProvider
- 2、OAuth2RefreshTokenAuthenticationProvider
- 3、 OAuth2ClientCredentialsAuthenticationProvider
- 4、总结流程
- 附OAuth2Authorization样例
首先了解的人肯定知道 这个**/oauth2/token**路径是用来申请token的,那那么他是怎么生效的和怎么配置的呢
一、OAuth2TokenEndpointConfigurerOAuth2TokenEndpointConfigurer用来配置Spring Authorization Server的OAuth2TokenEndpointFilter过滤器,这个过滤器用来处理客户端发来的/oauth2/token(默认)请求。配置项有以下五个:
其中requestMatcher需要通过ProviderSettings自定义,这里并没有自定义入口。
1、关于authenticationProvider和authenticationProviders自定义的注意自定义accessTokenRequestConverter的话,就意味着你修改了默认的配置。而默认的配置是一个委托类,包含了三种策略(参见上图)。如果你的自定义配置没有适配这三种授权方式,将会失去对这三种方式的支持。
一旦你通过OAuth2TokenEndpointConfigurer#authenticationProvider(AuthenticationProvider)方法进行了自定义,默认提供的三种AuthenticationProvider也将自动失效。
OAuth2TokenGenerator是所有类型Token生成器的抽象。 目前有以下几个实现
OAuth2TokenGenerator的配置也是在这里完成的。那么它是从哪里初始化的呢?
OAuth2TokenGenerator的初始化是借助于工具类OAuth2ConfigurerUtils的静态方法getTokenGenerator来生成。源码如下:
这里采用了单例懒加载设计:
1、先从SharedObject中获取,如果有就直接返回了。
2、如果SharedObject中没有就从Spring IoC中找找,再没有就开始初始化一个,初始化的逻辑专门分析,这里你把它当作黑盒,可以不打断你的思路。
3、初始化成功后,再放入SharedObject,下次再拿直接就有了。
上面初始化的步骤
你会发现它初始化的范式和上面OAuth2TokenGenerator差不多。优先从SharedObject中获取,没有就看看Spring IoC中有没有JwtEncoder或者JWKSource,有就能初始化一个JwtGenerator,无论有没有都会返回。
这里有一个很好玩的东西OAuth2TokenCustomizer,这个是干啥的呢,这个是如果你JWT有自定义需求,主要定义JWT的header和claims,就可以定义一个该类型的Spring Bean就可以了。
@Bean
public OAuth2TokenCustomizer jwtTokenCustomizer(){
return context -> {
Authentication principal = context.getPrincipal();
OAuth2Authorization authorization = context.getAuthorization();
Set authorizedScopes = context.getAuthorizedScopes();
Authentication authorizationGrant = context.getAuthorizationGrant();
ProviderContext providerContext = context.getProviderContext();
RegisteredClient registeredClient = context.getRegisteredClient();
// 上面的都可以拿到
// 目的是为了定制jwt 的header 和 claims
JoseHeader.Builder headers = context.getHeaders();
context.getClaims().audience(Arrays.asList("client1","client2"))
.claim("some","any");
};
}
1.3、claimsOAuth2AccessTokenGenerator的初始化
这个OAuth2AccessTokenGenerator其实就是个兜底的,它只能生成不透明令牌,如果JwtGenerator不存在,它就“扶正”了。你如果不喜欢使用JWT,就不要让JwtGenerator初始化。
类似于JwtGenerator的自定义接口OAuth2TokenCustomizer,OAuth2AccessTokenGenerator也有一个自定义接口OAuth2TokenCustomizer,这两个的用法非常类似,就不再赘述了。
1.4、OAuth2RefreshTokenGenerator的初始化最后也会初始化一个刷新Token的生成器OAuth2RefreshTokenGenerator,它是一个不透明令牌。
1.4、DelegatingOAuth2TokenGenerator
最终我们使用的是一个代理委托类,按照策略进行生成。
- 如果存在JwtGenerator,那就连同OAuth2AccessTokenGenerator和JwtGenerator组装一个代理生成器
- 如果没有就只有OAuth2AccessTokenGenerator和JwtGenerator。
对于其它配置都是老面孔了,AuthenticationSuccessHandler和AuthenticationFailureHandler已经多次提及,在OAuth2TokenEndpointFilter中都提供了默认实现,如果不满足需要可自行扩展,这里就不再赘述。下面我们会对OAuth2TokenEndpointFilter进行一个简单的分析和实践
三、OAuth2TokenEndpointFilter-token端点过滤器 1、OAuth2TokenEndpointFilter的类结构该过滤器时在OAuth2TokenEndpointConfigurer中添加
OAuth2TokenEndpointFilter的属性字段,可以很明显的看出来默认的拦截端点
-
OAuth2TokenEndpointFilter的类图
它会根据不同的授权方式AuthorizationGrantType(目前只支持authorization_code(授权码)、refresh_token(刷新)、client_credentials(客户)端三种授权方式)用不同策略提取请求中的授权信息,分别对应三种Token : -
OAuth2AuthorizationCodeAuthenticationToken 授权码模式。
-
OAuth2RefreshTokenAuthenticationToken 刷新Token。
-
OAuth2ClientCredentialsAuthenticationToken 客户端凭据模式。
DelegatingAuthenticationConverter负责维护token请求参数提取的策略,它有三种策略。接下来我们来看看如何从token请求中提取参数,并封装为Authentication。
2.1、授权码模式授权码模式的参数提取由OAuth2AuthorizationCodeAuthenticationConverter负责。它的提取策略是:
- 授权类型必须是授权码模式,也就是必须携带参数grant_type=authorization_code。
- 必须携带有效的code值。
- 必须携带一个redirect_uri。
封装为OAuth2AuthorizationCodeAuthenticationToken:
授权码根据客户端认证方式的不同请求也略有不同 - client_secret_post方式
除了携带grant_type、code、redirect_uri三个参数外,还携带了客户端的client_id和client_secret。
POST /oauth2/token HTTP/1.1 Host: localhost:9000 Content-Type: application/x-www-form-urlencoded Content-Length: 218 grant_type=authorization_code&code=BAVzaAx8TtTTRE-E_CoQJ8-Bu9-APXjZOqVwPm7JxTQox3ko6d3aTM-m_p4aWoeEII6UDg5X9StWvTW5m9_0IvdCD2pJlaHVkLGnwXWzjXRPtU9hJVMvfI8VR-t8UvCL&redirect_uri=http%3A%2F%2F127.0.0.1%3A8082%2Ffoo%2Fbar&client_id=felord&client_secret=secret
- client_secret_basic方式
携带了grant_type、code、redirect_uri三个参数,并且附带了Basic Authorization请求头,规则BASE64.encode(client_id:client_secret)。
POST /oauth2/token HTTP/1.1 Host: localhost:9000 Authorization: Basic ZmVsb3JkOnNlY3JldA== Content-Type: application/x-www-form-urlencoded Content-Length: 218 grant_type=authorization_code&code=BAVzaAx8TtTTRE-E_CoQJ8-Bu9-APXjZOqVwPm7JxTQox3ko6d3aTM-m_p4aWoeEII6UDg5X9StWvTW5m9_0IvdCD2pJlaHVkLGnwXWzjXRPtU9hJVMvfI8VR-t8UvCL&redirect_uri=http%3A%2F%2F127.0.0.1%3A8082%2Ffoo%2Fbar
- private_key_jwt或者client_secret_jwt方式
除了携带grant_type、code、redirect_uri三个参数外,还必须携带client_id、client_assertion_type、assertion-type。这个参见前面对两种OAuth2客户端认证方式的讲解。
POST /oauth2/token HTTP/1.1 Host: localhost:9000 Content-Type: application/x-www-form-urlencoded Content-Length: 1008 grant_type=authorization_code&code=5e7apu0SF720WKmQwVj-wx3lsEDkug1suSnqiXhvB6RwRkOjNrWN43n6DLmKXpcz3RaHG5gSFnvIth97nw-ltKRaDOtSkvl9LfN9YrivhfKG4Ln0Wqe1gmoXvhpyvD45&redirect_uri=http%3A%2F%2F127.0.0.1%3A8082%2Ffoo%2Fbar&client_id=felord&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=eyJ4NXQjUzI1NiI6IlZ4YTJKMllTcnRFSkxPZlI2LU9zMXRPaXJfWXIzS0s2OVI5anJ1cTlzdmciLCJraWQiOiJqb3NlIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJmZWxvcmQiLCJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3Q6OTAwMFwvb2F1dGgyXC90b2tlbiIsImlzcyI6ImZlbG9yZCIsImV4cCI6MTY1MDI2NDg4NCwiaWF0IjoxNjUwMjY0ODI0LCJqdGkiOiI4MDk5ZWI1Mi05ZTI1LTQ2OTgtYjQwMS1iMjc5MmNhNmI0YmIifQ.sjZBUP5-uQzNQo634B-WCL2yYZt5fktfqeCXLS8qqdCTsGQrm07RGVk774h-VImg3CF5-0v2_aA5CfI1ESMNTmNwfyPLzDWpzInDI6_MX-tLta67TXGButvov0SrXjI8NPcw3IIlfQ91TBs5Msx_W-zpL4A_Px0cr8JuCFiAf092E_Yi7nTqJqwuETopIcSnPDeJsw9ReYsaHEbJ-2570IPcJP357t7RDT7JCJJYIruweIMO6fMAGTksz2cOQNmXd-bDcNE5Oaqm8vZ_2vRF4LuJ19WKM_RHQKwIId9yRrTsRd4rjlCHPYj95NZSyfqWeVtUezDMCjnPit3PaY31oQ2.2、刷新token模式
access_token过期后,OAuth2客户端可以携带refresh_token通过**/oauth2/token**去请求一个新的令牌。参数提取由OAuth2RefreshTokenAuthenticationConverter负责。它的提取策略为:
- 必须携带参数grant_type=refresh_token。
- 必须携带有效的refresh_token值。
- 可以携带授权范围值scope,当然这个是可选的。
封装为OAuth2RefreshTokenAuthenticationToken:
刷新token的请求根据不同的OAuth2认证方式也是不一样的,都需要组装携带它们各自特色的客户端认证参数,把上面授权码模式中的grant_type值替换为refresh_token、把code=替换refresh_token=即可,这里就不再一一演示了。
客户端凭据模式由OAuth2ClientCredentialsAuthenticationConverter负责提取参数。它的提取策略为:
- 必须携带参数grant_type=client_credentials。
- 可以携带授权范围值scope,当然这个是可选的。
封装为OAuth2ClientCredentialsAuthenticationToken:
根据Spring Security的范式,封装为AuthenticationToken自然要交给认证管理器AuthenticationManager,由它检索出对应的AuthenticationProvider来认证AuthenticationToken。
根据上一篇的讲解,我们可以知道默认提供了三个AuthenticationProvider:
- OAuth2AuthorizationCodeAuthenticationProvider
- OAuth2RefreshTokenAuthenticationProvider
- OAuth2ClientCredentialsAuthenticationProvider
依次对授权码模式、刷新模式、客户端凭据模式的token请求进行认证处理,基于篇幅的原因,它们的逻辑我将分章节进行讲解。
认证成功后会交给authenticationSuccessHandler进行处理,它的逻辑为:
写入响应的逻辑由DefaultOAuth2AccessTokenResponseMapConverter负责,我觉得有必要学习一下,它是HttpMessageConverter的一个实现。它首先它借助于内部的DefaultMapOAuth2AccessTokenResponseConverter将OAuth2AccessTokenResponse转换为Map
如果想改变返回体的结构,可以通过其setAccessTokenResponseParametersConverter方法改写DefaultMapOAuth2AccessTokenResponseConverter。3、小结
/oauth2/token返回的token只能交给OAuth2客户端使用,不能交给其它User Agent使用,这是非常不安全的。只有注册在授权服务器具有client_id的才是OAuth2客户端。很多同学都混淆了这一点,我们只能通过令牌中继间接的通过已授权的OAuth2客户端来使用token请求资源。
五、AuthenticationProvider上面分析了OAuth2TokenEndpointFilter,它处理了授权码模式、刷新模式、客户端凭据模式三种Token请求的逻辑,正好对应了三个AuthenticationProvider。
这个名字的类有两个,请认准
org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider
-
获取当前验证码对应的OAuth2客户端信息。
-
然后根据携带的授权码code检索出在之前授权码请求中存储的授权信息OAuth2Authorization进行条件判定,以证明本次请求合规。
-
首先,查询的客户端信息和请求的客户端要一致,如果发现冒用也要强行过期掉OAuth2Authorization。
-
其次,redirectUri也要一致。
-
授权码必须在有效期内,否则也不行。
-
组装构造AccessToken的上下文,包含了:
- OAuth2客户端信息RegisteredClient。
- 资源拥有者的认证信息Principal。
- 授权服务器的上下文信息ProviderContext。
- 本次授权的信息OAuth2Authorization。
- 已授权范围。
- 授权类型,自然是授权码方式,来自AuthorizationGrantType。
- 本次Token请求的OAuth2AuthorizationCodeAuthenticationToken。
- Token类型OAuth2TokenType,这里ACCESS_TOKEN。
-
通过Token生成器将步骤⑥的上下文对象转换为OAuth2Token,这里实际是Jwt。
-
依据步骤⑦生成的Jwt初始化OAuth2AccessToken。
-
会把OAuth2AccessToken一起写入OAuth2Authorization,如果令牌是是Jwt风格,会把令牌包含的claims也写进去,后面的刷新Token、ID Token都是这样。
-
刷新Token的生成,步骤非常简单不再赘述,值得一提的是它需要同时满足以下条件:
- OAuth2客户端支持刷新Token。
- OAuth2客户端不是公共客户端(ClientAuthenticationMethod.NONE)。
-
OIDC专属的Id Token的生成,条件是请求必须属于OIDC认证。
-
步骤⑧⑩⑾生成的Token信息都会被存入 OAuth2Authorization,同时本次授权的授权码会被主动作废,OAuth2AuthorizationService会对OAuth2Authorization的持久化进行更新。
最后注册客户端信息RegisteredClient、客户端认证信息OAuth2ClientAuthenticationToken、访问令牌OAuth2AccessToken、刷新令牌(可能为null)OAuth2RefreshToken,以及可能包含OidcIdToken的additionalParameters组成了OAuth2AccessTokenAuthenticationToken返回
2、OAuth2RefreshTokenAuthenticationProviderSpring Authorization Server刷新Token的逻辑由该AuthenticationProvider负责。刷新令牌请求中包含了以下三个重要的东西:
- 刷新令牌,令牌必须有效。
- 授权范围grant_type,必须是refresh_token。
- 授权范围scope,这个是可选的,有一个重要的点你必须
记住:刷新令牌携带的scope必须是没有受过权的,否则将抛出异常;另外如果不携带该参数则刷新后的访问令牌默认范围是上次授权的范围。
这个AuthenticationProvider逻辑上非常简单,依旧是对参数进行了校验,生成token的步骤和OAuth2AuthorizationCodeAuthenticationProvider类似。一个重要的差别就是,如果你的客户端设置TokenSettings开启了重用刷新令牌isReuseRefreshTokens=ture,那么刷新令牌就可以重复使用。
客户端凭据模式的Token签发由该AuthenticationProvider负责。
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication =
(OAuth2ClientCredentialsAuthenticationToken) authentication;
//①获取当前验证码对应的OAuth2客户端信息。
OAuth2ClientAuthenticationToken clientPrincipal =
getAuthenticatedClientElseThrowInvalidClient(clientCredentialsAuthentication);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
// ②判断是不是客户端凭据模式。
if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.CLIENT_CREDENTIALS)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
//③如果请求中携带的授权范围scope不为空,就校验一下有没有超出客户端定义的范围;如果没有超出就按照请求中的去设定;如果请求中不携带scope的话就把客户端定义的全部范围赋予给本次请求。
Set authorizedScopes = registeredClient.getScopes(); // Default to configured scopes
if (!CollectionUtils.isEmpty(clientCredentialsAuthentication.getScopes())) {
for (String requestedScope : clientCredentialsAuthentication.getScopes()) {
if (!registeredClient.getScopes().contains(requestedScope)) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
}
}
authorizedScopes = new LinkedHashSet<>(clientCredentialsAuthentication.getScopes());
}
// ④组装构造AccessToken的上下文,细节参见OAuth2AuthorizationCodeAuthenticationProvider。
// @formatter:off
OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(clientPrincipal)
.providerContext(ProviderContextHolder.getProviderContext())
.authorizedScopes(authorizedScopes)
.tokenType(OAuth2TokenType.ACCESS_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrant(clientCredentialsAuthentication)
.build();
// @formatter:on
// ⑤通过Token生成器将步骤④的上下文对象转换为OAuth2Token,这里通常是Jwt。
OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
if (generatedAccessToken == null) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the access token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
// ⑥依据步骤⑤生成的OAuth2Token初始化OAuth2AccessToken。
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());
// @formatter:off
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
.principalName(clientPrincipal.getName())
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.attribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME, authorizedScopes);
// @formatter:on
if (generatedAccessToken instanceof ClaimAccessor) {
authorizationBuilder.token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));
} else {
authorizationBuilder.accessToken(accessToken);
}
OAuth2Authorization authorization = authorizationBuilder.build();
// ⑦把授权信息持久化。
this.authorizationService.save(authorization);
//⑧生成已授权的信息OAuth2AccessTokenAuthenticationToken。
return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken);
}
- ①获取当前验证码对应的OAuth2客户端信息。
- ②判断是不是客户端凭据模式。
- ③如果请求中携带的授权范围scope不为空,就校验一下有没有超出客户端定义的范围;如果没有超出就按照请求中的去设定;如果请求中不携带scope的话就把客户端定义的全部范围赋予给本次请求。
- ④组装构造AccessToken的上下文,细节参见OAuth2AuthorizationCodeAuthenticationProvider。
- ⑤通过Token生成器将步骤④的上下文对象转换为OAuth2Token,这里通常是Jwt。
- ⑥依据步骤⑤生成的OAuth2Token初始化OAuth2AccessToken。
- ⑦把授权信息持久化。
- ⑧生成已授权的信息OAuth2AccessTokenAuthenticationToken。
大体上,都是通过token端点传递参数,然后根据各自的AuthenticationConverter从请求中提取参数并封装成Authentication,Authentication会按照授权类型(grant_type)的策略从三种AuthenticationProvider中选择一个定向处理,根据不同的逻辑生成最终授权的OAuth2AccessTokenAuthenticationToken。
附OAuth2Authorization样例{
"id": "a1b0c6cb-45d2-46ab-a418-552e025ce719",
"access_token_expires_at": null,
"access_token_issued_at": null,
"access_token_metadata": null,
"access_token_scopes": null,
"access_token_type": null,
"access_token_value": null,
"attributes": "{"@class":"java.util.Collections$UnmodifiableMap","java.security.Principal":{"@class":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","authorities":["java.util.Collections$UnmodifiableRandomAccessList",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"details":{"@class":"org.springframework.security.web.authentication.WebAuthenticationDetails","remoteAddress":"0:0:0:0:0:0:0:1","sessionId":"9BA3FCEFA50ED24B42259BA062C6C4B2"},"authenticated":true,"principal":{"@class":"org.springframework.security.core.userdetails.User","password":null,"username":"felord","authorities":["java.util.Collections$UnmodifiableSet",[{"@class":"org.springframework.security.core.authority.SimpleGrantedAuthority","authority":"ROLE_USER"}]],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"credentials":null},"org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest":{"@class":"org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest","authorizationUri":"http://localhost:9000/oauth2/authorize","authorizationGrantType":{"value":"authorization_code"},"responseType":{"value":"code"},"clientId":"e2fa7e64-249b-46f0-ae1d-797610e88615","redirectUri":"http://127.0.0.1:8082/foo/bar","scopes":["java.util.Collections$UnmodifiableSet",["message.read","message.write"]],"state":"noihJd3-Mc_nEbAx8As0aUEwJraRUHQAbcfQ87F2FtE=","additionalParameters":{"@class":"java.util.Collections$UnmodifiableMap"},"authorizationRequestUri":"http://localhost:9000/oauth2/authorize?response_type=code&client_id=e2fa7e64-249b-46f0-ae1d-797610e88615&scope=message.read%20message.write&state=noihJd3-Mc_nEbAx8As0aUEwJraRUHQAbcfQ87F2FtE%3D&redirect_uri=http://127.0.0.1:8082/foo/bar","attributes":{"@class":"java.util.Collections$UnmodifiableMap"}},"state":"-PS0r6frBqz8QTBlFpQXKdJnBlZG5FMmHQ5Nziwuy0Y="}",
"authorization_code_expires_at": null,
"authorization_code_issued_at": null,
"authorization_code_metadata": null,
"authorization_code_value": null,
"authorization_grant_type": "authorization_code",
"oidc_id_token_claims": null,
"oidc_id_token_expires_at": null,
"oidc_id_token_issued_at": null,
"oidc_id_token_metadata": null,
"oidc_id_token_value": null,
"principal_name": "test",
"refresh_token_expires_at": null,
"refresh_token_issued_at": null,
"refresh_token_metadata": null,
"refresh_token_value": null,
"registered_client_id": "2c9c20818099c695018099cbca030000",
"state": "-PS0r6frBqz8QTBlFpQXKdJnBlZG5FMmHQ5Nziwuy0Y="
}



