spring-cloud / spring-cloud-config

External configuration (server and client) for Spring Cloud
Apache License 2.0
1.95k stars 1.29k forks source link

Provide support for OAuth2 in Spring Config Client. #2348

Open cobar79 opened 10 months ago

cobar79 commented 10 months ago

I would like to implement OAuth2 resource server in the Spring Config Server and require JWT for all configuration requests.

Is your feature request related to a problem? Please describe.

I can't seem to find any method to implement a Spring Security OAuth2 Client Provider to the Spring Cloud Client call. Overriding ConfigServicePropertySourceLocator

177 and Adding Generic "Authorization" support for Config Client do not appear to work with current import connection framework "optional:configserver:http://${env.config.hostname}:${env.config.port}/config-server"

The call to the Configuration Server is made before the ConfigServicePropertySourceLocator is instantiated.

Describe the solution you'd like I would like to use the same implementation used for Machine to Machine OAuth2 communication where by the Spring Security handles obtaining and refreshing the Bearer Token to be passed to the Spring Config Server calls on startup. Preferably a WebClient over RestTemplate solution.

    @Bean
    ReactiveClientRegistrationRepository clientRegistrations(
        @Value("${spring.security.oauth2.client.provider.keycloak-client.token-uri}") String tokenUri,
        @Value("${spring.security.oauth2.client.registration.keycloak-client.client-id}") String clientId,
        @Value("${spring.security.oauth2.client.registration.keycloak-client.client-secret}") String clientSecret,
        @Value("${spring.security.oauth2.client.registration.keycloak-client.scope}") String scope,
        @Value("${spring.security.oauth2.client.registration.keycloak-client.authorization-grant-type}") String authorizationGrantType
    ) {

        Collection<String> scopeList = Arrays.asList(scope.split(","));

        ClientRegistration registration = ClientRegistration
            .withRegistrationId(registrationId)
            .tokenUri(tokenUri)
            .clientId(clientId)
            .clientSecret(clientSecret)
            .scope(scopeList)
            .authorizationGrantType(new AuthorizationGrantType(authorizationGrantType))
            .build();
        return new InMemoryReactiveClientRegistrationRepository(registration);
    }

    @Bean(value = "authWebClient")
    WebClient authWebClient(ReactiveClientRegistrationRepository clientRegistrations) {
        InMemoryReactiveOAuth2AuthorizedClientService clientService = new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrations);
        AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrations, clientService);
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth.setDefaultClientRegistrationId(registrationId);
        return WebClient.builder()
            .filter(oauth)
            .baseUrl(baseUrl)
            .build();
    }

Describe alternatives you've considered A method to intercept the Spring Config Server rest call, call the IDP and include the Bearer Token manually, there by bypassing the current RestTemplate and any Basic Authentication.

Additional context Spring Boot 3.1.2, Spring Security 6.1.2 Keycloak OAuth2 implementation

Configuration Server Security

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
        MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector);
        http.anonymous(AbstractHttpConfigurer::disable);
        http.csrf(AbstractHttpConfigurer::disable);
        http.authorizeHttpRequests(authorize -> authorize
            .requestMatchers(mvcMatcherBuilder.pattern("/" + appName + "/actuator/**")).hasRole("actuator")
            .requestMatchers(mvcMatcherBuilder.pattern("/" + appName + "/**")).hasRole("m2m")
            //Permit webhook refresh access without authorization
            .requestMatchers(mvcMatcherBuilder.pattern("/" + appName + "/monitor")).permitAll()
            .anyRequest()
            .authenticated()
        );

        http.oauth2ResourceServer(authorize ->
            authorize.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtTokenConverter))
                .authenticationEntryPoint(customAuthenticationEntryPoint)
        );

        http.sessionManagement(session -> session
            .sessionCreationPolicy(stateless? SessionCreationPolicy.STATELESS: SessionCreationPolicy.IF_REQUIRED)
        );
        return http.build();
    }
ryanjbaxter commented 10 months ago

The commercial offer of Spring Cloud, Spring Cloud Services, uses OAuth2 to authorize the client to use the config server. You can find the code for how they do that here spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client

I suppose you could do something similar.

cobar79 commented 10 months ago

spring-cloud-services-starters looks to be a heavy port.

I was looking for a 3-5 story point way of overwriting the current RestTemplate logic.

Unfortunately, attempts of old school filter/interceptors did not work as the call was made before the filter/interceptors where instantiated. I guess I will look at extending the existing framework to override the basic authorization.

cobar79 commented 10 months ago

@ryanjbaxter Should I just close this? It was meant as a feature request. However, it doesn't look it is being considered.

ryanjbaxter commented 10 months ago

I am not saying you need to port exactly what spring-cloud-services did I am just showing that it is possible to authorize the client before the request to the server is made, specifically I think this class is where that is done.

https://github.com/pivotal-cf/spring-cloud-services-starters/blob/29b7961ec57ec918110b669fb2409d55aacc8974/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/ConfigResourceClientAutoConfiguration.java#L39

@kvmw am I correct?

kvmw commented 10 months ago

@ryanjbaxter @cobar79 , You need to configure the RestTemplate in 2 different places :

  1. Firstly, you need a BootstrapRegistryInitializer. Since the call to config-server is done during the application startup you need to configure the RestTemplate before startup, during the bootstrapping using a BootstrapRegistryInitializer. check ConfigClientOAuth2BootstrapRegistryInitializer in SCS Starters, for example. This has been mentioned in the Spring Cloud Config Docs too.

  2. Also, if your client application consumes plain text or binary resources, you need to configure a ConfigResouceClient as well. check ConfigResourceClientAutoConfiguration in SCS Starters, for example. You can ignore this configuration if you don't need to consume such resources.

The ConfigResourceClientAutoConfiguration which was mentioned above by @ryanjbaxter, is for the client applications with legacy processing enabled. This is the alternative for BootstrapRegistryInitializer and you can ignore it if your application is developed recently and does not need legacy processing.

@ryanjbaxter , It would be nice if ConfigServerConfigDataLoader in Spring Cloud Config Client would provide an easier way to inject the RestTemplate. I don't know if alternative approach is even possible but the current one (using BootstrapRegistryInitializer) is not the most straightforward and intuitive one.

ryanjbaxter commented 10 months ago

I am not sure there would be an easier way because we are so early in the startup of the Boot application at the point we are loading configuration.

spring-cloud-issues commented 10 months ago

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

cobar79 commented 10 months ago

@ryanjbaxter @kvmw

Great discussion. However, this was just meant to be a feature request. As a Spring Developer, I have a requirement to lock down the config server, thus making it an OAuth2 resource server. Therefore, this requires the config server clients to authenticate with a Bearer token from the IDP. I would think this would be very common occurrence in the Spring Community?

As a proof of concept I cloned this project and with small changes to ConfigClientRequestTemplateFactory and ConfigClientProperties I was able to obtain a Bearer token from IDP and append it to the config server endpoint call.

We are investigating the commercial Cloud-Services-for-VMware-Tanzu product cost and portability to AWS. However, it would be more convenient and easier to maintain the additional feature to this project than it will be to implement the commercial version.

kvmw commented 10 months ago

@cobar79 As I mentioned above the call to Config Server happens during bootstrap before any of those beans you mentioned above are even initialised. So changing those beans doesn't help.

Have a look at this code once again: https://github.com/spring-cloud/spring-cloud-config/blob/main/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLoader.java#L262

cobar79 commented 10 months ago

@kvmw Again, just proof of concept but it works:

The method below creates a Rest Template. I simply detect an OAuth2 property and use the Rest Template to get the Token from IDP and add it to the headers map which is added to the GenericRequestHeaderInterceptor. Later when the Config Server call is made, it uses the GenericRequestHeaderInterceptor adds the Bearer Token header in included in the call.

ConfigClientRequestTemplateFactory.create

                String tokenUri = properties.getTokenUri();
        if (tokenUri != null) {
            String token = getOAuthToken(template, tokenUri);
            headers.put(AUTHORIZATION, "Bearer " + token);
            properties.setHeaders(headers);
        }

injected just before adding the inteceptor

        if (!headers.isEmpty()) {
            template.setInterceptors(Arrays.asList(new GenericRequestHeaderInterceptor(headers)));
        }
kvmw commented 10 months ago

@cobar79
I see what you mean now. I was thinking your poc work is in your code rather than spring-cloud-config code. I think that would work but not sure if the spring-config team would want that.

@ryanjbaxter would you consider a pull-request to support this requirement?

ryanjbaxter commented 9 months ago

Absolutely!

cobar79 commented 9 months ago

Thanks @ryanjbaxter I will look over the guidelines and supporting documentation. I may need guidance moving from POC to PR since this will be my first open source contribution. Here are some areas or questions I have.

1) I replicated the spring.security.oauth2.client properties to fit into the spring.cloud.config properties. I wasn't crazy about duplicating the properties but given the impact of loading multiple properties and complexity of OAuth2 properties I think I would stick with updating ConfigClientProperties with OAuth2 properties. Even the commercial version flattened the OAuth2 properties into a single client registration.

spring:
  cloud:
    config:
        token-uri: ${oauth.scheme}://localhost:${env.idp.port}/realms/${oauth.realm}/protocol/openid-connect/token
        client-id:

I was planning on leaving the basic authentication properties as is and using username/password for both oauth2 and basic authentication. Thoughts?

spring:
  cloud:
    config:
      username:
      password:
      token:

2) Client/User credential encryption: Most cyber teams won't allow plain text credentials in property files. That leaves environment/system properties or encrypting the credentials. I assume the Spring team would just want to go with environment/system properties for this? In the POC, I actually incorporated a Jayspt SimplePBEStringEncryptor. I passed in the algorithm and iterations via the properties and set the encryption password as an environment variable.

spring:
  cloud:
    config:
      token-uri: 
      client-id: ENC(blablabla)
      client-secret: ENC(yaddaYaddaYadda)
      username: ENC()
      password: ENC()
      encryptor-algorithm: PBEWITHHMACSHA512ANDAES_256
      encryptor-iterations: 1000
        <dependency>
            <groupId>com.github.ulisesbocchio</groupId>
            <artifactId>jasypt-spring-boot</artifactId>
            <version>3.0.5</version>
        </dependency>
ryanjbaxter commented 9 months ago

We love first time contributors ❤️

  1. That sounds fine to me

  2. We have the ability to encrypt and decrypt properties as part of Spring Cloud Config. Have you looked into leveraging that functionality?

https://docs.spring.io/spring-cloud-config/docs/current/reference/html/#_encryption_and_decryption

cobar79 commented 9 months ago

Thanks Ryan.

I have it mostly stubbed out with JCE. I am having issues with JCE cypher properties in the Config Client application. I can't seem to find any documentation on using JCE in a normal Boot application. Do you happen to know where I can find documentation?

Removed old Jasypt encryption and attempted to try simple Symmetric encryption first.

    Property: spring.cloud.config.client-secret
    Value: "{cipher}cdac3c554d3f6fbdc6f2f553a7b3b3e1b64b1fbb3b1c8fa5b2766be4dafc0b6a6ca38e13442787f5b779dbbd3634001f7a89d030ad8f0363c61a7719d425ef21"
    Origin: URL [file:config/application-local.yml] - 78:22
    Reason: java.lang.UnsupportedOperationException: No decryption for FailsafeTextEncryptor. Did you configure the keystore correctly?

This seems to say you can't do Symmetric Encryption, but thought I would ask.

cobar79 commented 9 months ago

@ryanjbaxter Ready for PR. How do I obtain access to push feature branch and create PR?

I went with Jasypt encryption since I couldn't find supporting documentation of JCE support for the client boot application. See comment above.

cobar79 commented 9 months ago

@kvmw Can you help me with getting access to push my branch and create a PR?

kvmw commented 9 months ago

@cobar79 I don't have admin access to the repo. You need to fork the repo and submit the PR from your fork.

ryanjbaxter commented 9 months ago

You can configure encryption using symetric or asymmetric encryption https://docs.spring.io/spring-cloud-config/docs/4.0.4/reference/html/#_key_management

Ultimately that shouldn't really matter I believe since the user will configure encryption however they want to encrypt the secret right?

As far as the PR goes, fork the repo, create a branch from the 4.0.x branch, then push that branch to your forked repo and create a PR from that branch against the 4.0.x branch in this repo on GitHub.

cobar79 commented 9 months ago

@ryanjbaxter Since the config server logic is way before spring boot sequence, there is no encryption bean created yet. So the properties loaded in the ConfigClientProperties were encrypted.

I can try the JCE again, but I had issues with the cipher in the properties. It failed with the cipher unquoted for yaml syntax and failed for "No decryption for FailsafeTextEncryptor. Did you configure the keystore correctly?" when quoted.

I went with Jayspt since it is the most common practice for encrypting properties in Spring applications.

PR

DasAmpharos commented 6 months ago

@ryanjbaxter do you have any idea on the timeline for this feature? My team is really interested in this feature and we are trying to determine if we should wait for official support or implement a custom solution.

ryanjbaxter commented 6 months ago

It won't be for a while since we need to wait for a major release of Spring Cloud where we can introduce major changes like this.