Refactor BFF Package Structure #64
@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.4.4</version>
|
<version>4.0.1</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
|
|||||||
@ -5,64 +5,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
|
||||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
|
||||||
import org.springframework.security.web.util.matcher.RequestMatchers;
|
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
|
||||||
import se.su.dsv.studentportalen.bff.login.BFFAuthenticationEntryPoint;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties
|
@EnableConfigurationProperties
|
||||||
@ConfigurationPropertiesScan
|
@ConfigurationPropertiesScan
|
||||||
public class Studentportalen extends SpringBootServletInitializer {
|
public class Studentportalen extends SpringBootServletInitializer {
|
||||||
|
|
||||||
private static final RequestMatcher DOCUMENTATION_MATCHER = RequestMatchers.anyOf(
|
|
||||||
antMatcher("/swagger"),
|
|
||||||
antMatcher("/swagger-ui/**"),
|
|
||||||
antMatcher("/v3/api-docs/**"));
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(Studentportalen.class, args);
|
SpringApplication.run(Studentportalen.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
public SecurityFilterChain securityFilterChain(
|
|
||||||
HttpSecurity http,
|
|
||||||
FrontendConfiguration frontendConfiguration)
|
|
||||||
throws Exception
|
|
||||||
{
|
|
||||||
http.exceptionHandling(exception -> exception
|
|
||||||
.authenticationEntryPoint(new BFFAuthenticationEntryPoint()));
|
|
||||||
http.oauth2Login(login -> login
|
|
||||||
.defaultSuccessUrl(frontendConfiguration.url(), true));
|
|
||||||
http.authorizeHttpRequests(authorize -> authorize
|
|
||||||
.requestMatchers(DOCUMENTATION_MATCHER).permitAll()
|
|
||||||
.anyRequest().authenticated());
|
|
||||||
http.cors(cors -> cors
|
|
||||||
.configurationSource(_ -> frontendOnlyCors(frontendConfiguration)));
|
|
||||||
return http.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CorsConfiguration frontendOnlyCors(FrontendConfiguration frontendConfiguration) {
|
|
||||||
var corsConfiguration = new CorsConfiguration();
|
|
||||||
corsConfiguration.setAllowedOrigins(List.of(frontendConfiguration.url()));
|
|
||||||
corsConfiguration.setAllowedMethods(List.of("GET", "POST"));
|
|
||||||
|
|
||||||
// Allow the frontend to see the X-Authorization-Url header
|
|
||||||
corsConfiguration.setExposedHeaders(List.of("X-Authorization-Url"));
|
|
||||||
|
|
||||||
// To allow the session cookie to be included
|
|
||||||
corsConfiguration.setAllowCredentials(true);
|
|
||||||
|
|
||||||
// Content-Type is allowed by default but with a restriction on the value
|
|
||||||
// The restriction does not allow "application/json" so we add it as an allowed header
|
|
||||||
corsConfiguration.setAllowedHeaders(List.of("Content-Type"));
|
|
||||||
return corsConfiguration;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package se.su.dsv.studentportalen.bff;
|
package se.su.dsv.studentportalen.bff.config;
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package se.su.dsv.studentportalen.bff;
|
package se.su.dsv.studentportalen.bff.config;
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package se.su.dsv.studentportalen.bff.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import se.su.dsv.studentportalen.bff.login.BFFAuthenticationEntryPoint;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(
|
||||||
|
HttpSecurity http,
|
||||||
|
FrontendConfiguration frontendConfiguration)
|
||||||
|
throws Exception
|
||||||
|
{
|
||||||
|
http.exceptionHandling(exception -> exception
|
||||||
|
.authenticationEntryPoint(new BFFAuthenticationEntryPoint()));
|
||||||
|
http.oauth2Login(login -> login
|
||||||
|
.defaultSuccessUrl(frontendConfiguration.url(), true));
|
||||||
|
http.authorizeHttpRequests(authorize -> authorize
|
||||||
|
.requestMatchers("/swagger", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.anyRequest().authenticated());
|
||||||
|
http.cors(cors -> cors
|
||||||
|
.configurationSource(_ -> frontendOnlyCors(frontendConfiguration)));
|
||||||
|
http.csrf(csrf -> csrf.spa());
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CorsConfiguration frontendOnlyCors(FrontendConfiguration frontendConfiguration) {
|
||||||
|
var corsConfiguration = new CorsConfiguration();
|
||||||
|
corsConfiguration.setAllowedOrigins(List.of(frontendConfiguration.url()));
|
||||||
|
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
|
||||||
|
|
||||||
|
// Allow the frontend to see the X-Authorization-Url header
|
||||||
|
corsConfiguration.setExposedHeaders(List.of("X-Authorization-Url"));
|
||||||
|
|
||||||
|
// To allow the session cookie to be included
|
||||||
|
corsConfiguration.setAllowCredentials(true);
|
||||||
|
|
||||||
|
// Content-Type is allowed by default but with a restriction on the value
|
||||||
|
// The restriction does not allow "application/json" so we add it as an allowed header
|
||||||
|
// X-XSRF-TOKEN is needed for CSRF protection
|
||||||
|
corsConfiguration.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
|
||||||
|
return corsConfiguration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package se.su.dsv.studentportalen.bff.frontend.profile;
|
package se.su.dsv.studentportalen.bff.controller;
|
||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@ -6,14 +6,26 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import se.su.dsv.studentportalen.bff.dto.response.ProfileResponse;
|
||||||
|
import se.su.dsv.studentportalen.bff.service.ProfileService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for user profile operations.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public class ProfileController {
|
public class ProfileController {
|
||||||
|
|
||||||
|
private final ProfileService service;
|
||||||
|
|
||||||
|
public ProfileController(ProfileService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/profile")
|
@GetMapping("/profile")
|
||||||
public Profile getProfile(
|
public ProfileResponse getProfile(
|
||||||
@AuthenticationPrincipal(errorOnInvalidType = true) OAuth2User currentUser)
|
@AuthenticationPrincipal(errorOnInvalidType = true) OAuth2User currentUser)
|
||||||
{
|
{
|
||||||
return new Profile(currentUser.getAttribute("name"), Profile.Language.ENGLISH);
|
return service.getProfile(currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
package se.su.dsv.studentportalen.bff;
|
package se.su.dsv.studentportalen.bff.controller;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
import se.su.dsv.studentportalen.bff.config.BackendApiConfiguration;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
@ -15,6 +16,9 @@ import java.util.concurrent.StructuredTaskScope.Subtask;
|
|||||||
@Profile("development")
|
@Profile("development")
|
||||||
public class TestController {
|
public class TestController {
|
||||||
|
|
||||||
|
private static final int NAME_DELAY_SECONDS = 2;
|
||||||
|
private static final int EMAIL_DELAY_SECONDS = 3;
|
||||||
|
|
||||||
private final RestClient restClient;
|
private final RestClient restClient;
|
||||||
private final BackendApiConfiguration backendApiConfiguration;
|
private final BackendApiConfiguration backendApiConfiguration;
|
||||||
|
|
||||||
@ -67,13 +71,13 @@ public class TestController {
|
|||||||
|
|
||||||
@RequestMapping("/name")
|
@RequestMapping("/name")
|
||||||
public String name() throws InterruptedException {
|
public String name() throws InterruptedException {
|
||||||
Thread.sleep(Duration.ofSeconds(2));
|
Thread.sleep(Duration.ofSeconds(NAME_DELAY_SECONDS));
|
||||||
return "Greg";
|
return "Greg";
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequestMapping("/email")
|
@RequestMapping("/email")
|
||||||
public String email() throws InterruptedException {
|
public String email() throws InterruptedException {
|
||||||
Thread.sleep(Duration.ofSeconds(3));
|
Thread.sleep(Duration.ofSeconds(EMAIL_DELAY_SECONDS));
|
||||||
return "greg@localhost";
|
return "greg@localhost";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
/// Everything related to the API used by the frontend
|
|
||||||
@NonNullApi
|
@NonNullApi
|
||||||
@NonNullFields
|
@NonNullFields
|
||||||
package se.su.dsv.studentportalen.bff.frontend;
|
package se.su.dsv.studentportalen.bff.controller;
|
||||||
|
|
||||||
import org.springframework.lang.NonNullApi;
|
import org.springframework.lang.NonNullApi;
|
||||||
import org.springframework.lang.NonNullFields;
|
import org.springframework.lang.NonNullFields;
|
||||||
@ -1,19 +1,25 @@
|
|||||||
package se.su.dsv.studentportalen.bff.frontend.profile;
|
package se.su.dsv.studentportalen.bff.dto.response;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
public record Profile(
|
/**
|
||||||
@JsonProperty(value = "name", required = true) String name,
|
* User profile information.
|
||||||
@JsonProperty(value = "language", required = true) Language language)
|
*/
|
||||||
|
public record ProfileResponse(
|
||||||
|
@JsonProperty(value = "name", required = true)
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@JsonProperty(value = "language", required = true)
|
||||||
|
Language language)
|
||||||
{
|
{
|
||||||
public enum Language {
|
public enum Language {
|
||||||
@JsonProperty("sv") SWEDISH,
|
@JsonProperty("sv") SWEDISH,
|
||||||
@JsonProperty("en") ENGLISH
|
@JsonProperty("en") ENGLISH
|
||||||
}
|
}
|
||||||
|
|
||||||
public Profile {
|
public ProfileResponse {
|
||||||
Objects.requireNonNull(name, "name must be specified");
|
Objects.requireNonNull(name, "name must be specified");
|
||||||
Objects.requireNonNull(language, "language must be specified");
|
Objects.requireNonNull(language, "language must be specified");
|
||||||
}
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
@NonNullApi
|
||||||
|
@NonNullFields
|
||||||
|
package se.su.dsv.studentportalen.bff.dto.response;
|
||||||
|
|
||||||
|
import org.springframework.lang.NonNullApi;
|
||||||
|
import org.springframework.lang.NonNullFields;
|
||||||
@ -8,7 +8,11 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
|||||||
|
|
||||||
public class BFFAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
public class BFFAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
@Override
|
@Override
|
||||||
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
|
public void commence(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuthenticationException authException)
|
||||||
|
{
|
||||||
String loginUri = ServletUriComponentsBuilder.fromRequest(request)
|
String loginUri = ServletUriComponentsBuilder.fromRequest(request)
|
||||||
.replacePath("/oauth2/authorization/studentportalen")
|
.replacePath("/oauth2/authorization/studentportalen")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/// This package contains the classes and logic for handling the login process
|
/// This package contains the classes and logic for handling the login process
|
||||||
/// described in section 6.1 of [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff).
|
/// described in section 6.1 of OAuth 2.0 for Browser-Based Applications.
|
||||||
|
/// See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps
|
||||||
///
|
///
|
||||||
/// The frontend running in the browser will attempt to make a request to the
|
/// The frontend running in the browser will attempt to make a request to the
|
||||||
/// backend-for-frontend (BFF). If the client is not already authenticated (by
|
/// backend-for-frontend (BFF). If the client is not already authenticated (by
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package se.su.dsv.studentportalen.bff.service;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import se.su.dsv.studentportalen.bff.dto.response.ProfileResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for user profile operations.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ProfileService {
|
||||||
|
|
||||||
|
public ProfileResponse getProfile(OAuth2User currentUser) {
|
||||||
|
String name = currentUser.getAttribute("name");
|
||||||
|
return new ProfileResponse(name, ProfileResponse.Language.ENGLISH);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
@NonNullApi
|
||||||
|
@NonNullFields
|
||||||
|
package se.su.dsv.studentportalen.bff.service;
|
||||||
|
|
||||||
|
import org.springframework.lang.NonNullApi;
|
||||||
|
import org.springframework.lang.NonNullFields;
|
||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import suLogoLandscape from "./assets/SU_logo_optimized.svg";
|
import suLogoLandscape from "./assets/SU_logo_optimized.svg";
|
||||||
|
import { setLanguagePreference } from "./hooks/backend.ts";
|
||||||
import { ProfileContext } from "./hooks/profile.ts";
|
import { ProfileContext } from "./hooks/profile.ts";
|
||||||
import Studentportalen from "./Studentportalen.tsx";
|
import Studentportalen from "./Studentportalen.tsx";
|
||||||
import { useViewTransitioningFetch } from "./hooks/fetch";
|
import { useViewTransitioningFetch } from "./hooks/fetch";
|
||||||
@ -7,6 +9,10 @@ import { useViewTransitioningFetch } from "./hooks/fetch";
|
|||||||
function App() {
|
function App() {
|
||||||
const { data: profile, error } = useViewTransitioningFetch("/profile");
|
const { data: profile, error } = useViewTransitioningFetch("/profile");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguagePreference(profile?.language);
|
||||||
|
}, [profile?.language]);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return splashScreen("Loading...");
|
return splashScreen("Loading...");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import createClient, { Middleware } from "openapi-fetch";
|
import createClient, { Middleware } from "openapi-fetch";
|
||||||
import type { paths } from "../lib/api";
|
import type { paths } from "../lib/api";
|
||||||
|
|
||||||
|
let languagePreference: string | undefined;
|
||||||
|
|
||||||
|
export function setLanguagePreference(language: string | undefined) {
|
||||||
|
languagePreference = language;
|
||||||
|
}
|
||||||
|
|
||||||
const client = createClient<paths>({
|
const client = createClient<paths>({
|
||||||
baseUrl: import.meta.env.VITE_BACKEND_URL,
|
baseUrl: import.meta.env.VITE_BACKEND_URL,
|
||||||
});
|
});
|
||||||
@ -11,6 +17,33 @@ const includeCredentials: Middleware = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getCookie(name: string): string | undefined {
|
||||||
|
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
|
||||||
|
return match ? match[2] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeCsrfToken: Middleware = {
|
||||||
|
onRequest({ request }) {
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
if (method === "POST" || method === "PUT" || method === "DELETE") {
|
||||||
|
const csrfToken = getCookie("XSRF-TOKEN");
|
||||||
|
if (csrfToken) {
|
||||||
|
request.headers.set("X-XSRF-TOKEN", csrfToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const includeAcceptLanguage: Middleware = {
|
||||||
|
onRequest({ request }) {
|
||||||
|
const language =
|
||||||
|
languagePreference ?? navigator.language.split("-")[0] ?? "en";
|
||||||
|
request.headers.set("Accept-Language", language);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const initiateAuthorizationOnUnauthorized: Middleware = {
|
const initiateAuthorizationOnUnauthorized: Middleware = {
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@ -24,6 +57,8 @@ const initiateAuthorizationOnUnauthorized: Middleware = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
client.use(includeCredentials);
|
client.use(includeCredentials);
|
||||||
|
client.use(includeCsrfToken);
|
||||||
|
client.use(includeAcceptLanguage);
|
||||||
client.use(initiateAuthorizationOnUnauthorized);
|
client.use(initiateAuthorizationOnUnauthorized);
|
||||||
|
|
||||||
export function useBackend() {
|
export function useBackend() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user