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

gh-140 : Use AOP instead of custom authorized-clients repo #142

Merged
merged 1 commit into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion samples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

<properties>
<java.version>17</java.version>
<spring-cloud.version>2022.0.2</spring-cloud.version>
<spring-cloud.version>2022.0.4</spring-cloud.version>

<!-- OpenAPI -->
<io.swagger.core.v3.version>2.2.9</io.swagger.core.v3.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function gatewayApiConfigFactory(): GatewayApiConfiguration {

export function greetingsApiConfigFactory(): GreetingsApiConfiguration {
const params: GreetingsApiConfigurationParameters = {
basePath: '/bff/greetings-api/v1/',
basePath: '/bff/greetings-api/v1',
};
return new GreetingsApiConfiguration(params);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
Expand All @@ -38,14 +38,15 @@ public class GatewayController {
private final ReactiveClientRegistrationRepository clientRegistrationRepository;
private final SpringAddonsOidcClientProperties addonsClientProperties;
private final LogoutRequestUriBuilder logoutRequestUriBuilder;
private final ServerSecurityContextRepository securityContextRepository = new WebSessionServerSecurityContextRepository();
private final ServerLogoutHandler logoutHandler;
private final List<LoginOptionDto> loginOptions;

public GatewayController(
OAuth2ClientProperties clientProps,
ReactiveClientRegistrationRepository clientRegistrationRepository,
SpringAddonsOidcProperties addonsProperties,
LogoutRequestUriBuilder logoutRequestUriBuilder) {
LogoutRequestUriBuilder logoutRequestUriBuilder,
ServerLogoutHandler logoutHandler) {
this.addonsClientProperties = addonsProperties.getClient();
this.clientRegistrationRepository = clientRegistrationRepository;
this.logoutRequestUriBuilder = logoutRequestUriBuilder;
Expand All @@ -55,6 +56,7 @@ public GatewayController(
e.getValue().getProvider(),
"%s/oauth2/authorization/%s".formatted(addonsClientProperties.getClientUri(), e.getKey())))
.toList();
this.logoutHandler = logoutHandler;
}

@GetMapping(path = "/login-options", produces = "application/json")
Expand Down Expand Up @@ -94,7 +96,7 @@ public Mono<ResponseEntity<Void>> logout(ServerWebExchange exchange, Authenticat
uri = Mono.just(addonsClientProperties.getPostLogoutRedirectUri());
}
return uri.flatMap(logoutUri -> {
return securityContextRepository.save(exchange, null).thenReturn(logoutUri);
return logoutHandler.logout(new WebFilterExchange(exchange, ex -> Mono.empty().then()), authentication).thenReturn(logoutUri);
}).map(logoutUri -> {
return ResponseEntity.noContent().location(logoutUri).build();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ spring:
provider:
keycloak:
issuer-uri: ${keycloak-issuer}
user-name-attribute: preferred_username
cognito:
issuer-uri: ${cognito-issuer}
auth0:
Expand Down Expand Up @@ -130,24 +131,19 @@ com:
security-matchers:
- /login/**
- /oauth2/**
- /
- /login-options
- /logout
- /me
- /bff/**
permit-all:
- /login/**
- /oauth2/**
- /
- /login-options
- /me
# The Angular app needs access to the CSRF cookie (to return its value as X-XSRF-TOKEN header)
csrf: cookie-accessible-from-js
login-path: /ui/
post-login-redirect-path: /ui/
post-logout-redirect-path: /ui/
# This is an "experemiental" feature, use with caution
back-channel-logout-enabled: true
# Auth0 and Cognito do not follow strictly the OpenID RP-Initiated Logout spec and need specific configuration
oauth2-logout:
cognito-confidential-user:
Expand All @@ -165,7 +161,10 @@ com:
value: demo.c4-soft.com
# Configuration for a resource server security filterchain
resourceserver:
enabled: true
permit-all:
- /
- /login-options
- /resource-server/**
- /ui/**
- /v3/api-docs/**
Expand Down
2 changes: 1 addition & 1 deletion samples/tutorials/bff/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
<module>gateway</module>
<module>greetings-api</module>
</modules>
</project>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -27,8 +27,8 @@

import com.c4_soft.springaddons.security.oidc.starter.LogoutRequestUriBuilder;
import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties;
import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.SpringAddonsOAuth2AuthorizedClientRepository;
import com.nimbusds.jwt.JWTClaimNames;
import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.AuthorizedSessionRepository;
import com.c4_soft.springaddons.security.oidc.starter.synchronised.client.OAuth2PrincipalSupport;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand All @@ -45,7 +45,8 @@
public class UiController {
private final WebClient api;
private final InMemoryClientRegistrationRepository clientRegistrationRepository;
private final SpringAddonsOAuth2AuthorizedClientRepository authorizedClientRepo;
private final OAuth2AuthorizedClientRepository authorizedClientRepo;
private final AuthorizedSessionRepository authorizedSessionRepo;
private final SpringAddonsOidcProperties addonsClientProps;
private final LogoutRequestUriBuilder logoutRequestUriBuilder;

Expand Down Expand Up @@ -104,44 +105,36 @@ public String getGreeting(HttpServletRequest request, Authentication auth, Model
public RedirectView logout(
@RequestParam("clientRegistrationId") String clientRegistrationId,
@RequestParam(name = "redirectTo", required = false) Optional<String> redirectTo,
Authentication auth,
HttpServletRequest request,
HttpServletResponse response) {
final var authorizedClient = authorizedClientRepo.loadAuthorizedClient(clientRegistrationId, auth, request);
final var postLogoutUri = UriComponentsBuilder.fromUri(addonsClientProps.getClient().getClientUri()).path(redirectTo.orElse("/ui/greet"))
.encode(StandardCharsets.UTF_8).build().toUriString();
final var userIds = authorizedClientRepo.getOAuth2UsersBySession(request.getSession());
final var user = userIds.get(authorizedClient.getClientRegistration().getProviderDetails().getIssuerUri());
final var idToken = user instanceof OidcUser oidcUser ? oidcUser.getIdToken().getTokenValue() : null;
final var userSubject =
Optional.ofNullable(user).map(OAuth2User::getAttributes).map(attr -> attr.get(JWTClaimNames.SUBJECT)).map(String.class::cast).orElse(null);
final var authentication = OAuth2PrincipalSupport.getAuthentication(request.getSession(), clientRegistrationId).orElse(null);
final var authorizedClient = authorizedClientRepo.loadAuthorizedClient(clientRegistrationId, authentication, request);
final var idToken = authentication instanceof OidcUser oidcUser ? oidcUser.getIdToken().getTokenValue() : null;
String logoutUri = logoutRequestUriBuilder.getLogoutRequestUri(authorizedClient.getClientRegistration(), idToken, URI.create(postLogoutUri));

log.info("Remove authorized client with ID {} for {}", clientRegistrationId, userSubject);
this.authorizedClientRepo.removeAuthorizedClient(clientRegistrationId, auth, request, response);
if (userIds.isEmpty()) {
log.info("Remove authorized client with ID {} for {}", clientRegistrationId, authentication.getName());
this.authorizedClientRepo.removeAuthorizedClient(clientRegistrationId, authentication, request, response);
final var authorizedClientIds = authorizedSessionRepo.findAuthorizedClientIdsBySessionId(request.getSession().getId());
if (authorizedClientIds.isEmpty()) {
request.getSession().invalidate();
}

log.info("Redirecting {} to {} for logout", userSubject, logoutUri);
log.info("Redirecting {} to {} for logout", authentication.getName(), logoutUri);
return new RedirectView(logoutUri);
}

@GetMapping("/bulk-logout-idps")
@PreAuthorize("isAuthenticated()")
public RedirectView bulkLogout(HttpServletRequest request) {
final var identities = authorizedClientRepo.getOAuth2UsersBySession(request.getSession());
final var issuers = identities.keySet();
final var registrations = StreamSupport.stream(this.clientRegistrationRepository.spliterator(), false)
.filter(registration -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(registration.getAuthorizationGrantType()))
.filter(registration -> issuers.contains(registration.getProviderDetails().getIssuerUri())).iterator();
if (registrations.hasNext()) {
final var clientRegistration = registrations.next();
final var authorizedClientIds = authorizedSessionRepo.findAuthorizedClientIdsBySessionId(request.getSession().getId()).iterator();
if (authorizedClientIds.hasNext()) {
final var id = authorizedClientIds.next();
final var builder = UriComponentsBuilder.fromPath("/ui/logout-idp");
builder.queryParam("clientRegistrationId", clientRegistration.getRegistrationId());
builder.queryParam("clientRegistrationId", id.getClientRegistrationId());
builder.queryParam("redirectTo", "/ui/bulk-logout-idps");
return new RedirectView(builder.encode(StandardCharsets.UTF_8).build().toUriString());

}
return new RedirectView(addonsClientProps.getClient().getPostLogoutRedirectPath());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ com:
client-uri: ${client-uri}
post-login-redirect-path: /ui/greet
post-logout-redirect-path: /ui/greet
back-channel-logout-enabled: true
multi-tenancy-enabled: true
oauth2-logout:
cognito-confidential-user:
uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout
Expand All @@ -128,6 +128,7 @@ logging:
org:
springframework:
security: INFO
boot: DEBUG

management:
endpoint:
Expand Down
14 changes: 12 additions & 2 deletions spring-addons-starter-oidc/pom.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.c4-soft.springaddons</groupId>
Expand All @@ -21,7 +23,10 @@
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-oauth2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
Expand All @@ -34,6 +39,11 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
<scope>optional</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ public class SpringAddonsOidcClientProperties {
@NestedConfigurationProperty
private Map<String, OAuth2LogoutProperties> oauth2Logout = new HashMap<>();

/**
* <p>
* If true, AOP is used to instrument authorized client repository and keep the principalName current user has for each issuer he authenticates on.
* </p>
* <p>
* This is useful only if you allow a user to authenticate on more than one OpenID Provider at a time. For instance, user logs in on Google and on an
* authorization server of your own and your client sends direct queries to Google APIs (with an access token issued by Google) and resource servers of your
* own (with an access token from your authorization server).
* </p>
*/
private boolean multiTenancyEnabled = false;

/**
* Whether to enable a security filter-chain and a controller (intercepting POST requests to "/backchannel_logout") to implement the client side of a
* <a href="https://openid.net/specs/openid-connect-backchannel-1_0.html">Back-Channel Logout</a>
*/
private boolean backChannelLogoutEnabled = false;
// private boolean backChannelLogoutEnabled = false;

/**
* Path matchers for the routes accessible to anonymous requests
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.c4_soft.springaddons.security.oidc.starter.properties.condition.configuration;

import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

public class IsClientMultiTenancyEnabled extends AllNestedConditions {

public IsClientMultiTenancyEnabled() {
super(ConfigurationPhase.PARSE_CONFIGURATION);
}

@ConditionalOnProperty(prefix = "com.c4-soft.springaddons.oidc.client", name = "multi-tenancy-enabled", matchIfMissing = false)
static class IsMultiTenancyEnabled {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public IsNotServlet() {
}

@ConditionalOnWebApplication(type = Type.SERVLET)
static class IsServletWebApp {
static class IsServletWebApp {

}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.c4_soft.springaddons.security.oidc.starter.reactive.client;

import org.springframework.security.oauth2.client.OAuth2AuthorizedClientId;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
* A repository to store relationships between authorized clients and user sessions
*
* @author Jerome Wacongne ch4mp&#64;c4-soft.com
*/
public abstract class AbstractReactiveAuthorizedSessionRepository implements SessionListener {

public AbstractReactiveAuthorizedSessionRepository(SessionLifecycleEventNotifier sessionEventNotifier) {
sessionEventNotifier.register(this);
}

public abstract Mono<String> save(OAuth2AuthorizedClientId authorizedClientId, String sessionId);

public abstract Mono<String> delete(OAuth2AuthorizedClientId authorizedClientId);

public abstract Mono<String> findById(OAuth2AuthorizedClientId authorizedClientId);

public abstract Flux<OAuth2AuthorizedClientId> findAuthorizedClientIdsBySessionId(String sessionId);

@Override
public void sessionRemoved(String sessionId) {
this.findAuthorizedClientIdsBySessionId(sessionId).subscribe(authorizedClientId -> {
this.delete(authorizedClientId).subscribe();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.c4_soft.springaddons.security.oidc.starter.reactive.client;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.security.oauth2.client.OAuth2AuthorizedClientId;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class InMemoryReactiveAuthorizedSessionRepository extends AbstractReactiveAuthorizedSessionRepository {
public InMemoryReactiveAuthorizedSessionRepository(SessionLifecycleEventNotifier sessionEventNotifier) {
super(sessionEventNotifier);
}

private static final Map<OAuth2AuthorizedClientId, String> index = new ConcurrentHashMap<>();

@Override
public Mono<String> save(OAuth2AuthorizedClientId authorizedClientId, String sessionId) {
return Mono.justOrEmpty(index.put(authorizedClientId, sessionId));
}

@Override
public Mono<String> delete(OAuth2AuthorizedClientId authorizedClientId) {
return Mono.justOrEmpty(index.remove(authorizedClientId));
}

@Override
public Mono<String> findById(OAuth2AuthorizedClientId authorizedClientId) {
return Mono.justOrEmpty(index.get(authorizedClientId));
}

@Override
public Flux<OAuth2AuthorizedClientId> findAuthorizedClientIdsBySessionId(String sessionId) {
return Flux.fromStream(index.entrySet().stream()).filter(e -> Objects.equals(sessionId, e.getValue())).map(Map.Entry::getKey);
}
}
Loading
Loading