diff --git a/bff/pom.xml b/bff/pom.xml index d5ac45a..9786d17 100644 --- a/bff/pom.xml +++ b/bff/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.4 + 4.0.1 diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/Studentportalen.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/Studentportalen.java index 6f92487..19d9281 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/Studentportalen.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/Studentportalen.java @@ -5,64 +5,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.context.properties.EnableConfigurationProperties; 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 @EnableConfigurationProperties @ConfigurationPropertiesScan 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) { 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; - } } diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/BackendApiConfiguration.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/BackendApiConfiguration.java similarity index 80% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/BackendApiConfiguration.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/config/BackendApiConfiguration.java index 47eba93..a11ff1f 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/BackendApiConfiguration.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/BackendApiConfiguration.java @@ -1,4 +1,4 @@ -package se.su.dsv.studentportalen.bff; +package se.su.dsv.studentportalen.bff.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/FrontendConfiguration.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/FrontendConfiguration.java similarity index 88% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/FrontendConfiguration.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/config/FrontendConfiguration.java index b6aea15..ad670db 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/FrontendConfiguration.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/FrontendConfiguration.java @@ -1,4 +1,4 @@ -package se.su.dsv.studentportalen.bff; +package se.su.dsv.studentportalen.bff.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/config/SecurityConfiguration.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/SecurityConfiguration.java new file mode 100644 index 0000000..d188365 --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/config/SecurityConfiguration.java @@ -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; + } +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/ProfileController.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/ProfileController.java similarity index 57% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/ProfileController.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/controller/ProfileController.java index 4483c71..eb97bd2 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/ProfileController.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/ProfileController.java @@ -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.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.RequestMapping; 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 @RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE) public class ProfileController { + + private final ProfileService service; + + public ProfileController(ProfileService service) { + this.service = service; + } + @GetMapping("/profile") - public Profile getProfile( + public ProfileResponse getProfile( @AuthenticationPrincipal(errorOnInvalidType = true) OAuth2User currentUser) { - return new Profile(currentUser.getAttribute("name"), Profile.Language.ENGLISH); + return service.getProfile(currentUser); } } diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/TestController.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/TestController.java similarity index 88% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/TestController.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/controller/TestController.java index 950a214..b977a13 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/TestController.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/TestController.java @@ -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.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestClient; +import se.su.dsv.studentportalen.bff.config.BackendApiConfiguration; import java.time.Duration; import java.util.concurrent.ExecutionException; @@ -15,6 +16,9 @@ import java.util.concurrent.StructuredTaskScope.Subtask; @Profile("development") 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 BackendApiConfiguration backendApiConfiguration; @@ -67,13 +71,13 @@ public class TestController { @RequestMapping("/name") public String name() throws InterruptedException { - Thread.sleep(Duration.ofSeconds(2)); + Thread.sleep(Duration.ofSeconds(NAME_DELAY_SECONDS)); return "Greg"; } @RequestMapping("/email") public String email() throws InterruptedException { - Thread.sleep(Duration.ofSeconds(3)); + Thread.sleep(Duration.ofSeconds(EMAIL_DELAY_SECONDS)); return "greg@localhost"; } diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/package-info.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/package-info.java similarity index 53% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/package-info.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/controller/package-info.java index 476dc55..4315538 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/package-info.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/controller/package-info.java @@ -1,7 +1,6 @@ -/// Everything related to the API used by the frontend @NonNullApi @NonNullFields -package se.su.dsv.studentportalen.bff.frontend; +package se.su.dsv.studentportalen.bff.controller; import org.springframework.lang.NonNullApi; import org.springframework.lang.NonNullFields; diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/Profile.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/ProfileResponse.java similarity index 53% rename from bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/Profile.java rename to bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/ProfileResponse.java index f0be027..a5c655c 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/frontend/profile/Profile.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/ProfileResponse.java @@ -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 java.util.Objects; -public record Profile( - @JsonProperty(value = "name", required = true) String name, - @JsonProperty(value = "language", required = true) Language language) +/** + * User profile information. + */ +public record ProfileResponse( + @JsonProperty(value = "name", required = true) + String name, + + @JsonProperty(value = "language", required = true) + Language language) { public enum Language { @JsonProperty("sv") SWEDISH, @JsonProperty("en") ENGLISH } - public Profile { + public ProfileResponse { Objects.requireNonNull(name, "name must be specified"); Objects.requireNonNull(language, "language must be specified"); } diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/package-info.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/package-info.java new file mode 100644 index 0000000..238166f --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/dto/response/package-info.java @@ -0,0 +1,6 @@ +@NonNullApi +@NonNullFields +package se.su.dsv.studentportalen.bff.dto.response; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/login/BFFAuthenticationEntryPoint.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/login/BFFAuthenticationEntryPoint.java index 32290fe..9992289 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/login/BFFAuthenticationEntryPoint.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/login/BFFAuthenticationEntryPoint.java @@ -8,7 +8,11 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; public class BFFAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) { + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + { String loginUri = ServletUriComponentsBuilder.fromRequest(request) .replacePath("/oauth2/authorization/studentportalen") .build() diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/login/package-info.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/login/package-info.java index ad2e5cc..986d63e 100644 --- a/bff/src/main/java/se/su/dsv/studentportalen/bff/login/package-info.java +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/login/package-info.java @@ -1,5 +1,6 @@ /// 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 /// backend-for-frontend (BFF). If the client is not already authenticated (by diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/service/ProfileService.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/service/ProfileService.java new file mode 100644 index 0000000..a21963c --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/service/ProfileService.java @@ -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); + } +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/service/package-info.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/service/package-info.java new file mode 100644 index 0000000..8740265 --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/service/package-info.java @@ -0,0 +1,6 @@ +@NonNullApi +@NonNullFields +package se.su.dsv.studentportalen.bff.service; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77e508c..195cbb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import "./App.css"; import suLogoLandscape from "./assets/SU_logo_optimized.svg"; +import { setLanguagePreference } from "./hooks/backend.ts"; import { ProfileContext } from "./hooks/profile.ts"; import Studentportalen from "./Studentportalen.tsx"; import { useViewTransitioningFetch } from "./hooks/fetch"; @@ -7,6 +9,10 @@ import { useViewTransitioningFetch } from "./hooks/fetch"; function App() { const { data: profile, error } = useViewTransitioningFetch("/profile"); + useEffect(() => { + setLanguagePreference(profile?.language); + }, [profile?.language]); + if (!profile) { return splashScreen("Loading..."); } diff --git a/frontend/src/hooks/backend.ts b/frontend/src/hooks/backend.ts index e5f4bcc..22242e0 100644 --- a/frontend/src/hooks/backend.ts +++ b/frontend/src/hooks/backend.ts @@ -1,6 +1,12 @@ import createClient, { Middleware } from "openapi-fetch"; import type { paths } from "../lib/api"; +let languagePreference: string | undefined; + +export function setLanguagePreference(language: string | undefined) { + languagePreference = language; +} + const client = createClient({ 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 = { onResponse({ response }) { if (response.status === 401) { @@ -24,6 +57,8 @@ const initiateAuthorizationOnUnauthorized: Middleware = { }; client.use(includeCredentials); +client.use(includeCsrfToken); +client.use(includeAcceptLanguage); client.use(initiateAuthorizationOnUnauthorized); export function useBackend() {