背景
OAuth2协议起来越普及,大多数企业都有自己的一套单点登录系统,通常都会支持OAuth协议,但这个单点登录系统通常会在OAuth标准协议上多多少少会有改造,我们在企业内部开发一个应用服务,需要对接单点登录SSO,只要支持OAuth协议,我们就可以使用
spring-boot-starter-oauth2-client
组件进行对接,如果是标准的OAuth2协议,基本上通过配置就能完成对接,如果有定制改造和适配,就会有一定的门槛,本文给大家展示如何在
spring-boot-starter-oauth2-client
基础上进行适配企业自己的SSO系统。
OAuth2 Client端的pom.xml
做为OAuth2协议的客户端,通常既需要跳转SSO登录,也需要通过SSO校验token,因此除了需要引入
spring-boot-starter-oauth2-client
,还需要引入
spring-boot-starter-oauth2-resource-server
- 完整pom依赖如下
<dependencies><!-- spring framework module --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-client</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency><!-- spring framework module end --></dependencies>
配置文件
spring:security:oauth2:client:registration:sso:authorization-uri: https://${sso.host}:${sso.port}/${sso.context-path}/oauth2/authorize
token-uri: https://${sso.host}:${sso.port}/${sso.context-path}/oauth2/getToken
user-info-uri: https://${sso.host}:${sso.port}/${sso.context-path}/oauth2/getUserInfo
user-info-authentication-method: GET
user-name-attribute: loginName
resourceserver:opaqueToken:client-id: ${sso.client-id}client-secret: ${sso.client-secret}introspection-uri: https://${sso.host}:${sso.port}/${sso.context-path}/oauth2/checkTokenValid
sso:registration-id: sso
host: sso.xxx.com
port:443context-path: sso
client-id: demo-client-id
client-secret: demo-client-secret
logout-path: /sso/logout
如果是标准的OAuth2协议对接,上面的配置就可以满足需求了,接下来重点讲解几个关键的定制开发
关键逻辑介绍
security.oauth2.client
开头的配置项可以参考OAuth2ClientProperties
这个类OAuth2
协议响应的标准参数字段可以参考org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames
这个类sendRedirectForAuthorization
重定向到authorization-uri
,并且会携带response_type
、client_id
、scope
、state
、redirect_uri
、nonce
参数OAuth2LoginAuthenticationFilte
和OAuth2LoginAuthenticationProvider
-OAuth2LoginAuthenticationFilter
会对回调地址(携带了code
和state
)进行处理,调用AuthemticationManager
进行认证- 背后OAuth2LoginAuthenticationProvider
会进行连续token-uri
、user-info-uri
请求,最后返回完全填充的OAuth2LoginAuthenticationToken
- 缓存跳转登录前的请求
AuthorizationRequestRepository
适配场景1: 认证接口未返回response_type字段
源码分析
查看
org.springframework.security.oauth2.core.endpoint.DefaultMapOAuth2AccessTokenResponseConverter
这个类,在
convert
方法里面,会根据SSO响应的参数构造一个
OAuth2AccessToken
对象,关键源码如下
public OAuth2AccessToken(TokenType tokenType, String tokenValue, Instant issuedAt, Instant expiresAt, Set<String> scopes) {
super(tokenValue, issuedAt, expiresAt);
Assert.notNull(tokenType, "tokenType cannot be null");
this.tokenType = tokenType;
this.scopes = Collections.unmodifiableSet(scopes != null ? scopes : Collections.emptySet());
}
自定义DefaultMapOAuth2AccessTokenResponseConverter
由于
DefaultMapOAuth2AccessTokenResponseConverter
类是
final
,不能继承,所以我们创建一个
DemoMapOAuth2AccessTokenResponseConverter
,然后把
DefaultMapOAuth2AccessTokenResponseConverter
源码
copy
过来,主要修改accessTokenType为空的情况
@Override
public OAuth2AccessTokenResponse convert(Map<String, Object> source) {
String accessToken = getParameterValue(source, OAuth2ParameterNames.ACCESS_TOKEN);
OAuth2AccessToken.TokenType accessTokenType = getAccessTokenType(source);
// 接口没有返回token_type字段,构造OAuth2AccessTokenResponse时会报错
if(null == accessTokenType) {
accessTokenType = OAuth2AccessToken.TokenType.BEARER;
}
long expiresIn = getExpiresIn(source);
Set<String> scopes = getScopes(source);
String refreshToken = getParameterValue(source, OAuth2ParameterNames.REFRESH_TOKEN);
Map<String, Object> additionalParameters = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
if (!TOKEN_RESPONSE_PARAMETER_NAMES.contains(entry.getKey())) {
additionalParameters.put(entry.getKey(), entry.getValue());
}
}
// @formatter:off
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(accessTokenType)
.expiresIn(expiresIn)
.scopes(scopes)
.refreshToken(refreshToken)
.additionalParameters(additionalParameters)
.build();
// @formatter:on
}
让DemoMapOAuth2AccessTokenResponseConverter生效
@Bean
public SecurityFilterChain authorizationClientSecurityFilterChain(HttpSecurity http) throws Exception {
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry authorizationManagerRequestMatcherRegistry = http.authorizeHttpRequests();
// 其它请求都需要认证
authorizationManagerRequestMatcherRegistry.anyRequest().authenticated();
// Session会话管理
SessionManagementConfigurer<HttpSecurity> sessionManagementConfigurer = http.sessionManagement();
sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.ALWAYS);
// OAuth2.0登录配置
OAuth2LoginConfigurer<HttpSecurity> oAuth2LoginConfigurer = http.oauth2Login();
// 自定义获取token请求
oAuth2LoginConfigurer.tokenEndpoint(c->{
DefaultAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
OAuth2AccessTokenResponseHttpMessageConverter oAuth2AccessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
oAuth2AccessTokenResponseHttpMessageConverter.setAccessTokenResponseConverter(new DemoMapOAuth2AccessTokenResponseConverter());
RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), oAuth2AccessTokenResponseHttpMessageConverter));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
authorizationCodeTokenResponseClient.setRestOperations(restTemplate);
c.accessTokenResponseClient(authorizationCodeTokenResponseClient);
});
return http.build();
}
适配场景2: 根据access_token获取用户信息
源码分析
查看
org.springframework.security.oauth2.client.userinfo.OAuth2UserRequestEntityConverter
这个类,
convert
方法是生成的
http
请求调用需要的参数,如果参数名、参数结构与标准
OAuth2
协议不同,那么就需要在这里进行改造,新建一个
DemoOAuth2UserRequestEntityConverter
,继承
OAuth2UserRequestEntityConverter
,主要是改造
Get
请求时的参数构成
@Override
public RequestEntity<?> convert(OAuth2UserRequest userRequest) {
ClientRegistration clientRegistration = userRequest.getClientRegistration();
HttpMethod httpMethod = getHttpMethod(clientRegistration);
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri());
RequestEntity<?> request;
if (HttpMethod.POST.equals(httpMethod)) {
headers.setContentType(DEFAULT_CONTENT_TYPE);
MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
formParameters.add(OAuth2ParameterNames.ACCESS_TOKEN, userRequest.getAccessToken().getTokenValue());
formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
request = new RequestEntity<>(formParameters, headers, httpMethod, uriBuilder.build().toUri());
}
else {
uriBuilder
.queryParam(OAuth2ParameterNames.ACCESS_TOKEN, userRequest.getAccessToken().getTokenValue())
.queryParam(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
request = new RequestEntity<>(httpMethod, uriBuilder.build().toUri());
}
return request;
}
适配场景3: 获取用户信息接口响应改造
源码分析
DefaultOAuth2UserService
这个类的
loadUser
这个方法,是对用户信息进行解析,不同的
SSO
会响应不同的错误码等,
新建一个DemoOAuth2UserService
,继承
DefaultOAuth2UserService
,主要是对接口响应出错时的处理
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
Map<String, Object> userAttributes = response.getBody();
// SSO返回错误处理
if(userAttributes.containsKey("errcode")) {
String errcode = String.valueOf(userAttributes.get("errcode"));
String msg = String.valueOf(userAttributes.get("msg"));
OAuth2Error oauth2Error = null;
switch (errcode) {
// 参数access_token不正确或过期
case "2002":
oauth2Error = new OAuth2Error("2002", "", null);
break;
default:
oauth2Error = new OAuth2Error("sso_unknown_error_code", msg, null);
break;
}
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
让DemoOAuth2UserRequestEntityConverter和DemoOAuth2UserService生效
- 在
DemoOAuth2UserService
构造函数中指定DemoOAuth2UserRequestEntityConverter
public DemoOAuth2UserService() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
this.restOperations = restTemplate;
requestEntityConverter = new DemoOAuth2UserRequestEntityConverter();
setRequestEntityConverter(requestEntityConverter);
}
- 在
DemoOAuth2UserService
为上加上@Service
注解 - 在
Oauth2ClientAutoConfiguration
中引用
@Resource
private OAuth2UserService<OAuth2UserRequest, OAuth2User> demoOAuth2UserService;
- 构造
SecurityFilterChain
中追加
@Bean
public SecurityFilterChain authorizationClientSecurityFilterChain(HttpSecurity http) throws Exception {
...
// 自定义获取用户信息接口
oAuth2LoginConfigurer.userInfoEndpoint(c->{
c.userService(demoOAuth2UserService);
});
}
适配场景4: 校验access_token请求
源码分析
SpringOpaqueTokenIntrospector
这个类是负责发起
introspection-uri
请求,校验
access_token
,返回用户信息,我们新建一个
DemoSpringOpaqueTokenIntrospector
,继承
SpringOpaqueTokenIntrospector
,主要是优化直接调用
access_token
获取用户,获取用户失败相当于
access_token
失效
@Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(iamProperties.getRegistrationId());
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, null, null);
OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, accessToken, Collections.emptyMap());
try {
OAuth2User oAuth2User = demoOAuth2UserService.loadUser(oAuth2UserRequest);
return oAuth2User;
} catch (OAuth2AuthenticationException e) {
throw new BadOpaqueTokenException(e.getMessage(), e);
}
}
让DemoSpringOpaqueTokenIntrospector生效
- 在
DemoSpringOpaqueTokenIntrospector
类上加上@Component
注解 - 创建
DemoOpaqueTokenAuthenticationProvider
, 把OpaqueTokenAuthenticationProvider
源码复制过来,因为OpaqueTokenAuthenticationProvider
是final
@RequiredArgsConstructor
@Component
public class DemoOpaqueTokenAuthenticationProvider implements AuthenticationProvider {
private final OpaqueTokenIntrospector introspector;
private OAuth2AuthenticatedPrincipal getOAuth2AuthenticatedPrincipal(BearerTokenAuthenticationToken bearer) {
try {
return this.introspector.introspect(bearer.getToken());
} catch (BadOpaqueTokenException var3) {
this.logger.debug("Failed to authenticate since token was invalid");
throw new InvalidBearerTokenException(var3.getMessage(), var3);
} catch (OAuth2IntrospectionException var4) {
throw new AuthenticationServiceException(var4.getMessage(), var4);
}
}
}
- 在
Oauth2ClientAutoConfiguration
中引用
@Resource
private IamOpaqueTokenAuthenticationProvider iamOpaqueTokenAuthenticationProvider;
@Bean
public SecurityFilterChain authorizationClientSecurityFilterChain(HttpSecurity http) throws Exception {
...
http.authenticationProvider(iamOpaqueTokenAuthenticationProvider);
...
}
调试过程常见问题记录
认证服务
OAuth2AuthorizationCodeRequestAuthenticationValidator
104行,如果
RedirectHost
是
localhost
,会报错
if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) {
// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-9.7.1
// While redirect URIs using localhost (i.e., "http://localhost:{port}/{path}")
// function similarly to loopback IP redirects described in Section 10.3.3,
// the use of "localhost" is NOT RECOMMENDED.
OAuth2Error error = new OAuth2Error(
OAuth2ErrorCodes.INVALID_REQUEST,
"localhost is not allowed for the redirect_uri (" + requestedRedirectUri + "). " +
"Use the IP literal (127.0.0.1) instead.",
"https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-9.7.1");
throwError(error, OAuth2ParameterNames.REDIRECT_URI,
authorizationCodeRequestAuthentication, registeredClient);
}
oauth2Login 和 oauth2Client 之间有什么区别
oauth2Login()
将使用
OAuth2
(或
OIDC
)对用户进行身份验证,使用来自
JWT
或
userInfo
端点的信息填充
Spring
的
Principal
。
oauth2Client()
不会对用户进行身份验证,但会向 OAuth2 授权服务器寻求它需要访问的资源(范围)的许可。
oauth2Client()
您仍然需要对用户进行身份验证,例如通过
formLogin()
.
[access_denied] OAuth 2.0 Parameter: client_id
原因: 在
Consent required
页面没有任何勾选授权
authorization_request_not_found
资源服务
- BearerTokenAuthenticationFilter
- OAuth2ResourceServerProperties
请求认证服务校验token: OpaqueTokenIntrospector
private Converter<String, RequestEntity<?>> defaultRequestEntityConverter(URI introspectionUri) {
return (token) -> {
HttpHeaders headers = requestHeaders();
MultiValueMap<String, String> body = requestBody(token);
return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri);
};
}
private MultiValueMap<String, String> requestBody(String token) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("token", token);
return body;
}
默认是在body放一个json
{"token":"xxxxxxxx"}
获取Bean,默认是SpringOpaqueTokenIntrospector,可以通过BeanPostProcessor修改requestEntityConverter
OpaqueTokenIntrospector getIntrospector() {
if (this.introspector != null) {
return this.introspector.get();
}
return this.context.getBean(OpaqueTokenIntrospector.class);
}
OAuth2UserService
如果需要自定义获取权限
authorities
,就创建一个
Bean
,重写
loadUser
版权归原作者 太空眼睛 所有, 如有侵权,请联系我们删除。