Public Access
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.
This commit is contained in:
1 parent
762b7898d3
commit
5d608b72e6
12 files changed
+362
-121
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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<String> candidates(HttpServletRequest request) {
|
||||
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
|
||||
}
|
||||
|
||||
private static Optional<String> 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<String> bearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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\"}");
|
||||
}
|
||||
}
|
||||
@@ -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<UserPrincipal> 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<UserPrincipal> principalFromJwt(HttpServletRequest request) {
|
||||
return AccessTokens.candidates(request)
|
||||
.map(jwtService::parseLoginToken)
|
||||
.flatMap(Optional::stream)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private UserPrincipal dummyPrincipal() {
|
||||
|
||||
@@ -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<UserPrincipal> 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);
|
||||
|
||||
@@ -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<UserPrincipal> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user