0


【Spring Security OAuth2 Client】基本介绍以及定制开发

背景

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_typeclient_idscopestateredirect_urinonce参数
  • OAuth2LoginAuthenticationFilteOAuth2LoginAuthenticationProvider- OAuth2LoginAuthenticationFilter会对回调地址(携带了codestate)进行处理,调用AuthemticationManager进行认证- 背后OAuth2LoginAuthenticationProvider会进行连续token-uriuser-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源码复制过来,因为OpaqueTokenAuthenticationProviderfinal
@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

本文转载自: https://blog.csdn.net/friendlytkyj/article/details/130439868
版权归原作者 太空眼睛 所有, 如有侵权,请联系我们删除。

“【Spring Security OAuth2 Client】基本介绍以及定制开发”的评论:

还没有评论