Add SPA sample using BFF and Spring Cloud Gateway

This commit is contained in:
Joe Grandja
2024-11-08 14:29:41 -05:00
parent 3a63bf6dd2
commit a6d9c19daa
30 changed files with 14035 additions and 0 deletions

View File

@@ -0,0 +1 @@
spring-security.version=6.3.0

View File

@@ -0,0 +1,35 @@
plugins {
id "org.springframework.boot" version "3.2.2"
id "io.spring.dependency-management" version "1.1.0"
id "java"
}
group = project.rootProject.group
version = project.rootProject.version
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
}
ext {
set("springCloudVersion", "2023.0.2")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework.cloud:spring-cloud-starter-gateway-mvc"
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Joe Grandja
* @since 1.4
*/
@SpringBootApplication
public class BackendForSpaClientApplication {
public static void main(String[] args) {
SpringApplication.run(BackendForSpaClientApplication.class, args);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import java.util.Arrays;
import java.util.Collections;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* @author Joe Grandja
* @since 1.4
*/
@Configuration(proxyBeanMethods = false)
public class CorsConfig {
@Value("${app.base-uri}")
private String appBaseUri;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("X-XSRF-TOKEN");
config.addAllowedHeader(HttpHeaders.CONTENT_TYPE);
config.setAllowedMethods(Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedOrigins(Collections.singletonList(this.appBaseUri));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import org.springframework.cloud.gateway.server.mvc.common.Shortcut;
import org.springframework.cloud.gateway.server.mvc.filter.SimpleFilterSupplier;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.web.servlet.function.HandlerFilterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getApplicationContext;
/**
* Custom {@code HandlerFilterFunction}'s registered in META-INF/spring.factories and used in application.yml.
*
* @author Joe Grandja
* @since 1.4
*/
public interface GatewayFilterFunctions {
@Shortcut
static HandlerFilterFunction<ServerResponse, ServerResponse> relayTokenIfExists(String clientRegistrationId) {
return (request, next) -> {
Authentication principal = (Authentication) request.servletRequest().getUserPrincipal();
OAuth2AuthorizedClientRepository authorizedClientRepository = getApplicationContext(request)
.getBean(OAuth2AuthorizedClientRepository.class);
OAuth2AuthorizedClient authorizedClient = authorizedClientRepository.loadAuthorizedClient(
clientRegistrationId, principal, request.servletRequest());
if (authorizedClient != null) {
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
ServerRequest bearerRequest = ServerRequest.from(request)
.headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken.getTokenValue())).build();
return next.handle(bearerRequest);
}
return next.handle(request);
};
}
class FilterSupplier extends SimpleFilterSupplier {
FilterSupplier() {
super(GatewayFilterFunctions.class);
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import java.util.LinkedHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.CompositeLogoutHandler;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfLogoutHandler;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
/**
* @author Joe Grandja
* @since 1.4
*/
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfig {
@Value("${app.base-uri}")
private String appBaseUri;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
/*
IMPORTANT:
Set the csrfRequestAttributeName to null, to opt-out of deferred tokens, resulting in the CsrfToken to be loaded on every request.
If it does not exist, the CookieCsrfTokenRepository will automatically generate a new one and add the Cookie to the response.
See the reference: https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#deferred-csrf-token
*/
csrfTokenRequestAttributeHandler.setCsrfRequestAttributeName(null);
// @formatter:off
http
.authorizeHttpRequests(authorize ->
authorize
.anyRequest().authenticated()
)
.csrf(csrf ->
csrf
.csrfTokenRepository(cookieCsrfTokenRepository)
.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
)
.cors(Customizer.withDefaults())
.exceptionHandling(exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint())
)
.oauth2Login(oauth2Login ->
oauth2Login
.successHandler(new SimpleUrlAuthenticationSuccessHandler(this.appBaseUri)))
.logout(logout ->
logout
.addLogoutHandler(logoutHandler(cookieCsrfTokenRepository))
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
)
.oauth2Client(Customizer.withDefaults());
// @formatter:on
return http.build();
}
private AuthenticationEntryPoint authenticationEntryPoint() {
AuthenticationEntryPoint authenticationEntryPoint =
new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/messaging-client-oidc");
MediaTypeRequestMatcher textHtmlMatcher =
new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
textHtmlMatcher.setUseEquals(true);
LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
entryPoints.put(textHtmlMatcher, authenticationEntryPoint);
DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints);
delegatingAuthenticationEntryPoint.setDefaultEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
return delegatingAuthenticationEntryPoint;
}
private LogoutHandler logoutHandler(CsrfTokenRepository csrfTokenRepository) {
return new CompositeLogoutHandler(
new SecurityContextLogoutHandler(),
new CsrfLogoutHandler(csrfTokenRepository));
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* @author Joe Grandja
* @since 1.4
*/
@Controller
public class DefaultController {
@Value("${app.base-uri}")
private String appBaseUri;
@GetMapping("/")
public String root() {
return "redirect:" + this.appBaseUri;
}
// '/authorized' is the registered 'redirect_uri' for authorization_code
@GetMapping("/authorized")
public String authorized() {
return "redirect:" + this.appBaseUri;
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
sample.config.GatewayFilterFunctions.FilterSupplier

View File

@@ -0,0 +1,53 @@
server:
port: 8080
logging:
level:
root: INFO
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO
spring:
security:
oauth2:
client:
registration:
messaging-client-oidc:
provider: spring
client-id: messaging-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/{registrationId}"
scope: openid,profile
client-name: messaging-client-oidc
messaging-client-authorization-code:
provider: spring
client-id: messaging-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:8080/authorized"
scope: message.read,message.write
client-name: messaging-client-authorization-code
provider:
spring:
issuer-uri: http://localhost:9000
cloud:
gateway:
mvc:
routes:
- id: userinfo
uri: http://localhost:9000
predicates:
- Path=/userinfo
filters:
- TokenRelay=
- id: messages
uri: http://localhost:8090
predicates:
- Path=/messages
filters:
- RelayTokenIfExists=messaging-client-authorization-code
app:
base-uri: http://127.0.0.1:4200