OAuth2协议起来越普及,大多数企业都有自己的一套单点登录系统,通常都会支持OAuth协议,但这个单点登录系统通常会在OAuth标准协议上多多少少会有改造,我们在企业内部开发一个应用服务,需要对接单点登录SSO,只要支持OAuth协议,我们就可以使用spring-boot-starter-oauth2-client
组件进行对接,如果是标准的OAuth2协议,基本上通过配置就能完成对接,如果有定制改造和适配,就会有一定的门槛,本文给大家展示如何在spring-boot-starter-oauth2-client
基础上进行适配企业自己的SSO系统。
做为OAuth2协议的客户端,通常既需要跳转SSO登录,也需要通过SSO校验token,因此除了需要引入spring-boot-starter-oauth2-client
,还需要引入spring-boot-starter-oauth2-resource-server
<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: 443
context-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
查看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
类是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
}
@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();
}
查看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;
}
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);
}
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);
});
}
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
类上加上@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);
...
}
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()
将使用 OAuth2
(或 OIDC
)对用户进行身份验证,使用来自 JWT
或 userInfo
端点的信息填充 Spring
的 Principal
。oauth2Client()
不会对用户进行身份验证,但会向 OAuth2 授权服务器寻求它需要访问的资源(范围)的许可。oauth2Client()
您仍然需要对用户进行身份验证,例如通过formLogin()
.
原因: 在Consent required
页面没有任何勾选授权
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);
}
如果需要自定义获取权限authorities
,就创建一个Bean
,重写loadUser