【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: 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_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行,如果RedirectHostlocalhost,会报错

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)对用户进行身份验证,使用来自 JWTuserInfo 端点的信息填充 SpringPrincipaloauth2Client()不会对用户进行身份验证,但会向 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

  • 4
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

太空眼睛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值