Skip to content

Commit

Permalink
gh-155 Configurable HTTP status in OAuth2 flows responses
Browse files Browse the repository at this point in the history
  • Loading branch information
ch4mpy committed Nov 22, 2023
1 parent 8cc7974 commit 2b5e12b
Show file tree
Hide file tree
Showing 15 changed files with 452 additions and 91 deletions.
22 changes: 18 additions & 4 deletions README.MD
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
You can now **test your OAuth2 / OpenID knowledge with a dedicated quiz** available at [https://quiz.c4-soft.com/ui/quizzes](https://quiz.c4-soft.com/ui/quizzes) before you rush into configuring your applications.

7.x is a break through in usability: all 6 `spring-addons` Boot starters are merged into a single one: [`com.c4-soft.springaddons:spring-addons-starter-oidc`](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons-starter-oidc/), and so are 4 of the test libs: [`com.c4-soft.springaddons:spring-addons-starter-oidc-test`](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons-starter-oidc-test/). To use the test annotations without the starter, the dependency is unchanged: [`com.c4-soft.springaddons:spring-addons-oauth2-test`](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons-oauth2-test/).

Please follow the [migration guide](https://github.com/ch4mpy/spring-addons/blob/master/7.0.0-migration-guide.md) to move from `6.x` to `7.1.1`. There is no urge to do so on existing projects as 6.2.x patches should be published until the end of 2023.
Please follow the [migration guide](https://github.com/ch4mpy/spring-addons/blob/master/7.0.0-migration-guide.md) to move from `6.x` to `7.x`.

All samples and tutorials sources are migrated to latest starter and test annotations, but some READMEs might still need a refresh. Please make sure you refer to source code for up-to-date configuration.

Expand Down Expand Up @@ -426,7 +424,7 @@ These starters are designed to push auto-configuration one step further. In most
I could forget to update README before releasing, so please refer to [maven central](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons/) to pick latest available release
```xml
<properties>
<springaddons.version>7.1.14</springaddons.version>
<springaddons.version>7.1.15</springaddons.version>
</properties>
<dependencies>
Expand Down Expand Up @@ -462,6 +460,22 @@ I could forget to update README before releasing, so please refer to [maven cent

### 5.1. <a name="release-notes-7"/>`7.x` Branch

#### `7.1.15`
- [gh-155](https://github.com/ch4mpy/spring-addons/issues/155) Configurable HTTP status for responses to authorization_code flow initiation, authorization-code callback and logout. This makes BFF configuration easier for single page and mobile applications. Default OAuth2 response status (`302 Found`) can be overriden with:
```yaml
com:
c4-soft:
springaddons:
oidc:
ops:
client:
oauth2-redirections:
pre-authorization-code: FOUND
post-authorization-code: FOUND
rp-initiated-logout: ACCEPTED
```
A per-request override can be done by setting `X-RESPONSE-STATUS` header with either a status code or label (for instance, both `201` and `ACCEPTED` are accepted as value).

#### `7.1.14`
- update CSRF configuration for SPAs as instructed by spring-security team in https://github.com/spring-projects/spring-security/issues/14125

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import static org.springframework.security.config.Customizer.withDefaults;

import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;

import org.springframework.beans.factory.annotation.Value;
Expand All @@ -14,26 +18,34 @@
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
import org.springframework.security.web.server.csrf.CsrfToken;
import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestAttributeHandler;
import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler;
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.util.UriComponentsBuilder;

import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;

@Configuration
Expand All @@ -42,50 +54,59 @@ public class SecurityConf {

/**
* <p>
* Security filter-chain for resources needing sessions with CSRF protection enabled and CSRF token cookie accessible to Angular
* application.
* Security filter-chain for resources needing sessions with CSRF protection enabled and CSRF token cookie accessible to Angular application.
* </p>
* <p>
* It is defined with low order (high precedence) and security-matcher to limit the resources it applies to.
* </p>
*
*
* @param http
* @param clientRegistrationRepository
* @param securityMatchers
* @param permitAll
* @return
* @throws URISyntaxException
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityWebFilterChain clientFilterCHain(
ServerHttpSecurity http,
ServerProperties serverProperties,
ReactiveClientRegistrationRepository clientRegistrationRepository,
@Value("${gateway-uri}") URI gatewayUri,
@Value("${client-security-matchers:[]}") String[] securityMatchers,
@Value("${client-permit-all:[]}") String[] permitAll,
@Value("${post-logout-redirect-uri}") String postLogoutRedirectUri) {
@Value("${pre-authorization-status:FOUND}") HttpStatus preAuthorizationStatus,
@Value("${post-authorization-status:FOUND}") HttpStatus postAuthorizationStatus,
@Value("${post-logout-redirect-uri}") String postLogoutRedirectUri)
throws URISyntaxException {

// Apply this filter-chain only to resources needing sessions
final var clientRoutes =
Stream.of(securityMatchers).map(PathPatternParserServerWebExchangeMatcher::new).map(ServerWebExchangeMatcher.class::cast).toList();
http.securityMatcher(new OrServerWebExchangeMatcher(clientRoutes));

// Set post-login URI to Angular app (login being successful or not)
// The following handlers answer with NO_CONTENT HTTP status so that single page and mobile apps can handle the redirection by themselves
http.oauth2Login(login -> {
login.authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/ui/"));
login.authenticationFailureHandler(new RedirectServerAuthenticationFailureHandler("/ui/"));
login.authorizationRedirectStrategy(new C4OAuth2ServerRedirectStrategy(preAuthorizationStatus));

// Set post-login URI to Angular app (login being successful or not)
final var uiUri = UriComponentsBuilder.fromUri(gatewayUri).path("/ui/").build().toUri();
login.authenticationSuccessHandler(new C4Oauth2ServerAuthenticationSuccessHandler(postAuthorizationStatus, uiUri));
login.authenticationFailureHandler(new C4Oauth2ServerAuthenticationFailureHandler(postAuthorizationStatus, uiUri));
});

// Keycloak fully complies with RP-Initiated Logout
// Keycloak fully complies with RP-Initiated Logout but we need an answer in the 2xx range for single page and mobile apps to handle the redirection by
// themselves
// The following is a wrapper around the OidcClientInitiatedServerLogoutSuccessHandler to change the response status.
http.logout(logout -> {
logout.logoutSuccessHandler(new AngularLogoutSucessHandler(clientRegistrationRepository, postLogoutRedirectUri));
logout.logoutSuccessHandler(new SpaLogoutSucessHandler(clientRegistrationRepository, postLogoutRedirectUri));
});

// Sessions being necessary, configure CSRF protection to work with Angular.
// Note the csrfCookieWebFilter below which actually attaches the CSRF token cookie to responses
http.csrf(csrf -> {
var delegate = new XorServerCsrfTokenRequestAttributeHandler();
csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(delegate::handle);
csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler());
});

// If SSL enabled, disable http (https only)
Expand All @@ -102,6 +123,9 @@ SecurityWebFilterChain clientFilterCHain(
return http.build();
}

/**
* @return second half of CSRF handling for SPAs
*/
@Bean
WebFilter csrfCookieWebFilter() {
return (exchange, chain) -> {
Expand All @@ -115,10 +139,10 @@ WebFilter csrfCookieWebFilter() {
* Security filter-chain for resources for which sessions are not needed.
* </p>
* <p>
* It is defined with lower precedence (higher order) than the client filter-chain and no security matcher => this one acts as default for
* all requests that do not match the client filter-chain secutiy-matcher.
* It is defined with lower precedence (higher order) than the client filter-chain and no security matcher => this one acts as default for all requests that
* do not match the client filter-chain secutiy-matcher.
* </p>
*
*
* @param http
* @param serverProperties
* @param permitAll
Expand Down Expand Up @@ -166,23 +190,102 @@ SecurityWebFilterChain resourceServerFilterCHain(
return http.build();
}

static class AngularLogoutSucessHandler implements ServerLogoutSuccessHandler {
@RequiredArgsConstructor
static class C4OAuth2ServerRedirectStrategy implements ServerRedirectStrategy {
private final HttpStatus defaultStatus;

@Override
public Mono<Void> sendRedirect(ServerWebExchange exchange, URI location) {
return Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
// @formatter:off
final var status = Optional.ofNullable(exchange.getRequest().getHeaders().get("X-RESPONSE-STATUS"))
.map(List::stream)
.orElse(Stream.empty())
.filter(StringUtils::hasLength)
.findAny()
.map(statusStr -> {
try {
final var statusCode = Integer.parseInt(statusStr);
return HttpStatus.valueOf(statusCode);
} catch(NumberFormatException e) {
return HttpStatus.valueOf(statusStr.toUpperCase());
}
})
.orElse(defaultStatus);
// @formatter:on
response.setStatusCode(status);
response.getHeaders().setLocation(location);
});
}

}

static class C4Oauth2ServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
private final URI redirectUri;
private final C4OAuth2ServerRedirectStrategy redirectStrategy;

public C4Oauth2ServerAuthenticationSuccessHandler(HttpStatus status, URI location) {
this.redirectUri = location;
this.redirectStrategy = new C4OAuth2ServerRedirectStrategy(status);
}

@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), redirectUri);
}

}

static class C4Oauth2ServerAuthenticationFailureHandler implements ServerAuthenticationFailureHandler {
private final URI redirectUri;
private final C4OAuth2ServerRedirectStrategy redirectStrategy;

public C4Oauth2ServerAuthenticationFailureHandler(HttpStatus status, URI location) {
this.redirectUri = location;
this.redirectStrategy = new C4OAuth2ServerRedirectStrategy(status);
}

@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) {
return redirectStrategy.sendRedirect(webFilterExchange.getExchange(), redirectUri);
}
}

static class SpaLogoutSucessHandler implements ServerLogoutSuccessHandler {
private final OidcClientInitiatedServerLogoutSuccessHandler delegate;
public AngularLogoutSucessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository, String postLogoutRedirectUri) {

public SpaLogoutSucessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository, String postLogoutRedirectUri) {
this.delegate = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
this.delegate.setPostLogoutRedirectUri(postLogoutRedirectUri);
}

@Override
public
Mono<
Void>
onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {
return delegate.onLogoutSuccess(exchange, authentication).then(Mono.fromRunnable(() -> {
exchange.getExchange().getResponse().setStatusCode(HttpStatus.ACCEPTED);
}));
}
}

/**
* Adapted from https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa
*/
static final class SpaCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler {
private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler();

@Override
public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) {
/*
* Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body.
*/
this.delegate.handle(exchange, csrfToken);
}

@Override
public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) {
final var hasHeader = exchange.getRequest().getHeaders().get(csrfToken.getHeaderName()).stream().filter(StringUtils::hasText).count() > 0;
return hasHeader ? super.resolveCsrfTokenValue(exchange, csrfToken) : this.delegate.resolveCsrfTokenValue(exchange, csrfToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,17 @@
"name": "post-logout-redirect-uri",
"type": "java.lang.String",
"description": "Where to redirect the user after he logged out from the authorization server (probably the UI URI on the gateway)"
},
{
"name": "pre-authorization-status",
"type": "org.springframework.http.HttpStatus",
"description": "HTTP status for the 1st response in authorization_code flow, with location to the authorization server authorization endpoint",
"defaultValue": "FOUND"
},
{
"name": "post-authorization-status",
"type": "org.springframework.http.HttpStatus",
"description": "HTTP status for the last response in authorization_code flow, with location back to the UI",
"defaultValue": "FOUND"
}
]}
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,23 @@ logging:
boot: DEBUG

---
spring:
config:
activate:
on-profile: ssl

scheme: https
server:
ssl:
enabled: true


---
spring:
config:
activate:
on-profile: ssl
on-profile: mobile

pre-authorization-status: NO_CONTENT
post-authorization-status: NO_CONTENT

scheme: https
# gateway-uri: ${scheme}://10.0.2.2:${server.port}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
issuer: https://oidc.c4-soft.com/auth/realms/master

server:
port: 7084
ssl:
Expand All @@ -13,7 +15,7 @@ spring:
oauth2:
resourceserver:
jwt:
issuer-uri: https://oidc.c4-soft.com/auth/realms/master
issuer-uri: ${issuer}

logging:
level:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,7 @@
package com.c4_soft.dzone_oauth2_spring.c4_bff;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.stereotype.Component;

import com.c4_soft.springaddons.security.oidc.starter.LogoutRequestUriBuilder;
import com.c4_soft.springaddons.security.oidc.starter.reactive.client.SpringAddonsServerLogoutSuccessHandler;

import reactor.core.publisher.Mono;

@Configuration
public class SecurityConf {

@Component
static class AngularLogoutSucessHandler implements ServerLogoutSuccessHandler {
private final SpringAddonsServerLogoutSuccessHandler delegate;

public AngularLogoutSucessHandler(LogoutRequestUriBuilder logoutUriBuilder, ReactiveClientRegistrationRepository clientRegistrationRepo) {
this.delegate = new SpringAddonsServerLogoutSuccessHandler(logoutUriBuilder, clientRegistrationRepo);
}

@Override
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {
return delegate.onLogoutSuccess(exchange, authentication).then(Mono.fromRunnable(() -> {
exchange.getExchange().getResponse().setStatusCode(HttpStatus.ACCEPTED);
}));
}

}
}
Loading

0 comments on commit 2b5e12b

Please sign in to comment.