From 5d608b72e6c9a6439a51017df922551db830e7bb Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 13:45:08 +0200 Subject: [PATCH] feat(security): accept the auth cookie and guard against CSRF Core reads the login token from the tbp_access cookie or a Bearer header, whichever verifies, and parses it once per request. Unsafe requests need an X-TBP-Client or Authorization header, CORS allows only the exact frontend origin, and a missing or expired token answers 401 instead of 403. --- readme.md | 6 +- .../configuration/security/AccessTokens.java | 41 +++++++ .../security/ClientHeaderFilter.java | 41 +++++++ .../security/DevJwtAuthenticationFilter.java | 28 ++--- .../security/JwtAuthenticationFilter.java | 46 +++----- .../configuration/security/JwtService.java | 28 ++--- .../security/SecurityConfig.java | 16 ++- .../security/AccessTokenFiltersTest.java | 100 ++++++++++++++++++ .../security/ClientHeaderFilterTest.java | 56 ++++++++++ .../security/ExpiredAccessTokenTest.java | 42 ++++++++ .../security/JwtServiceTest.java | 56 ++++------ .../api/LibraryControllerBacklogTest.java | 23 ++-- 12 files changed, 362 insertions(+), 121 deletions(-) create mode 100644 src/main/java/core_service/configuration/security/AccessTokens.java create mode 100644 src/main/java/core_service/configuration/security/ClientHeaderFilter.java create mode 100644 src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java create mode 100644 src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java create mode 100644 src/test/java/core_service/configuration/security/ExpiredAccessTokenTest.java diff --git a/readme.md b/readme.md index b093f26..363d7e5 100644 --- a/readme.md +++ b/readme.md @@ -37,8 +37,10 @@ To Be Played is split into three repositories: Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay -until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and -`steamId` from the claims. +until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's +`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims. +`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the +frontend origin. A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. diff --git a/src/main/java/core_service/configuration/security/AccessTokens.java b/src/main/java/core_service/configuration/security/AccessTokens.java new file mode 100644 index 0000000..1cc8b9a --- /dev/null +++ b/src/main/java/core_service/configuration/security/AccessTokens.java @@ -0,0 +1,41 @@ +package core_service.configuration.security; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Where a request can carry the login token: the access cookie that Auth sets, or a Bearer header (API clients and + * tests). Both are tried; a stale cookie next to a valid header must not block the request. + */ +final class AccessTokens { + + static final String ACCESS_COOKIE = "tbp_access"; + private static final String BEARER = "Bearer "; + + private AccessTokens() { + } + + static Stream candidates(HttpServletRequest request) { + return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream); + } + + private static Optional cookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + return Arrays.stream(cookies) + .filter(cookie -> ACCESS_COOKIE.equals(cookie.getName()) && !cookie.getValue().isBlank()) + .map(Cookie::getValue) + .findFirst(); + } + + private static Optional bearer(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty(); + } +} diff --git a/src/main/java/core_service/configuration/security/ClientHeaderFilter.java b/src/main/java/core_service/configuration/security/ClientHeaderFilter.java new file mode 100644 index 0000000..a60aa14 --- /dev/null +++ b/src/main/java/core_service/configuration/security/ClientHeaderFilter.java @@ -0,0 +1,41 @@ +package core_service.configuration.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Set; + +/** + * CSRF guard for the login cookie. A request that changes data must carry {@code X-TBP-Client} or + * {@code Authorization}. A page on another origin can send neither without a CORS preflight, and CORS allows only + * our own origins. + */ +public class ClientHeaderFilter extends OncePerRequestFilter { + + public static final String CLIENT_HEADER = "X-TBP-Client"; + private static final Set SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS"); + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + if (SAFE_METHODS.contains(request.getMethod()) + || request.getHeader(CLIENT_HEADER) != null + || request.getHeader("Authorization") != null) { + filterChain.doFilter(request, response); + return; + } + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"code\":\"CLIENT_HEADER_MISSING\",\"message\":\"Missing X-TBP-Client header\"}"); + } +} diff --git a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java index 45a8a6f..3d8b851 100644 --- a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java +++ b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java @@ -17,6 +17,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; +import java.util.Optional; /** * Dev profile authentication filter. @@ -56,28 +57,11 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private java.util.Optional principalFromJwt(HttpServletRequest request) { - String authHeader = request.getHeader("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return java.util.Optional.empty(); - } - String jwt = authHeader.substring(7); - if (!jwtService.isTokenValid(jwt)) { - log.debug("Dev mode: invalid JWT, falling back to dummy principal"); - return java.util.Optional.empty(); - } - try { - String email = jwtService.extractEmail(jwt); - String userId = jwtService.extractUserId(jwt); - Long steamId = jwtService.extractSteamId(jwt); - if (email == null || userId == null) { - return java.util.Optional.empty(); - } - return java.util.Optional.of(new UserPrincipal(userId, email, steamId)); - } catch (Exception e) { - log.warn("Dev mode: JWT parsing failed: {}", e.getMessage()); - return java.util.Optional.empty(); - } + private Optional principalFromJwt(HttpServletRequest request) { + return AccessTokens.candidates(request) + .map(jwtService::parseLoginToken) + .flatMap(Optional::stream) + .findFirst(); } private UserPrincipal dummyPrincipal() { diff --git a/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java b/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java index 1eeefdb..0daac99 100644 --- a/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java +++ b/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java @@ -6,7 +6,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -17,11 +16,11 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; +import java.util.Optional; @Component("jwtAuthenticationFilter") @Profile("!dev") @RequiredArgsConstructor -@Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; @@ -33,37 +32,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @NonNull FilterChain filterChain ) throws ServletException, IOException { - final String authHeader = request.getHeader("Authorization"); - - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - - final String jwt = authHeader.substring(7); - if (!jwtService.isTokenValid(jwt)) { - filterChain.doFilter(request, response); - return; - } - - try { - final String userEmail = jwtService.extractEmail(jwt); - final String userId = jwtService.extractUserId(jwt); - final Long steamId = jwtService.extractSteamId(jwt); - - if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { - - UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId); - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - principal, - null, - Collections.emptyList() - ); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } catch (Exception e) { - log.error("JWT Validation failed: {}", e.getMessage()); + Optional principal = AccessTokens.candidates(request) + .map(jwtService::parseLoginToken) + .flatMap(Optional::stream) + .findFirst(); + if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + principal.get(), + null, + Collections.emptyList() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); } filterChain.doFilter(request, response); diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index caaa428..f483e40 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -1,6 +1,7 @@ package core_service.configuration.security; import core_service.shared.security.ProfileTicketVerifier; +import core_service.shared.security.UserPrincipal; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; @@ -14,7 +15,7 @@ import java.util.Optional; @Service public class JwtService implements ProfileTicketVerifier { - // Only non-login tokens carry this claim; isTokenValid rejects them. + // Only non-login tokens carry this claim; parseLoginToken rejects them. private static final String TYPE_CLAIM = "typ"; private static final String PROFILE_TICKET_TYPE = "public-profile"; @@ -28,27 +29,20 @@ public class JwtService implements ProfileTicketVerifier { return parser.parseSignedClaims(token).getPayload(); } - public String extractEmail(String token) { - return claims(token).getSubject(); - } - - public String extractUserId(String token) { - return claims(token).get("userId", String.class); - } - - public Long extractSteamId(String token) { - return claims(token).get("steamId", Long.class); - } - /** - * True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login. + * The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is + * signed with the same key, but is no login. */ - public boolean isTokenValid(String token) { + public Optional parseLoginToken(String token) { try { Claims claims = claims(token); - return claims.get(TYPE_CLAIM) == null; + String userId = claims.get("userId", String.class); + if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) { + return Optional.empty(); + } + return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class))); } catch (Exception e) { - return false; + return Optional.empty(); } } diff --git a/src/main/java/core_service/configuration/security/SecurityConfig.java b/src/main/java/core_service/configuration/security/SecurityConfig.java index 95d3a53..c40d4a4 100644 --- a/src/main/java/core_service/configuration/security/SecurityConfig.java +++ b/src/main/java/core_service/configuration/security/SecurityConfig.java @@ -6,17 +6,21 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.OncePerRequestFilter; +import java.time.Duration; import java.util.Arrays; import java.util.List; @@ -43,10 +47,13 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins)); + // Exact origins only: with credentials, a pattern would send the login cookie to every matching site. + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(false); + configuration.setAllowedHeaders(List.of( + "Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER, "X-Profile-Ticket")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(Duration.ofHours(1)); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); @@ -59,11 +66,14 @@ public class SecurityConfig { .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 401, not Spring's default 403: the frontend renews the access cookie only on a 401. + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth .requestMatchers(WHITE_LIST_URLS).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) + .addFilterAfter(new ClientHeaderFilter(), CorsFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java b/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java new file mode 100644 index 0000000..556dde8 --- /dev/null +++ b/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java @@ -0,0 +1,100 @@ +package core_service.configuration.security; + +import core_service.shared.security.UserPrincipal; +import core_service.shared.test.AbstractUnitTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.lenient; + +/** + * Both login filters take the token from the access cookie or the Bearer header, the first one that verifies. + */ +class AccessTokenFiltersTest extends AbstractUnitTest { + + private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L); + + @Mock + private JwtService jwtService; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private Object principalAfter(jakarta.servlet.Filter filter, MockHttpServletRequest request) throws Exception { + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication == null ? null : authentication.getPrincipal(); + } + + private void validToken(String token) { + lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER)); + } + + @Test + void cookieAuthenticates() throws Exception { + validToken("good"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good")); + + assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + } + + @Test + void bearerAuthenticates() throws Exception { + validToken("good"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer good"); + + assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + } + + @Test + void staleCookieFallsBackToTheBearerHeader() throws Exception { + validToken("good"); + lenient().when(jwtService.parseLoginToken("stale")).thenReturn(Optional.empty()); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale")); + request.addHeader("Authorization", "Bearer good"); + + assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + } + + @Test + void invalidTokenStaysAnonymous() throws Exception { + lenient().when(jwtService.parseLoginToken("bad")).thenReturn(Optional.empty()); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad")); + + assertNull(principalAfter(new JwtAuthenticationFilter(jwtService), request)); + } + + @Test + void devFilter_UsesTheCookieUser() throws Exception { + validToken("good"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good")); + + assertEquals(USER, principalAfter(new DevJwtAuthenticationFilter(jwtService), request)); + } + + @Test + void devFilter_WithoutTokenUsesTheDummyUser() throws Exception { + UserPrincipal principal = (UserPrincipal) principalAfter(new DevJwtAuthenticationFilter(jwtService), + new MockHttpServletRequest()); + + assertEquals("1", principal.id()); + } +} diff --git a/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java new file mode 100644 index 0000000..b662aec --- /dev/null +++ b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java @@ -0,0 +1,56 @@ +package core_service.configuration.security; + +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The dev profile logs every request in, so only the CSRF header and CORS decide here. + */ +@AutoConfigureMockMvc +class ClientHeaderFilterTest extends AbstractIntegrationTest { + + private static final String FRONTEND = "https://tbp.andreas-dahm.eu"; + + @Autowired + private MockMvc mockMvc; + + @Test + void postWithoutClientHeader_Is403() throws Exception { + mockMvc.perform(post("/api/library/sync")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING")); + } + + @Test + void postWithClientHeader_PassesTheFilter() throws Exception { + int status = mockMvc.perform(post("/api/library/sync").header(ClientHeaderFilter.CLIENT_HEADER, "test")) + .andReturn().getResponse().getStatus(); + + org.junit.jupiter.api.Assertions.assertNotEquals(403, status); + } + + @Test + void corsPreflight_AllowsOnlyTheFrontendWithCookies() throws Exception { + mockMvc.perform(options("/api/library/sync") + .header("Origin", FRONTEND) + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "x-tbp-client")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", FRONTEND)) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")); + + mockMvc.perform(options("/api/library/sync") + .header("Origin", "https://evil.andreas-dahm.eu") + .header("Access-Control-Request-Method", "POST")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/core_service/configuration/security/ExpiredAccessTokenTest.java b/src/test/java/core_service/configuration/security/ExpiredAccessTokenTest.java new file mode 100644 index 0000000..694a2b4 --- /dev/null +++ b/src/test/java/core_service/configuration/security/ExpiredAccessTokenTest.java @@ -0,0 +1,42 @@ +package core_service.configuration.security; + +import core_service.shared.test.AbstractIntegrationTest; +import jakarta.servlet.http.Cookie; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Date; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The real (non-dev) filter chain, where the token decides. + */ +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class ExpiredAccessTokenTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + private static String expiredAccessToken() { + return Jwts.builder() + .subject("a@b.de") + .claim("userId", "42") + .issuedAt(new Date(System.currentTimeMillis() - 120_000)) + .expiration(new Date(System.currentTimeMillis() - 60_000)) + .signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA) + .compact(); + } + + @Test + void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception { + mockMvc.perform(get("/api/library/summary").cookie(new Cookie(AccessTokens.ACCESS_COOKIE, expiredAccessToken()))) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index 692f8e2..f95b58d 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import core_service.shared.security.UserPrincipal; import core_service.shared.test.AbstractUnitTest; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.BeforeEach; @@ -54,51 +55,40 @@ class JwtServiceTest extends AbstractUnitTest { } @Test - void extractEmail_Success() { - var email = "test@example.com"; - var token = signToken("0815", email, null); + void parseLoginToken_ReturnsTheUser() { + String token = signToken("0815", "test@example.com", 123456789L); - assertEquals(email, jwtService.extractEmail(token)); + assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)), + jwtService.parseLoginToken(token)); } @Test - void extractUserId_Success() { - var userId = "0815"; - var token = signToken(userId, "test@example.com", null); - - assertEquals(userId, jwtService.extractUserId(token)); + void parseLoginToken_RejectsGarbage() { + assertEquals(Optional.empty(), jwtService.parseLoginToken("not-a-jwt")); } @Test - void extractSteamId_Success() { - var steamId = 123456789L; - var token = signToken("0815", "test@example.com", steamId); - - assertEquals(steamId, jwtService.extractSteamId(token)); - } - - @Test - void isTokenValid_Success() { - var token = signToken("0815", "test@example.com", null); - - assertTrue(jwtService.isTokenValid(token)); - } - - @Test - void isTokenValid_Failure_garbage() { - assertFalse(jwtService.isTokenValid("not-a-jwt")); - } - - @Test - void isTokenValid_Failure_wrongKey() throws Exception { + void parseLoginToken_RejectsAForeignKey() throws Exception { KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); String token = Jwts.builder() .subject("test@example.com") + .claim("userId", "0815") .expiration(new Date(System.currentTimeMillis() + 60_000)) .signWith(other.getPrivate(), Jwts.SIG.EdDSA) .compact(); - assertFalse(jwtService.isTokenValid(token)); + assertEquals(Optional.empty(), jwtService.parseLoginToken(token)); + } + + @Test + void parseLoginToken_RejectsATokenWithoutUserId() { + String token = Jwts.builder() + .subject("test@example.com") + .expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(signingKey, Jwts.SIG.EdDSA) + .compact(); + + assertEquals(Optional.empty(), jwtService.parseLoginToken(token)); } private String signTicket(String type, String profileUserId, long expiresInMs) { @@ -130,7 +120,7 @@ class JwtServiceTest extends AbstractUnitTest { } @Test - void isTokenValid_ProfileTicketIsNoLogin() { - assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000))); + void parseLoginToken_ProfileTicketIsNoLogin() { + assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000))); } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java index 1ec171c..e1540e7 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; +import core_service.configuration.security.ClientHeaderFilter; import core_service.shared.test.AbstractIntegrationTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,7 +57,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateBacklog_StoresAllFields() throws Exception { - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("FINISHED")) @@ -76,7 +77,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { game.setNotes("old"); userGameRepository.save(game); - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"DROPPED\",\"notes\":\" \"}")) .andExpect(status().isOk()); @@ -95,7 +96,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { "{\"status\":\"PLAYING\",\"rating\":6}", "{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"}) void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception { - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON).content(body)) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isBadRequest()); assertEquals(GameStatus.BACKLOG, stored().getStatus()); @@ -103,7 +104,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateBacklog_FractionalRating_Is400() throws Exception { - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\",\"rating\":4.7}")) .andExpect(status().isBadRequest()); @@ -113,7 +114,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateBacklog_NotesTooLong_Is400() throws Exception { String notes = "x".repeat(2001); - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}")) .andExpect(status().isBadRequest()); } @@ -124,7 +125,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { game.setRemovedAt(Instant.now()); userGameRepository.save(game); - mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); @@ -132,7 +133,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateBacklog_GameNotOwned_Is404() throws Exception { - mockMvc.perform(put("/api/library/games/999/backlog").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/999/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isNotFound()); } @@ -145,7 +146,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { game.setPriority(GamePriority.HIGH); userGameRepository.save(game); - mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("PLAYING")) @@ -160,7 +161,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateStatus_MissingStatus_Is400() throws Exception { - mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -173,7 +174,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { game.setRemovedAt(Instant.now()); userGameRepository.save(game); - mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); @@ -181,7 +182,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { @Test void updateStatus_GameNotOwned_Is404() throws Exception { - mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON) + mockMvc.perform(put("/api/library/games/999/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PLAYING\"}")) .andExpect(status().isNotFound()); }