From 930e08e6dd2ac1a5df36be6c2cb752047f02536a Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 13:45:04 +0200 Subject: [PATCH] feat(login): cookie session with rotating refresh token The login token moves out of localStorage into an HttpOnly cookie (15 min), so a script on the page can no longer read it. A random refresh token (30 days, sliding) renews it; only its SHA-256 is stored. Each refresh replaces the token, and a replaced token that comes back revokes every token of that login. Unsafe requests need an X-TBP-Client or Authorization header, and CORS allows only the exact frontend origin. A missing or expired token now answers 401 instead of 403, so the frontend can refresh. --- readme.md | 23 +- .../configuration/SchedulingConfig.java | 9 + .../security/ClientHeaderFilter.java | 41 +++ .../security/JwtAuthenticationFilter.java | 60 ++-- .../configuration/security/JwtService.java | 61 ++-- .../security/SecurityConfig.java | 16 +- .../modules/login/api/LoginController.java | 55 +++- .../login/api/dto/response/LoginResponse.java | 5 +- .../modules/login/model/RefreshToken.java | 7 + .../modules/login/model/Session.java | 4 + .../persistence/MinimalUserRepository.java | 2 + .../MinimalUserRepositoryImpl.java | 5 + .../persistence/RefreshTokenRepository.java | 23 ++ .../RefreshTokenRepositoryImpl.java | 64 ++++ .../entities/RefreshTokenEntity.java | 45 +++ .../persistence/mapper/EntityMapper.java | 7 + .../modules/login/service/LoginService.java | 17 +- .../login/service/RefreshTokenService.java | 125 ++++++++ .../modules/user/api/UserController.java | 13 +- .../dto/response/UserSettingsResponse.java | 7 - .../auth_service/shared/api/AuthCookies.java | 67 ++++ .../shared/security/SessionProperties.java | 25 ++ src/main/resources/application.yml | 3 + .../db/migration/V10__Add_Refresh_Tokens.sql | 15 + .../security/ExpiredAccessTokenTest.java | 65 ++++ .../security/JwtServiceTest.java | 45 +-- .../security/SessionFlowTest.java | 301 ++++++++++++++++++ .../login/api/LoginControllerTest.java | 79 ++++- .../login/service/LoginServiceTest.java | 42 ++- .../modules/user/api/UserControllerTest.java | 16 +- 30 files changed, 1096 insertions(+), 151 deletions(-) create mode 100644 src/main/java/auth_service/configuration/SchedulingConfig.java create mode 100644 src/main/java/auth_service/configuration/security/ClientHeaderFilter.java create mode 100644 src/main/java/auth_service/modules/login/model/RefreshToken.java create mode 100644 src/main/java/auth_service/modules/login/model/Session.java create mode 100644 src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java create mode 100644 src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java create mode 100644 src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java create mode 100644 src/main/java/auth_service/modules/login/service/RefreshTokenService.java delete mode 100644 src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java create mode 100644 src/main/java/auth_service/shared/api/AuthCookies.java create mode 100644 src/main/java/auth_service/shared/security/SessionProperties.java create mode 100644 src/main/resources/db/migration/V10__Add_Refresh_Tokens.sql create mode 100644 src/test/java/auth_service/configuration/security/ExpiredAccessTokenTest.java create mode 100644 src/test/java/auth_service/configuration/security/SessionFlowTest.java diff --git a/readme.md b/readme.md index ff5dd17..0a61f4b 100644 --- a/readme.md +++ b/readme.md @@ -20,6 +20,12 @@ To Be Played is split into three repositories: - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the public key as a JWK, so Core finds the right key after a rotation. +- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in + `tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in + `tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced + token that comes back revokes all tokens of that login. Logout revokes them too. +- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the + frontend origin. A `Bearer` header still works for API clients and tests. - User settings: display name (unique, case-insensitive), Steam ID, language, dark mode. - Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. @@ -32,10 +38,12 @@ To Be Played is split into three repositories: | Method | Path | Auth | |---|---|---| | `POST` | `/api/registration/new` | public | -| `POST` | `/api/auth/login` | public | -| `GET` | `/api/users/me` | JWT | -| `PUT` | `/api/users/settings` | JWT | -| `PUT` | `/api/users/settings/appearance` | JWT | +| `POST` | `/api/auth/login` | public, sets both cookies | +| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies | +| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies | +| `GET` | `/api/users/me` | access token | +| `PUT` | `/api/users/settings` | access token, sets a new access cookie | +| `PUT` | `/api/users/settings/appearance` | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | public | @@ -76,6 +84,11 @@ Needs Java 25 and Docker. Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the service listens on `http://localhost:8080`. Health: `/actuator/health`, metrics: `/actuator/prometheus`. +The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core +gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no +cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every +local port. + Configuration comes from environment variables: | Variable | Purpose | @@ -103,8 +116,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne ## Roadmap -- Short-lived access token plus rotating refresh token in an httpOnly cookie, with CSRF protection. Today the token - lives 24 h in `localStorage` and cannot be revoked. - Account deletion and data export. ## License diff --git a/src/main/java/auth_service/configuration/SchedulingConfig.java b/src/main/java/auth_service/configuration/SchedulingConfig.java new file mode 100644 index 0000000..f191178 --- /dev/null +++ b/src/main/java/auth_service/configuration/SchedulingConfig.java @@ -0,0 +1,9 @@ +package auth_service.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/auth_service/configuration/security/ClientHeaderFilter.java b/src/main/java/auth_service/configuration/security/ClientHeaderFilter.java new file mode 100644 index 0000000..3c80645 --- /dev/null +++ b/src/main/java/auth_service/configuration/security/ClientHeaderFilter.java @@ -0,0 +1,41 @@ +package auth_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 cookies. 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( + 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/auth_service/configuration/security/JwtAuthenticationFilter.java b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java index 20f1e2d..79fd4a8 100644 --- a/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java +++ b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java @@ -1,12 +1,12 @@ package auth_service.configuration.security; +import auth_service.shared.api.AuthCookies; import auth_service.shared.security.UserPrincipal; import jakarta.servlet.FilterChain; 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.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -16,12 +16,19 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; +import java.util.Optional; +import java.util.stream.Stream; +/** + * Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that + * verifies. A stale cookie next to a valid header must not block the request. + */ @Component @RequiredArgsConstructor -@Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String BEARER = "Bearer "; + private final JwtService jwtService; @Override @@ -31,39 +38,26 @@ 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 = Stream.of(AuthCookies.read(request, AuthCookies.ACCESS_COOKIE), bearer(request)) + .flatMap(Optional::stream) + .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); } + + 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/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index b762c1b..ee592a7 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -2,7 +2,11 @@ package auth_service.configuration.security; import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.PublicKeyPublisher; +import auth_service.shared.security.SessionProperties; import auth_service.shared.security.TokenGenerator; +import auth_service.shared.security.UserPrincipal; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Jwks; import jakarta.annotation.PostConstruct; @@ -17,6 +21,7 @@ import java.util.Base64; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Service responsible for generating and validating JSON Web Tokens (JWT). @@ -25,8 +30,6 @@ import java.util.Map; @Service public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher { - // Token validity: 24 hours in milliseconds - private static final long JWT_EXPIRATION_MS = 86400000; private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000; // Only non-login tokens carry this claim; isTokenValid rejects them. private static final String TYPE_CLAIM = "typ"; @@ -34,11 +37,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe private static final String ED25519 = "Ed25519"; private final JwtConfig jwtConfig; + private final SessionProperties sessionProperties; private PrivateKey privateKey; private PublicKey publicKey; + private JwtParser parser; - public JwtService(JwtConfig jwtConfig) { + public JwtService(JwtConfig jwtConfig, SessionProperties sessionProperties) { this.jwtConfig = jwtConfig; + this.sessionProperties = sessionProperties; } @PostConstruct @@ -49,6 +55,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe } this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey()); this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); + this.parser = Jwts.parser().verifyWith(publicKey).build(); } @Override @@ -58,7 +65,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe .subject(email) .claim("userId", userId) .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS)); + .expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis())); if (steamId != null) { builder.claim("steamId", steamId); @@ -92,46 +99,20 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe .build()); } - public String extractEmail(String token) { - return Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); - } - - public String extractUserId(String token) { - return Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .getPayload() - .get("userId", String.class); - } - - public Long extractSteamId(String token) { - return Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .getPayload() - .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 { - var claims = Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .getPayload(); - return claims.get(TYPE_CLAIM) == null; + Claims claims = parser.parseSignedClaims(token).getPayload(); + 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 _) { - return false; + return Optional.empty(); } } diff --git a/src/main/java/auth_service/configuration/security/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java index 2a36d84..29f0e40 100644 --- a/src/main/java/auth_service/configuration/security/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -5,6 +5,7 @@ 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; @@ -12,11 +13,14 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; 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 java.time.Duration; import java.util.Arrays; import java.util.List; @@ -66,13 +70,14 @@ public class SecurityConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // Use OriginPatterns instead of AllowedOrigins to safely support "*" wildcards - configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins)); + // Exact origins only: with credentials, a pattern would send the login cookies to every matching site. + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedHeaders(List.of("Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER)); // Lets the login page read how long a rate-limit block lasts configuration.setExposedHeaders(List.of("Retry-After")); - configuration.setAllowCredentials(false); // Set to true later if we use cookies + configuration.setAllowCredentials(true); + configuration.setMaxAge(Duration.ofHours(1)); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // Apply this CORS config to all endpoints @@ -87,11 +92,14 @@ public class SecurityConfig { // Tell Spring Security to use the CorsConfigurationSource bean defined above .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/main/java/auth_service/modules/login/api/LoginController.java b/src/main/java/auth_service/modules/login/api/LoginController.java index bd6f6b2..6c2e3f5 100644 --- a/src/main/java/auth_service/modules/login/api/LoginController.java +++ b/src/main/java/auth_service/modules/login/api/LoginController.java @@ -3,20 +3,32 @@ package auth_service.modules.login.api; import auth_service.modules.login.api.dto.request.LoginRequest; import auth_service.modules.login.api.dto.response.LoginResponse; import auth_service.modules.login.api.mapper.ApiMapper; +import auth_service.modules.login.model.Session; import auth_service.modules.login.service.LoginService; +import auth_service.modules.login.service.RefreshTokenService; +import auth_service.shared.api.AuthCookies; import auth_service.shared.api.ClientIp; +import auth_service.shared.api.dto.response.ErrorResponse; import io.micrometer.core.annotation.Timed; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; +import java.util.Optional; + /** - * API handling authentication requests. + * API handling authentication requests. The tokens travel only in HttpOnly cookies, never in a body. */ @RestController @RequestMapping("/api/auth") @@ -24,6 +36,8 @@ import org.springframework.web.bind.annotation.RestController; public class LoginController { private final LoginService loginService; + private final RefreshTokenService refreshTokenService; + private final AuthCookies authCookies; /** * Authenticates a user based on email and password. @@ -33,10 +47,43 @@ public class LoginController { */ @PostMapping("/login") @Timed(value = "auth.login.requests.time", description = "Time taken to process login requests") - public ResponseEntity login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { + public ResponseEntity login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest, + @CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String previousRefreshToken) { var user = ApiMapper.toModel(request); - var token = loginService.login(user, ClientIp.of(httpRequest)); - return ResponseEntity.ok(new LoginResponse("Login successful", token)); + Session session = loginService.login(user, ClientIp.of(httpRequest), previousRefreshToken); + return ResponseEntity.ok() + .headers(cookies(sessionCookies(session))) + .body(new LoginResponse("Login successful")); + } + + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) { + Optional session = refreshTokenService.refresh(refreshToken); + if (session.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .headers(cookies(authCookies.cleared())) + .contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("SESSION_EXPIRED", "Please log in again")); + } + return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build(); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) { + refreshTokenService.revoke(refreshToken); + return ResponseEntity.noContent().headers(cookies(authCookies.cleared())).build(); + } + + private List sessionCookies(Session session) { + return List.of(authCookies.access(session.accessToken()), authCookies.refresh(session.refreshToken())); + } + + private static HttpHeaders cookies(List cookies) { + HttpHeaders headers = new HttpHeaders(); + cookies.forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString())); + return headers; } } diff --git a/src/main/java/auth_service/modules/login/api/dto/response/LoginResponse.java b/src/main/java/auth_service/modules/login/api/dto/response/LoginResponse.java index 1c38aea..fe6ad75 100644 --- a/src/main/java/auth_service/modules/login/api/dto/response/LoginResponse.java +++ b/src/main/java/auth_service/modules/login/api/dto/response/LoginResponse.java @@ -1,8 +1,7 @@ package auth_service.modules.login.api.dto.response; /** - * DTO representing the outgoing login response. - * The email will be null if the login fails. + * DTO representing the outgoing login response. The tokens are in the Set-Cookie headers. */ -public record LoginResponse(String message, String token) { +public record LoginResponse(String message) { } diff --git a/src/main/java/auth_service/modules/login/model/RefreshToken.java b/src/main/java/auth_service/modules/login/model/RefreshToken.java new file mode 100644 index 0000000..208b455 --- /dev/null +++ b/src/main/java/auth_service/modules/login/model/RefreshToken.java @@ -0,0 +1,7 @@ +package auth_service.modules.login.model; + +import java.time.Instant; +import java.util.UUID; + +public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt) { +} diff --git a/src/main/java/auth_service/modules/login/model/Session.java b/src/main/java/auth_service/modules/login/model/Session.java new file mode 100644 index 0000000..246d927 --- /dev/null +++ b/src/main/java/auth_service/modules/login/model/Session.java @@ -0,0 +1,4 @@ +package auth_service.modules.login.model; + +public record Session(String accessToken, String refreshToken) { +} diff --git a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepository.java b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepository.java index 5c90aa2..df3d74d 100644 --- a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepository.java +++ b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepository.java @@ -16,5 +16,7 @@ public interface MinimalUserRepository { */ Optional findByEmail(String email); + Optional findById(long userId); + void updateLoginDate(long userId); } diff --git a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java index ed2493b..528e4be 100644 --- a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java @@ -35,6 +35,11 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository { .map(EntityMapper::mapToModel); } + @Override + public Optional findById(long userId) { + return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel); + } + @Override public void updateLoginDate(long userId) { var userEntity = entityManager.find(MinimalUserEntity.class, userId); diff --git a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java new file mode 100644 index 0000000..1bed2eb --- /dev/null +++ b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java @@ -0,0 +1,23 @@ +package auth_service.modules.login.persistence; + +import auth_service.modules.login.model.RefreshToken; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public interface RefreshTokenRepository { + + void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt); + + /** + * Locks the row until the transaction ends, so two refreshes with the same token run one after the other. + */ + Optional findByHashForUpdate(String tokenHash); + + void markRotated(long id, Instant rotatedAt); + + void revokeFamily(UUID familyId, Instant revokedAt); + + int deleteExpired(Instant now); +} diff --git a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java new file mode 100644 index 0000000..acddeda --- /dev/null +++ b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java @@ -0,0 +1,64 @@ +package auth_service.modules.login.persistence; + +import auth_service.modules.login.model.RefreshToken; +import auth_service.modules.login.persistence.entities.RefreshTokenEntity; +import auth_service.modules.login.persistence.mapper.EntityMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt) { + RefreshTokenEntity entity = new RefreshTokenEntity(); + entity.setUserId(userId); + entity.setFamilyId(familyId); + entity.setTokenHash(tokenHash); + entity.setCreatedAt(createdAt); + entity.setExpiresAt(expiresAt); + entityManager.persist(entity); + } + + @Override + public Optional findByHashForUpdate(String tokenHash) { + return entityManager.createQuery( + "SELECT t FROM RefreshTokenEntity t WHERE t.tokenHash = :hash", RefreshTokenEntity.class) + .setParameter("hash", tokenHash) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .getResultList() + .stream() + .findFirst() + .map(EntityMapper::mapToModel); + } + + @Override + public void markRotated(long id, Instant rotatedAt) { + entityManager.find(RefreshTokenEntity.class, id).setRotatedAt(rotatedAt); + } + + @Override + public void revokeFamily(UUID familyId, Instant revokedAt) { + entityManager.createQuery("UPDATE RefreshTokenEntity t SET t.revokedAt = :now " + + "WHERE t.familyId = :family AND t.revokedAt IS NULL") + .setParameter("now", revokedAt) + .setParameter("family", familyId) + .executeUpdate(); + } + + @Override + public int deleteExpired(Instant now) { + return entityManager.createQuery("DELETE FROM RefreshTokenEntity t WHERE t.expiresAt < :now") + .setParameter("now", now) + .executeUpdate(); + } +} diff --git a/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java b/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java new file mode 100644 index 0000000..69d7e3a --- /dev/null +++ b/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java @@ -0,0 +1,45 @@ +package auth_service.modules.login.persistence.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@Setter +public class RefreshTokenEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private long userId; + + @Column(name = "family_id", nullable = false) + private UUID familyId; + + @Column(name = "token_hash", nullable = false, unique = true) + private String tokenHash; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "rotated_at") + private Instant rotatedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} diff --git a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java index 737b80b..64a72f3 100644 --- a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java @@ -1,7 +1,9 @@ package auth_service.modules.login.persistence.mapper; +import auth_service.modules.login.model.RefreshToken; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.entities.MinimalUserEntity; +import auth_service.modules.login.persistence.entities.RefreshTokenEntity; public abstract class EntityMapper { @@ -21,4 +23,9 @@ public abstract class EntityMapper { return model; } + + public static RefreshToken mapToModel(RefreshTokenEntity entity) { + return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(), + entity.getRotatedAt(), entity.getRevokedAt()); + } } diff --git a/src/main/java/auth_service/modules/login/service/LoginService.java b/src/main/java/auth_service/modules/login/service/LoginService.java index 8434951..4586eb7 100644 --- a/src/main/java/auth_service/modules/login/service/LoginService.java +++ b/src/main/java/auth_service/modules/login/service/LoginService.java @@ -2,6 +2,7 @@ package auth_service.modules.login.service; import auth_service.shared.security.TokenGenerator; import auth_service.modules.login.exception.InvalidCredentialsException; +import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.MinimalUserRepository; import lombok.extern.slf4j.Slf4j; @@ -18,26 +19,30 @@ public class LoginService { private final PasswordEncoder passwordEncoder; private final TokenGenerator tokenGenerator; private final LoginAttemptService loginAttemptService; + private final RefreshTokenService refreshTokenService; // Checked against for unknown emails, so they cost the same bcrypt time as a wrong password. private final String dummyHash; public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder, - TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService) { + TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService, + RefreshTokenService refreshTokenService) { this.minimalUserRepository = minimalUserRepository; this.passwordEncoder = passwordEncoder; this.tokenGenerator = tokenGenerator; this.loginAttemptService = loginAttemptService; + this.refreshTokenService = refreshTokenService; this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString()); } /** - * Authenticates a user and returns a token. Unknown email, wrong password and disabled + * Authenticates a user and starts a session. Unknown email, wrong password and disabled * account all fail with the same InvalidCredentialsException. * - * @return The generated token + * @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it, + * so its login is revoked; else it would stay valid unseen for 30 days. */ - public String login(User userToLogin, String clientIp) { + public Session login(User userToLogin, String clientIp, String previousRefreshToken) { String email = userToLogin.getEmail(); loginAttemptService.acquire(email, clientIp); @@ -52,6 +57,8 @@ public class LoginService { User user = foundUser.get(); loginAttemptService.recordSuccess(email, clientIp); minimalUserRepository.updateLoginDate(user.getId()); - return tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId()); + refreshTokenService.revoke(previousRefreshToken); + String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId()); + return new Session(accessToken, refreshTokenService.startFamily(user.getId())); } } diff --git a/src/main/java/auth_service/modules/login/service/RefreshTokenService.java b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java new file mode 100644 index 0000000..643893e --- /dev/null +++ b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java @@ -0,0 +1,125 @@ +package auth_service.modules.login.service; + +import auth_service.modules.login.model.RefreshToken; +import auth_service.modules.login.model.Session; +import auth_service.modules.login.model.User; +import auth_service.modules.login.persistence.MinimalUserRepository; +import auth_service.modules.login.persistence.RefreshTokenRepository; +import auth_service.shared.security.SessionProperties; +import auth_service.shared.security.TokenGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Optional; +import java.util.UUID; + +/** + * Refresh tokens are random and stored only as SHA-256. Each use rotates the token; all tokens from one login + * form a family. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + + private static final int TOKEN_BYTES = 32; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final RefreshTokenRepository refreshTokenRepository; + private final MinimalUserRepository userRepository; + private final TokenGenerator tokenGenerator; + private final SessionProperties sessionProperties; + + @Transactional + public String startFamily(long userId) { + return issue(userId, UUID.randomUUID(), Instant.now()); + } + + /** + * Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone or + * disabled. + * A token that was rotated before means a second copy exists, so the whole family is revoked. This method + * returns instead of throwing, so that revoke is committed. + */ + @Transactional + public Optional refresh(String rawToken) { + if (isBlank(rawToken)) { + return Optional.empty(); + } + Instant now = Instant.now(); + Optional found = refreshTokenRepository.findByHashForUpdate(hash(rawToken)); + if (found.isEmpty()) { + return Optional.empty(); + } + RefreshToken token = found.get(); + if (token.revokedAt() != null || !token.expiresAt().isAfter(now)) { + return Optional.empty(); + } + if (token.rotatedAt() != null) { + log.warn("Refresh token of user {} was used twice, revoking its family", token.userId()); + refreshTokenRepository.revokeFamily(token.familyId(), now); + return Optional.empty(); + } + Optional user = userRepository.findById(token.userId()).filter(User::isEnabled); + if (user.isEmpty()) { + refreshTokenRepository.revokeFamily(token.familyId(), now); + return Optional.empty(); + } + refreshTokenRepository.markRotated(token.id(), now); + String next = issue(token.userId(), token.familyId(), now); + User current = user.get(); + String accessToken = tokenGenerator.generateToken( + String.valueOf(current.getId()), current.getEmail(), current.getSteamId()); + return Optional.of(new Session(accessToken, next)); + } + + /** + * Does nothing for a missing or unknown token. + */ + @Transactional + public void revoke(String rawToken) { + if (isBlank(rawToken)) { + return; + } + refreshTokenRepository.findByHashForUpdate(hash(rawToken)) + .ifPresent(token -> refreshTokenRepository.revokeFamily(token.familyId(), Instant.now())); + } + + @Scheduled(cron = "0 30 3 * * *") + @Transactional + public void deleteExpired() { + int deleted = refreshTokenRepository.deleteExpired(Instant.now()); + log.info("Deleted {} expired refresh tokens", deleted); + } + + private String issue(long userId, UUID familyId, Instant now) { + byte[] bytes = new byte[TOKEN_BYTES]; + RANDOM.nextBytes(bytes); + String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + refreshTokenRepository.save(userId, familyId, hash(rawToken), now, now.plus(sessionProperties.getRefreshTokenTtl())); + return rawToken; + } + + private static boolean isBlank(String rawToken) { + return rawToken == null || rawToken.isBlank(); + } + + static String hash(String rawToken) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index e6b4d8c..e54310d 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -1,15 +1,16 @@ package auth_service.modules.user.api; +import auth_service.shared.api.AuthCookies; import auth_service.shared.security.UserPrincipal; import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.response.UserProfileResponse; -import auth_service.modules.user.api.dto.response.UserSettingsResponse; import auth_service.modules.user.api.mapper.ApiMapper; import auth_service.modules.user.service.UserService; import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*; public class UserController { private final UserService userService; + private final AuthCookies authCookies; /** * Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1 @@ -40,11 +42,14 @@ public class UserController { @PutMapping(value = "/settings", version = "1.0", produces = "application/json") @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests") - public ResponseEntity updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, - @Valid @RequestBody UserSettingsRequest request) { + public ResponseEntity updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody UserSettingsRequest request) { var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(), ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); - return ResponseEntity.ok(new UserSettingsResponse(token)); + // A new access cookie, so the steamId claim that Core reads matches the saved settings. + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString()) + .build(); } @PutMapping(value = "/settings/appearance", version = "1.0") diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java deleted file mode 100644 index f808953..0000000 --- a/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package auth_service.modules.user.api.dto.response; - -/** - * @param token replaces the client's token, so the steamId claim matches the saved settings - */ -public record UserSettingsResponse(String token) { -} diff --git a/src/main/java/auth_service/shared/api/AuthCookies.java b/src/main/java/auth_service/shared/api/AuthCookies.java new file mode 100644 index 0000000..a8382cd --- /dev/null +++ b/src/main/java/auth_service/shared/api/AuthCookies.java @@ -0,0 +1,67 @@ +package auth_service.shared.api; + +import auth_service.shared.security.SessionProperties; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class AuthCookies { + + public static final String ACCESS_COOKIE = "tbp_access"; + public static final String REFRESH_COOKIE = "tbp_refresh"; + // Login, refresh and logout live here; logout needs the refresh cookie to revoke its family. + private static final String REFRESH_PATH = "/api/auth"; + + private final SessionProperties properties; + + public ResponseCookie access(String token) { + return accessBuilder(token).maxAge(properties.getAccessTokenTtl()).build(); + } + + public ResponseCookie refresh(String token) { + return refreshBuilder(token).maxAge(properties.getRefreshTokenTtl()).build(); + } + + public List cleared() { + return List.of(accessBuilder("").maxAge(Duration.ZERO).build(), refreshBuilder("").maxAge(Duration.ZERO).build()); + } + + public static Optional read(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + return Arrays.stream(cookies) + .filter(cookie -> name.equals(cookie.getName()) && !cookie.getValue().isBlank()) + .map(Cookie::getValue) + .findFirst(); + } + + private ResponseCookie.ResponseCookieBuilder accessBuilder(String token) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(ACCESS_COOKIE, token) + .httpOnly(true) + .secure(properties.isCookieSecure()) + .sameSite("Lax") + .path("/"); + String domain = properties.getCookieDomain(); + return domain == null || domain.isBlank() ? builder : builder.domain(domain); + } + + // Host-only on Auth: no other service needs it. Strict is safe because the frontend is same-site. + private ResponseCookie.ResponseCookieBuilder refreshBuilder(String token) { + return ResponseCookie.from(REFRESH_COOKIE, token) + .httpOnly(true) + .secure(properties.isCookieSecure()) + .sameSite("Strict") + .path(REFRESH_PATH); + } +} diff --git a/src/main/java/auth_service/shared/security/SessionProperties.java b/src/main/java/auth_service/shared/security/SessionProperties.java new file mode 100644 index 0000000..8ba1832 --- /dev/null +++ b/src/main/java/auth_service/shared/security/SessionProperties.java @@ -0,0 +1,25 @@ +package auth_service.shared.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "app.auth") +public class SessionProperties { + + private Duration accessTokenTtl = Duration.ofMinutes(15); + + /** Sliding: every refresh starts a new period. */ + private Duration refreshTokenTtl = Duration.ofDays(30); + + /** Parent domain for the access cookie, so Core on a sibling host gets it. Blank means host-only (localhost). */ + private String cookieDomain; + + private boolean cookieSecure = true; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fc78f64..1ed4186 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,6 +48,9 @@ app: cors: # Production default: strictly allow only the frontend domain allowed-origins: "https://tbp.andreas-dahm.eu" + auth: + cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:} + cookie-secure: true # Registration settings auth: diff --git a/src/main/resources/db/migration/V10__Add_Refresh_Tokens.sql b/src/main/resources/db/migration/V10__Add_Refresh_Tokens.sql new file mode 100644 index 0000000..66e862c --- /dev/null +++ b/src/main/resources/db/migration/V10__Add_Refresh_Tokens.sql @@ -0,0 +1,15 @@ +-- One row per refresh token; all tokens of one login share a family_id. See RefreshTokenService. +CREATE TABLE refresh_tokens +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + family_id UUID NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + rotated_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens (family_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); diff --git a/src/test/java/auth_service/configuration/security/ExpiredAccessTokenTest.java b/src/test/java/auth_service/configuration/security/ExpiredAccessTokenTest.java new file mode 100644 index 0000000..a7b7247 --- /dev/null +++ b/src/test/java/auth_service/configuration/security/ExpiredAccessTokenTest.java @@ -0,0 +1,65 @@ +package auth_service.configuration.security; + +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Every access token from this context is already expired when it is issued. + */ +@AutoConfigureMockMvc +@TestPropertySource(properties = "app.auth.access-token-ttl=-1s") +class ExpiredAccessTokenTest extends AbstractIntegrationTest { + + private static final String EMAIL = "review-findings@example.com"; + private static final String PASSWORD = "correct horse battery"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void seedUser() { + jdbc.update("DELETE FROM users WHERE email = ?", EMAIL); + jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)", + EMAIL, passwordEncoder.encode(PASSWORD)); + } + + private String expiredAccessCookie() throws Exception { + Cookie cookie = mockMvc.perform(post("/api/auth/login") + .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE); + assertNotNull(cookie); + return cookie.getValue(); + } + + @Test + void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception { + String expired = expiredAccessCookie(); + + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, expired))) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index 604666d..d3429e7 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -1,5 +1,7 @@ package auth_service.configuration.security; +import auth_service.shared.security.SessionProperties; +import auth_service.shared.security.UserPrincipal; import auth_service.shared.test.AbstractUnitTest; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.BeforeEach; @@ -15,6 +17,7 @@ import java.util.Base64; import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -51,7 +54,7 @@ class JwtServiceTest extends AbstractUnitTest { publicKey = KeyFactory.getInstance("Ed25519") .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64))); - jwtService = new JwtService(jwtConfig); + jwtService = new JwtService(jwtConfig, new SessionProperties()); jwtService.init(); } @@ -78,39 +81,25 @@ class JwtServiceTest extends AbstractUnitTest { } @Test - void extractEmail_Success() { - var email = "test@example.com"; - var token = jwtService.generateToken("0815", email, null); + void parseLoginToken_ReturnsTheUser() { + var token = jwtService.generateToken("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 = jwtService.generateToken(userId, "test@example.com", null); - - assertEquals(userId, jwtService.extractUserId(token)); + void parseLoginToken_RejectsGarbage() { + assertEquals(Optional.empty(), jwtService.parseLoginToken("invalid-token")); } @Test - void extractSteamId_Success() { - var steamId = 123456789L; - var token = jwtService.generateToken("0815", "test@example.com", steamId); - - assertEquals(steamId, jwtService.extractSteamId(token)); - } - - @Test - void isTokenValid_Success() { + void generateToken_ExpiresAfterTheAccessTokenTtl() { var token = jwtService.generateToken("0815", "test@example.com", null); - assertTrue(jwtService.isTokenValid(token)); - } - - @Test - void isTokenValid_Failure() { - assertFalse(jwtService.isTokenValid("invalid-token")); + var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload(); + long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; + assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s"); } @Test @@ -126,14 +115,14 @@ class JwtServiceTest extends AbstractUnitTest { } @Test - void isTokenValid_ProfileTicketIsNoLoginToken() { - assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L))); + void parseLoginToken_ProfileTicketIsNoLoginToken() { + assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L))); } @Test void init_BlankKid_FailsAtStartup() { when(jwtConfig.getKid()).thenReturn(" "); - JwtService service = new JwtService(jwtConfig); + JwtService service = new JwtService(jwtConfig, new SessionProperties()); assertThrows(IllegalStateException.class, service::init); } diff --git a/src/test/java/auth_service/configuration/security/SessionFlowTest.java b/src/test/java/auth_service/configuration/security/SessionFlowTest.java new file mode 100644 index 0000000..94b0713 --- /dev/null +++ b/src/test/java/auth_service/configuration/security/SessionFlowTest.java @@ -0,0 +1,301 @@ +package auth_service.configuration.security; + +import auth_service.modules.login.model.Session; +import auth_service.modules.login.service.RefreshTokenService; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.request.MockMvcRequestBuilders.put; +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 whole cookie session against a real database: login, refresh rotation, reuse detection, logout, the CSRF + * header and CORS. + */ +@AutoConfigureMockMvc +class SessionFlowTest extends AbstractIntegrationTest { + + private static final String EMAIL = "session-flow@example.com"; + private static final String PASSWORD = "correct horse battery"; + private static final String CLIENT = "web"; + private static final String FRONTEND = "https://tbp.andreas-dahm.eu"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private RefreshTokenService refreshTokenService; + + @BeforeEach + void seedUser() { + jdbc.update("DELETE FROM users WHERE email = ?", EMAIL); + jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)", + EMAIL, passwordEncoder.encode(PASSWORD)); + } + + private MockHttpServletResponse login() throws Exception { + return mockMvc.perform(post("/api/auth/login") + .header(ClientHeaderFilter.CLIENT_HEADER, CLIENT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").doesNotExist()) + .andReturn().getResponse(); + } + + private MockHttpServletResponse refresh(String refreshToken) throws Exception { + return mockMvc.perform(post("/api/auth/refresh") + .header(ClientHeaderFilter.CLIENT_HEADER, CLIENT) + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refreshToken))) + .andReturn().getResponse(); + } + + private static String cookie(MockHttpServletResponse response, String name) { + Cookie cookie = response.getCookie(name); + assertNotNull(cookie, name + " missing"); + return cookie.getValue(); + } + + @Test + void login_TheAccessCookieAuthenticates() throws Exception { + String access = cookie(login(), AuthCookies.ACCESS_COOKIE); + + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value(EMAIL)); + } + + @Test + void noLogin_Is401() throws Exception { + mockMvc.perform(get("/api/users/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + void refresh_RotatesTheToken() throws Exception { + String first = cookie(login(), AuthCookies.REFRESH_COOKIE); + + MockHttpServletResponse refreshed = refresh(first); + + assertEquals(204, refreshed.getStatus()); + String second = cookie(refreshed, AuthCookies.REFRESH_COOKIE); + assertNotEquals(first, second); + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, + cookie(refreshed, AuthCookies.ACCESS_COOKIE)))) + .andExpect(status().isOk()); + assertEquals(204, refresh(second).getStatus()); + } + + @Test + void refresh_ReusedToken_RevokesTheWholeFamily() throws Exception { + String first = cookie(login(), AuthCookies.REFRESH_COOKIE); + String second = cookie(refresh(first), AuthCookies.REFRESH_COOKIE); + + assertEquals(401, refresh(first).getStatus()); + + assertEquals(401, refresh(second).getStatus()); + } + + @Test + void refresh_OtherLoginsKeepWorkingAfterAReuse() throws Exception { + String stolen = cookie(login(), AuthCookies.REFRESH_COOKIE); + String otherDevice = cookie(login(), AuthCookies.REFRESH_COOKIE); + refresh(stolen); + refresh(stolen); + + assertEquals(204, refresh(otherDevice).getStatus()); + } + + @Test + void refresh_TwoParallelUsesOfOneToken_OnlyOneWinsAndTheFamilyEnds() throws Exception { + String token = cookie(login(), AuthCookies.REFRESH_COOKIE); + + List> results; + try (ExecutorService pool = Executors.newFixedThreadPool(2)) { + List>> futures = pool.invokeAll(List.>>of( + () -> refreshTokenService.refresh(token), () -> refreshTokenService.refresh(token))); + results = new ArrayList<>(); + for (Future> future : futures) { + results.add(future.get()); + } + } + + List winners = results.stream().flatMap(Optional::stream).toList(); + assertEquals(1, winners.size()); + assertEquals(401, refresh(winners.getFirst().refreshToken()).getStatus()); + } + + @Test + void login_WithAnOldRefreshCookie_RevokesTheOldLogin() throws Exception { + String old = cookie(login(), AuthCookies.REFRESH_COOKIE); + + mockMvc.perform(post("/api/auth/login") + .header(ClientHeaderFilter.CLIENT_HEADER, CLIENT) + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, old)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isOk()); + + assertEquals(401, refresh(old).getStatus()); + } + + @Test + void refresh_ExpiredToken_Is401() throws Exception { + String token = cookie(login(), AuthCookies.REFRESH_COOKIE); + jdbc.update("UPDATE refresh_tokens SET expires_at = now() - interval '1 second' " + + "WHERE user_id = (SELECT id FROM users WHERE email = ?)", EMAIL); + + assertEquals(401, refresh(token).getStatus()); + } + + @Test + void refresh_DisabledUser_Is401() throws Exception { + String token = cookie(login(), AuthCookies.REFRESH_COOKIE); + jdbc.update("UPDATE users SET enabled = false WHERE email = ?", EMAIL); + + assertEquals(401, refresh(token).getStatus()); + } + + @Test + void refresh_UnknownToken_Is401AndClearsTheCookies() throws Exception { + MockHttpServletResponse response = refresh("made-up"); + + assertEquals(401, response.getStatus()); + assertEquals(0, response.getCookie(AuthCookies.REFRESH_COOKIE).getMaxAge()); + } + + @Test + void refresh_WithoutCookie_Is401() throws Exception { + mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)) + .andExpect(status().isUnauthorized()); + } + + @Test + void logout_WithoutCookie_Is204() throws Exception { + mockMvc.perform(post("/api/auth/logout").header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)) + .andExpect(status().isNoContent()); + } + + @Test + void cookiesAreHostOnlyByDefault() throws Exception { + MockHttpServletResponse response = login(); + + assertEquals(null, response.getCookie(AuthCookies.ACCESS_COOKIE).getDomain()); + } + + @Test + void logout_RevokesTheRefreshToken() throws Exception { + String token = cookie(login(), AuthCookies.REFRESH_COOKIE); + + mockMvc.perform(post("/api/auth/logout") + .header(ClientHeaderFilter.CLIENT_HEADER, CLIENT) + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, token))) + .andExpect(status().isNoContent()); + + assertEquals(401, refresh(token).getStatus()); + } + + @Test + void deleteExpired_RemovesOnlyExpiredRows() throws Exception { + login(); + login(); + jdbc.update("UPDATE refresh_tokens SET expires_at = now() - interval '1 second' WHERE id = " + + "(SELECT MIN(t.id) FROM refresh_tokens t JOIN users u ON u.id = t.user_id WHERE u.email = ?)", EMAIL); + + refreshTokenService.deleteExpired(); + + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM refresh_tokens t JOIN users u ON u.id = t.user_id " + + "WHERE u.email = ?", Integer.class, EMAIL)); + } + + @Test + void postWithoutClientHeader_Is403() throws Exception { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING")); + } + + @Test + void putWithAccessCookieButWithoutClientHeader_Is403() throws Exception { + String access = cookie(login(), AuthCookies.ACCESS_COOKIE); + + mockMvc.perform(put("/api/users/settings/appearance") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING")); + } + + @Test + void bearerHeaderStillAuthenticatesWithoutClientHeader() throws Exception { + String access = cookie(login(), AuthCookies.ACCESS_COOKIE); + + mockMvc.perform(put("/api/users/settings/appearance") + .header("Authorization", "Bearer " + access) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accent\":\"blue\",\"corners\":\"round\",\"wideLayout\":false,\"showBanner\":true}")) + .andExpect(status().isNoContent()); + } + + @Test + void staleCookieNextToAValidBearer_Authenticates() throws Exception { + String access = cookie(login(), AuthCookies.ACCESS_COOKIE); + + mockMvc.perform(get("/api/users/me") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, "expired-or-garbage")) + .header("Authorization", "Bearer " + access)) + .andExpect(status().isOk()); + } + + @Test + void corsPreflight_AllowsOnlyTheFrontendWithCredentials() throws Exception { + mockMvc.perform(options("/api/auth/refresh") + .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/auth/refresh") + .header("Origin", "https://evil.andreas-dahm.eu") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "x-tbp-client")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/auth_service/modules/login/api/LoginControllerTest.java b/src/test/java/auth_service/modules/login/api/LoginControllerTest.java index 52ca72b..9579ea8 100644 --- a/src/test/java/auth_service/modules/login/api/LoginControllerTest.java +++ b/src/test/java/auth_service/modules/login/api/LoginControllerTest.java @@ -4,8 +4,13 @@ import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.modules.login.api.dto.request.LoginRequest; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; +import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; import auth_service.modules.login.service.LoginService; +import auth_service.modules.login.service.RefreshTokenService; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.security.SessionProperties; +import jakarta.servlet.http.Cookie; import auth_service.shared.test.AbstractWebMvcTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,15 +18,21 @@ import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters import org.springframework.boot.test.json.JacksonTester; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.util.Optional; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; 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; @@ -30,6 +41,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * Tests for LoginController. */ @WebMvcTest(LoginController.class) +@Import({AuthCookies.class, SessionProperties.class}) +@TestPropertySource(properties = "app.auth.cookie-domain=tbp.andreas-dahm.eu") @AutoConfigureMockMvc(addFilters = false) // Skip security filters for simple controller test @AutoConfigureJsonTesters class LoginControllerTest extends AbstractWebMvcTest { @@ -40,6 +53,9 @@ class LoginControllerTest extends AbstractWebMvcTest { @MockitoBean private LoginService loginService; + @MockitoBean + private RefreshTokenService refreshTokenService; + @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter; // Needed because it's used in SecurityConfig @@ -50,7 +66,7 @@ class LoginControllerTest extends AbstractWebMvcTest { void login_Success() throws Exception { // Arrange var request = new LoginRequest("test@example.com", "password"); - when(loginService.login(any(User.class), eq("127.0.0.1"))).thenReturn("mock-token"); + when(loginService.login(any(User.class), eq("127.0.0.1"), any())).thenReturn(new Session("mock-access", "mock-refresh")); // Act & Assert mockMvc.perform(post("/api/auth/login") @@ -58,14 +74,26 @@ class LoginControllerTest extends AbstractWebMvcTest { .content(loginRequestJson.write(request).getJson())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Login successful")) - .andExpect(jsonPath("$.token").value("mock-token")); + .andExpect(jsonPath("$.token").doesNotExist()) + .andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "mock-access")) + .andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true)) + .andExpect(cookie().secure(AuthCookies.ACCESS_COOKIE, true)) + .andExpect(cookie().sameSite(AuthCookies.ACCESS_COOKIE, "Lax")) + .andExpect(cookie().domain(AuthCookies.ACCESS_COOKIE, "tbp.andreas-dahm.eu")) + .andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 15 * 60)) + .andExpect(cookie().value(AuthCookies.REFRESH_COOKIE, "mock-refresh")) + .andExpect(cookie().httpOnly(AuthCookies.REFRESH_COOKIE, true)) + .andExpect(cookie().path(AuthCookies.REFRESH_COOKIE, "/api/auth")) + .andExpect(cookie().sameSite(AuthCookies.REFRESH_COOKIE, "Strict")) + .andExpect(cookie().domain(AuthCookies.REFRESH_COOKIE, (String) null)) + .andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 30 * 24 * 60 * 60)); } @Test void login_Failure() throws Exception { // Arrange var request = new LoginRequest("test@example.com", "wrong-password"); - when(loginService.login(any(User.class), any())).thenThrow(new InvalidCredentialsException("Invalid email or password")); + when(loginService.login(any(User.class), any(), any())).thenThrow(new InvalidCredentialsException("Invalid email or password")); // Act & Assert mockMvc.perform(post("/api/auth/login") @@ -79,7 +107,7 @@ class LoginControllerTest extends AbstractWebMvcTest { void login_UsesClientIpAddedByTraefik() throws Exception { // Arrange var request = new LoginRequest("test@example.com", "password"); - when(loginService.login(any(User.class), eq("203.0.113.7"))).thenReturn("mock-token"); + when(loginService.login(any(User.class), eq("203.0.113.7"), any())).thenReturn(new Session("mock-access", "mock-refresh")); // Act & Assert mockMvc.perform(post("/api/auth/login") @@ -93,7 +121,7 @@ class LoginControllerTest extends AbstractWebMvcTest { void login_TooManyAttempts() throws Exception { // Arrange var request = new LoginRequest("test@example.com", "password"); - when(loginService.login(any(User.class), any())).thenThrow(new TooManyLoginAttemptsException(900)); + when(loginService.login(any(User.class), any(), any())).thenThrow(new TooManyLoginAttemptsException(900)); // Act & Assert mockMvc.perform(post("/api/auth/login") @@ -114,4 +142,45 @@ class LoginControllerTest extends AbstractWebMvcTest { verifyNoInteractions(loginService); } + + @Test + void refresh_ValidCookie_SetsNewCookies() throws Exception { + when(refreshTokenService.refresh("old-refresh")).thenReturn(Optional.of(new Session("new-access", "new-refresh"))); + + mockMvc.perform(post("/api/auth/refresh").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "old-refresh"))) + .andExpect(status().isNoContent()) + .andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-access")) + .andExpect(cookie().value(AuthCookies.REFRESH_COOKIE, "new-refresh")); + } + + @Test + void refresh_RejectedCookie_ClearsBothCookies() throws Exception { + when(refreshTokenService.refresh("old-refresh")).thenReturn(Optional.empty()); + + mockMvc.perform(post("/api/auth/refresh").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "old-refresh"))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("SESSION_EXPIRED")) + .andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 0)) + .andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 0)) + .andExpect(cookie().path(AuthCookies.REFRESH_COOKIE, "/api/auth")) + .andExpect(cookie().domain(AuthCookies.ACCESS_COOKIE, "tbp.andreas-dahm.eu")); + } + + @Test + void refresh_NoCookie_Is401() throws Exception { + when(refreshTokenService.refresh(null)).thenReturn(Optional.empty()); + + mockMvc.perform(post("/api/auth/refresh")) + .andExpect(status().isUnauthorized()); + } + + @Test + void logout_RevokesAndClearsBothCookies() throws Exception { + mockMvc.perform(post("/api/auth/logout").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "the-refresh"))) + .andExpect(status().isNoContent()) + .andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 0)) + .andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 0)); + + verify(refreshTokenService).revoke("the-refresh"); + } } diff --git a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java index da4790d..d2fec44 100644 --- a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java +++ b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java @@ -3,6 +3,7 @@ package auth_service.modules.login.service; import auth_service.shared.security.TokenGenerator; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; +import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.shared.test.AbstractUnitTest; @@ -44,6 +45,9 @@ class LoginServiceTest extends AbstractUnitTest { @Mock private LoginAttemptService loginAttemptService; + @Mock + private RefreshTokenService refreshTokenService; + private LoginService loginService; private User user; @@ -51,7 +55,8 @@ class LoginServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash"); - loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService); + loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService, + refreshTokenService); user = User.builder() .email("test@example.com") .password("password") @@ -70,23 +75,46 @@ class LoginServiceTest extends AbstractUnitTest { when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token"); + when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh"); // Act - var result = loginService.login(user, IP); + var result = loginService.login(user, IP, null); // Assert - assertEquals("mock-token", result); + assertEquals(new Session("mock-token", "mock-refresh"), result); verify(loginAttemptService).acquire("test@example.com", IP); verify(loginAttemptService).recordSuccess("test@example.com", IP); } + @Test + void login_Success_RevokesTheRefreshTokenThisBrowserStillSends() { + var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); + + loginService.login(user, IP, "old-refresh"); + + verify(refreshTokenService).revoke("old-refresh"); + } + + @Test + void login_WrongPassword_RevokesNothing() { + var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false); + + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, "old-refresh")); + + verify(refreshTokenService, never()).revoke(any()); + } + @Test void login_UnknownEmail_SameErrorAsWrongPassword() { // Arrange when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty()); // Act & Assert - assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); // bcrypt still runs, so the response time does not reveal that the email is unknown verify(passwordEncoder).matches("password", "dummy-hash"); verify(loginAttemptService).acquire("test@example.com", IP); @@ -105,7 +133,7 @@ class LoginServiceTest extends AbstractUnitTest { when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false); // Act & Assert - assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); verify(loginAttemptService).acquire("test@example.com", IP); verify(loginAttemptService, never()).recordSuccess(any(), any()); } @@ -123,7 +151,7 @@ class LoginServiceTest extends AbstractUnitTest { when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); // Act & Assert - assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); verify(tokenGenerator, never()).generateToken(any(), any(), any()); } @@ -133,7 +161,7 @@ class LoginServiceTest extends AbstractUnitTest { doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).acquire("test@example.com", IP); // Act & Assert - assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP)); + assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP, null)); verifyNoInteractions(minimalUserRepository); verify(passwordEncoder, never()).matches(any(), eq("encodedPassword")); } diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 4bf85e1..20e991f 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -10,6 +10,8 @@ import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.service.UserService; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.security.SessionProperties; import auth_service.shared.test.AbstractWebMvcTest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -20,6 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -35,10 +38,12 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(UserController.class) +@Import({AuthCookies.class, SessionProperties.class}) @AutoConfigureMockMvc(addFilters = false) @AutoConfigureJsonTesters class UserControllerTest extends AbstractWebMvcTest { @@ -148,8 +153,9 @@ class UserControllerTest extends AbstractWebMvcTest { .contentType(MediaType.APPLICATION_JSON) .content(""" {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.token").value("new-token")); + .andExpect(status().isNoContent()) + .andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token")) + .andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true)); } @Test @@ -166,7 +172,7 @@ class UserControllerTest extends AbstractWebMvcTest { .contentType(MediaType.APPLICATION_JSON) .content(""" {"useDarkTheme":true,"language":"en","steamId":null}""")) - .andExpect(status().isOk()); + .andExpect(status().isNoContent()); verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any()); } @@ -300,7 +306,7 @@ class UserControllerTest extends AbstractWebMvcTest { .contentType(MediaType.APPLICATION_JSON) .content(""" {"useDarkTheme":true,"language":"en","steamId":null,"displayName":"Lobo Corp_1"}""")) - .andExpect(status().isOk()); + .andExpect(status().isNoContent()); } @ParameterizedTest @@ -372,7 +378,7 @@ class UserControllerTest extends AbstractWebMvcTest { mockMvc.perform(put("/api/users/settings") .contentType(MediaType.APPLICATION_JSON) .content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}")) - .andExpect(status().isOk()); + .andExpect(status().isNoContent()); verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"), argThat(settings -> settings.isPublicProfile() == expected));