Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify configuring authorization server using HttpSecurity.with() #1725

Merged
merged 1 commit into from
Oct 3, 2024

Conversation

jgrandja
Copy link
Collaborator

@jgrandja jgrandja commented Sep 19, 2024

This PR attempts to simplify the default configuration for an authorization server using HttpSecurity.with().

We would greatly appreciate if our community users can provide their upvote if you are in favour of:

Issue gh-1707

Copy link
Member

@rwinch rwinch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes look great to me! Thank you 😄

I think the only thing additional item I'd be looking for is to update the documentation.

  • How to use the updated configuration including:
    • A minimal configuration on a single SecurityFilterChain
    • A minimal Multiple FilterChains example
  • Is it still recommended to use a multiple SecurityFilterChain's? If so, why (so users can know the tradeoffs of having a single or multiple)? The demonstration of the minimal configuration that is preferred (single/multiple) should be the first config users see in Getting Started.

@jgrandja
Copy link
Collaborator Author

The following sample configuration is the new proposed way of configuring a dedicated authorization server SecurityFilterChain:

@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
		throws Exception {

	OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = authorizationServer();

	http
		.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
		.with(authorizationServerConfigurer, (authorizationServer) ->
			authorizationServer
				.oidc(Customizer.withDefaults())	// Enable OpenID Connect 1.0
		)
		.authorizeHttpRequests((authorize) ->
			authorize.anyRequest().authenticated()
		)
		// Redirect to the login page when not authenticated from the
		// authorization endpoint
		.exceptionHandling((exceptions) -> exceptions
			.defaultAuthenticationEntryPointFor(
				new LoginUrlAuthenticationEntryPoint("/login"),
				new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
			)
		);

	return http.build();
}

@jgrandja
Copy link
Collaborator Author

jgrandja commented Sep 23, 2024

The following sample configuration is the current way of configuring a dedicated authorization server SecurityFilterChain (as documented in the Getting Started):

@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
		throws Exception {

	OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
	http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
		.oidc(Customizer.withDefaults());	// Enable OpenID Connect 1.0

	http
		// Redirect to the login page when not authenticated from the
		// authorization endpoint
		.exceptionHandling((exceptions) -> exceptions
			.defaultAuthenticationEntryPointFor(
				new LoginUrlAuthenticationEntryPoint("/login"),
				new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
			)
		)
		// Accept access tokens for User Info and/or Client Registration
		.oauth2ResourceServer((resourceServer) -> resourceServer
			.jwt(Customizer.withDefaults()));

	return http.build();
}

@rwinch
Copy link
Member

rwinch commented Sep 23, 2024

@jgrandja I'm confused why you added these comments. In particular, I'd like to make sure that this was not intended to address my feedback since the feedback was requesting the documentation get updated & comments on the this pull request do not resolve that.

@jgrandja
Copy link
Collaborator Author

@rwinch The reason for updating the comment in the main issue and providing a side-by-side comparison of the new vs. existing sample configuration is to ensure we are truly simplifying the configuration based on feedback from our community users.

The content updates here was triggered from this comment. And then it made me realize that we should validate this proposed enhancement from the community first before we merge this.

Regardless on whether we merge this PR or not, the one thing that is missing in the reference doc is:

Is it still recommended to use a multiple SecurityFilterChain's? If so, why (so users can know the tradeoffs of having a single or multiple)? The demonstration of the minimal configuration that is preferred (single/multiple) should be the first config users see in Getting Started.

I will be adding this content in the reference doc this week.

@jgrandja jgrandja force-pushed the auth-server-config branch from e14e27c to 96ab59d Compare October 2, 2024 20:10
@jgrandja jgrandja added this to the 1.4.0-RC1 milestone Oct 3, 2024
@jgrandja jgrandja force-pushed the auth-server-config branch from 96ab59d to 15a3355 Compare October 3, 2024 19:20
@jgrandja jgrandja marked this pull request as ready for review October 3, 2024 19:26
@jgrandja jgrandja merged commit 2c79754 into spring-projects:main Oct 3, 2024
2 checks passed
@jgrandja jgrandja deleted the auth-server-config branch October 3, 2024 19:28
@JBraddockm
Copy link

JBraddockm commented Nov 23, 2024

I just wanted to share my experience with this new configuration in case someone else lands on this page through their researches. Not sure if this is something specific to my configuration or this is indeed an expected behaviour as part of a wider change in Spring Security, but I had a problem with upgrading to the new configuration style.

I have two filter chains where the first one configures the authorization server while the latter deals more specific cases such as allowing certain endpoints, and static files. Unless I put the following in the first security filter, then upgrading to the new configuration style breaks the app. Clients get access denied.

http.authorizeHttpRequests((authorize) ->
			authorize.anyRequest().authenticated()

Only time that the new configuration style works without the above lines being present in the first security filter chain is by removing the following from the second filter chain while leaving authorize.anyRequest().authenticated() in place.

    http.authorizeHttpRequests(
            (authorize) ->
                authorize
                    .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                    .permitAll()
                    .requestMatchers("/error", "/logout")
                    .permitAll()
                    .anyRequest() // Remains
                    .authenticated()); // Remains

Docs and your examples here indeed include this line in the first security filter, but it could perhaps be missed by some upgrading their old configurations so I just wanted to share my experience here.

Thank you.

@jgrandja
Copy link
Collaborator Author

@JBraddockm

Unless I put the following in the first security filter, then upgrading to the new configuration style breaks the app. Clients get access denied.

http.authorizeHttpRequests((authorize) ->
			authorize.anyRequest().authenticated()

authorize.anyRequest().authenticated() should always be applied to all filter chains in order to adhere to secure-by-default. This is how demo-authorizationserver is configured as well.

upgrading to the new configuration style breaks the app

I'm not completely following how the new configuration style broke your app. Can you provide your configuration before the upgrade and then after the upgrade so I can better understand?

@JBraddockm
Copy link

JBraddockm commented Dec 1, 2024

I'm not completely following how the new configuration style broke your app. Can you provide your configuration before the upgrade and then after the upgrade so I can better understand?

Thank you for the response.

The following configuration is working. However, if I remove http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); from the first filter chain, then it is not working. Redirecting to the login page results in 404. There is not a explicit error message or exceptions in the auth-server despite the log level being set to trace. In the client site, I get Access Denied exception.

  @Bean
  @Order(1)
  SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

    OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
        OAuth2AuthorizationServerConfigurer.authorizationServer();

    http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
        .with(
            authorizationServerConfigurer,
            (authorizationServer) -> authorizationServer.oidc(Customizer.withDefaults()));

    http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());

    http.exceptionHandling(
            (exceptions) ->
                exceptions.defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
        .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));

    return http.build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

    http.authorizeHttpRequests(
            (authorize) ->
                authorize
                    .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                    .permitAll()
                    .requestMatchers("/error", "/logout")
                    .permitAll()
                    .anyRequest()
                    .authenticated())
        .exceptionHandling(
            exception ->
                exception.defaultAuthenticationEntryPointFor(
                    new HxRefreshHeaderAuthenticationEntryPoint(),
                    new RequestHeaderRequestMatcher("HX-Request")))
        .formLogin(formLogin -> formLogin.loginPage("/login").permitAll())
        .oauth2Login(oauth2 -> oauth2.loginPage("/login").permitAll());
    return http.build();
  }

The reason I didn't have http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); in the first place is the examples I'd followed as I've been learning Spring Authorization Server didn't have it. For example, here or here. Not sure if it is meant to be there in the first place.

If I remove the endpoint permissions in the second filter chain, then it works with http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); being removed from the first filter chain. I of course get errors for the static files.

  @Bean
  @Order(1)
  SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

    OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
        OAuth2AuthorizationServerConfigurer.authorizationServer();

    http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
        .with(
            authorizationServerConfigurer,
            (authorizationServer) -> authorizationServer.oidc(Customizer.withDefaults()));

    http.exceptionHandling(
            (exceptions) ->
                exceptions.defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
        .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));

    return http.build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

    http.authorizeHttpRequests(
            (authorize) ->
                authorize
                    //
                    // .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                    //                    .permitAll()
                    //                    .requestMatchers("/error", "/logout")
                    //                    .permitAll()
                    .anyRequest()
                    .authenticated())
        .exceptionHandling(
            exception ->
                exception.defaultAuthenticationEntryPointFor(
                    new HxRefreshHeaderAuthenticationEntryPoint(),
                    new RequestHeaderRequestMatcher("HX-Request")))
        .formLogin(formLogin -> formLogin.loginPage("/login").permitAll())
        .oauth2Login(oauth2 -> oauth2.loginPage("/login").permitAll());
    return http.build();
  }

The following is the old configuration that I upgraded from, which is working.

  @Bean
  @Order(1)
  SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(withDefaults());

    http.exceptionHandling(
            (exceptions) ->
                exceptions.defaultAuthenticationEntryPointFor(
                    new LoginUrlAuthenticationEntryPoint("/login"),
                    new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
        .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));

    return http.build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

    http.authorizeHttpRequests(
            (authorize) ->
                authorize
                    .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                    .permitAll()
                    .requestMatchers("/error", "/logout")
                    .permitAll()
                    .anyRequest()
                    .authenticated())
        .exceptionHandling(
            exception ->
                exception.defaultAuthenticationEntryPointFor(
                    new HxRefreshHeaderAuthenticationEntryPoint(),
                    new RequestHeaderRequestMatcher("HX-Request")))
        .formLogin(formLogin -> formLogin.loginPage("/login").permitAll())
        .oauth2Login(oauth2 -> oauth2.loginPage("/login").permitAll());
    return http.build();
  }

The reason I don't have the /login in the requestMatchers because it breaks the HxRefreshHeaderAuthenticationEntryPoint, which I don't know why yet, though adding that to the list of permittable endpoints in requestMatchers does not make any difference to the issue.

My apologies if I wasn't clearer.

@jgrandja
Copy link
Collaborator Author

jgrandja commented Dec 2, 2024

@JBraddockm

The reason I didn't have http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()); in the first place is the examples I'd followed as I've been learning Spring Authorization Server didn't have it. For example, here or here. Not sure if it is meant to be there in the first place.

Both of the sample links you provided use the now deprecated OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http), which applies authorize.anyRequest().authenticated() as shown here:

As noted in my previous comment:

authorize.anyRequest().authenticated() should always be applied to all filter chains in order to adhere to secure-by-default.

This is a standard best practice for all Spring Security configurations in general. Start off with securing all endpoints and then customize the authorization rules for specific endpoints depending on your security requirements.

@JBraddockm
Copy link

Thank you.

jgrandja added a commit that referenced this pull request Dec 4, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement A general enhancement
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

3 participants