Skip to content
New issue

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

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

Already on GitHub? # to your account

Add SPA sample using Backend For Frontend and Spring Cloud Gateway #1816

Merged
merged 1 commit into from
Nov 19, 2024
Merged
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
20 changes: 20 additions & 0 deletions samples/README.adoc
Original file line number Diff line number Diff line change
@@ -5,6 +5,26 @@

The default sample provides the minimal configuration to get started with Spring Authorization Server.

[[spa-sample]]
== SPA (Single Page Application) Sample

The SPA sample provides a reference implementation of the https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-19#name-backend-for-frontend-bff[Backend For Frontend (BFF)] application architecture pattern.
The *spa-client* is the _frontend_ SPA implemented with Angular and the *backend-for-spa-client* is the _backend_ application.
The *backend-for-spa-client* uses https://spring.io/projects/spring-cloud-gateway[Spring Cloud Gateway] to route `/userinfo` (UserInfo Endpoint) requests to *demo-authorizationserver* and `/messages` requests to *messages-resource*.
The *backend-for-spa-client* performs the authorization flows and stores the access tokens.
The *spa-client* is never exposed the access tokens and directly communicates with the *backend-for-spa-client* via an authenticated session cookie.

[[run-spa-sample]]
=== Run the Sample

* Run Authorization Server -> `./gradlew -b samples/demo-authorizationserver/samples-demo-authorizationserver.gradle bootRun`
* Run Resource Server -> `./gradlew -b samples/messages-resource/samples-messages-resource.gradle bootRun`
* Run Backend -> `./gradlew -b samples/backend-for-spa-client/samples-backend-for-spa-client.gradle bootRun`
* Run Frontend -> `ng serve` (from `samples/spa-client` directory)
** *NOTE:* Angular must be installed locally before running `ng serve`. See https://angular.dev/installation[installation instructions].
* Go to `http://127.0.0.1:4200`
** Login with credentials -> user1 \ password

[[demo-sample]]
== Demo Sample

1 change: 1 addition & 0 deletions samples/backend-for-spa-client/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
spring-security.version=6.3.0
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
Original file line number Diff line number Diff line change
@@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
sample.config.GatewayFilterFunctions.FilterSupplier
Loading