feat(login): cookie session with rotating refresh token
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s

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.
This commit is contained in:
lobo committed 2026-09-25 13:45:04 +02:00
1 parent 600b0b143f
commit 930e08e6dd
30 files changed
+1089 -144

No files matched your search

+17 -6
View File
@@ -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 - 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 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. 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. - 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 - 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. 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 | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `POST` | `/api/registration/new` | public | | `POST` | `/api/registration/new` | public |
| `POST` | `/api/auth/login` | public | | `POST` | `/api/auth/login` | public, sets both cookies |
| `GET` | `/api/users/me` | JWT | | `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
| `PUT` | `/api/users/settings` | JWT | | `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
| `PUT` | `/api/users/settings/appearance` | JWT | | `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/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | 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 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`. 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: Configuration comes from environment variables:
| Variable | Purpose | | Variable | Purpose |
@@ -103,8 +116,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap ## 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. - Account deletion and data export.
## License ## License
@@ -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 {
}
@@ -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<String> 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\"}");
}
}
@@ -1,12 +1,12 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -16,12 +16,19 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; 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 @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER = "Bearer ";
private final JwtService jwtService; private final JwtService jwtService;
@Override @Override
@@ -31,39 +38,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain @NonNull FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization"); Optional<UserPrincipal> principal = Stream.of(AuthCookies.read(request, AuthCookies.ACCESS_COOKIE), bearer(request))
.flatMap(Optional::stream)
if (authHeader == null || !authHeader.startsWith("Bearer ")) { .map(jwtService::parseLoginToken)
filterChain.doFilter(request, response); .flatMap(Optional::stream)
return; .findFirst();
} if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
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( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal.get(),
null, null,
Collections.emptyList() Collections.emptyList()
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
} }
} catch (Exception e) {
log.error("JWT Validation failed: {}", e.getMessage());
}
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
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();
}
} }
@@ -2,7 +2,11 @@ package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher; import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.TokenGenerator; 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.Jwts;
import io.jsonwebtoken.security.Jwks; import io.jsonwebtoken.security.Jwks;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -17,6 +21,7 @@ import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
/** /**
* Service responsible for generating and validating JSON Web Tokens (JWT). * Service responsible for generating and validating JSON Web Tokens (JWT).
@@ -25,8 +30,6 @@ import java.util.Map;
@Service @Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher { 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; private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
// Only non-login tokens carry this claim; isTokenValid rejects them. // Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ"; 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 static final String ED25519 = "Ed25519";
private final JwtConfig jwtConfig; private final JwtConfig jwtConfig;
private final SessionProperties sessionProperties;
private PrivateKey privateKey; private PrivateKey privateKey;
private PublicKey publicKey; private PublicKey publicKey;
private JwtParser parser;
public JwtService(JwtConfig jwtConfig) { public JwtService(JwtConfig jwtConfig, SessionProperties sessionProperties) {
this.jwtConfig = jwtConfig; this.jwtConfig = jwtConfig;
this.sessionProperties = sessionProperties;
} }
@PostConstruct @PostConstruct
@@ -49,6 +55,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
} }
this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey()); this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
this.parser = Jwts.parser().verifyWith(publicKey).build();
} }
@Override @Override
@@ -58,7 +65,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
.subject(email) .subject(email)
.claim("userId", userId) .claim("userId", userId)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS)); .expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
if (steamId != null) { if (steamId != null) {
builder.claim("steamId", steamId); builder.claim("steamId", steamId);
@@ -92,46 +99,20 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
.build()); .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<UserPrincipal> parseLoginToken(String token) {
try { try {
var claims = Jwts.parser() Claims claims = parser.parseSignedClaims(token).getPayload();
.verifyWith(publicKey) String userId = claims.get("userId", String.class);
.build() if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
.parseSignedClaims(token) return Optional.empty();
.getPayload(); }
return claims.get(TYPE_CLAIM) == null; return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
} catch (Exception _) { } catch (Exception _) {
return false; return Optional.empty();
} }
} }
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.time.Duration;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -66,13 +70,14 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
// Use OriginPatterns instead of AllowedOrigins to safely support "*" wildcards // Exact origins only: with credentials, a pattern would send the login cookies to every matching site.
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins)); configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); 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 // Lets the login page read how long a rate-limit block lasts
configuration.setExposedHeaders(List.of("Retry-After")); 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(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// Apply this CORS config to all endpoints // Apply this CORS config to all endpoints
@@ -87,11 +92,14 @@ public class SecurityConfig {
// Tell Spring Security to use the CorsConfigurationSource bean defined above // Tell Spring Security to use the CorsConfigurationSource bean defined above
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .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 .authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST_URLS).permitAll() .requestMatchers(WHITE_LIST_URLS).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterAfter(new ClientHeaderFilter(), CorsFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
@@ -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.request.LoginRequest;
import auth_service.modules.login.api.dto.response.LoginResponse; import auth_service.modules.login.api.dto.response.LoginResponse;
import auth_service.modules.login.api.mapper.ApiMapper; 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.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.ClientIp;
import auth_service.shared.api.dto.response.ErrorResponse;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import 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 @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@@ -24,6 +36,8 @@ import org.springframework.web.bind.annotation.RestController;
public class LoginController { public class LoginController {
private final LoginService loginService; private final LoginService loginService;
private final RefreshTokenService refreshTokenService;
private final AuthCookies authCookies;
/** /**
* Authenticates a user based on email and password. * Authenticates a user based on email and password.
@@ -33,10 +47,43 @@ public class LoginController {
*/ */
@PostMapping("/login") @PostMapping("/login")
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests") @Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest,
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String previousRefreshToken) {
var user = ApiMapper.toModel(request); var user = ApiMapper.toModel(request);
var token = loginService.login(user, ClientIp.of(httpRequest)); Session session = loginService.login(user, ClientIp.of(httpRequest), previousRefreshToken);
return ResponseEntity.ok(new LoginResponse("Login successful", token)); 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> 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<Void> logout(
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
refreshTokenService.revoke(refreshToken);
return ResponseEntity.noContent().headers(cookies(authCookies.cleared())).build();
}
private List<ResponseCookie> sessionCookies(Session session) {
return List.of(authCookies.access(session.accessToken()), authCookies.refresh(session.refreshToken()));
}
private static HttpHeaders cookies(List<ResponseCookie> cookies) {
HttpHeaders headers = new HttpHeaders();
cookies.forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
return headers;
} }
} }
@@ -1,8 +1,7 @@
package auth_service.modules.login.api.dto.response; package auth_service.modules.login.api.dto.response;
/** /**
* DTO representing the outgoing login response. * DTO representing the outgoing login response. The tokens are in the Set-Cookie headers.
* The email will be null if the login fails.
*/ */
public record LoginResponse(String message, String token) { public record LoginResponse(String message) {
} }
@@ -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) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.login.model;
public record Session(String accessToken, String refreshToken) {
}
@@ -16,5 +16,7 @@ public interface MinimalUserRepository {
*/ */
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
Optional<User> findById(long userId);
void updateLoginDate(long userId); void updateLoginDate(long userId);
} }
@@ -35,6 +35,11 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
.map(EntityMapper::mapToModel); .map(EntityMapper::mapToModel);
} }
@Override
public Optional<User> findById(long userId) {
return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel);
}
@Override @Override
public void updateLoginDate(long userId) { public void updateLoginDate(long userId) {
var userEntity = entityManager.find(MinimalUserEntity.class, userId); var userEntity = entityManager.find(MinimalUserEntity.class, userId);
@@ -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<RefreshToken> findByHashForUpdate(String tokenHash);
void markRotated(long id, Instant rotatedAt);
void revokeFamily(UUID familyId, Instant revokedAt);
int deleteExpired(Instant now);
}
@@ -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<RefreshToken> 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();
}
}
@@ -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;
}
@@ -1,7 +1,9 @@
package auth_service.modules.login.persistence.mapper; 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.model.User;
import auth_service.modules.login.persistence.entities.MinimalUserEntity; import auth_service.modules.login.persistence.entities.MinimalUserEntity;
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
public abstract class EntityMapper { public abstract class EntityMapper {
@@ -21,4 +23,9 @@ public abstract class EntityMapper {
return model; return model;
} }
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
entity.getRotatedAt(), entity.getRevokedAt());
}
} }
@@ -2,6 +2,7 @@ package auth_service.modules.login.service;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.InvalidCredentialsException; 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.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.modules.login.persistence.MinimalUserRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -18,26 +19,30 @@ public class LoginService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final TokenGenerator tokenGenerator; private final TokenGenerator tokenGenerator;
private final LoginAttemptService loginAttemptService; private final LoginAttemptService loginAttemptService;
private final RefreshTokenService refreshTokenService;
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password. // Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
private final String dummyHash; private final String dummyHash;
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder, public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService) { TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService,
RefreshTokenService refreshTokenService) {
this.minimalUserRepository = minimalUserRepository; this.minimalUserRepository = minimalUserRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.tokenGenerator = tokenGenerator; this.tokenGenerator = tokenGenerator;
this.loginAttemptService = loginAttemptService; this.loginAttemptService = loginAttemptService;
this.refreshTokenService = refreshTokenService;
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString()); 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. * 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(); String email = userToLogin.getEmail();
loginAttemptService.acquire(email, clientIp); loginAttemptService.acquire(email, clientIp);
@@ -52,6 +57,8 @@ public class LoginService {
User user = foundUser.get(); User user = foundUser.get();
loginAttemptService.recordSuccess(email, clientIp); loginAttemptService.recordSuccess(email, clientIp);
minimalUserRepository.updateLoginDate(user.getId()); 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()));
} }
} }
@@ -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<Session> refresh(String rawToken) {
if (isBlank(rawToken)) {
return Optional.empty();
}
Instant now = Instant.now();
Optional<RefreshToken> 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> 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);
}
}
}
@@ -1,15 +1,16 @@
package auth_service.modules.user.api; package auth_service.modules.user.api;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; 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.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; 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.api.mapper.ApiMapper;
import auth_service.modules.user.service.UserService; import auth_service.modules.user.service.UserService;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final AuthCookies authCookies;
/** /**
* Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1 * 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") @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") @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
public ResponseEntity<UserSettingsResponse> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody UserSettingsRequest request) { @Valid @RequestBody UserSettingsRequest request) {
var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(), var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(),
ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); 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") @PutMapping(value = "/settings/appearance", version = "1.0")
@@ -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) {
}
@@ -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<ResponseCookie> cleared() {
return List.of(accessBuilder("").maxAge(Duration.ZERO).build(), refreshBuilder("").maxAge(Duration.ZERO).build());
}
public static Optional<String> 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);
}
}
@@ -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;
}
+3
View File
@@ -48,6 +48,9 @@ app:
cors: cors:
# Production default: strictly allow only the frontend domain # Production default: strictly allow only the frontend domain
allowed-origins: "https://tbp.andreas-dahm.eu" allowed-origins: "https://tbp.andreas-dahm.eu"
auth:
cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:}
cookie-secure: true
# Registration settings # Registration settings
auth: auth:
@@ -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);
@@ -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());
}
}
@@ -1,5 +1,7 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -15,6 +17,7 @@ import java.util.Base64;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -51,7 +54,7 @@ class JwtServiceTest extends AbstractUnitTest {
publicKey = KeyFactory.getInstance("Ed25519") publicKey = KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64))); .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64)));
jwtService = new JwtService(jwtConfig); jwtService = new JwtService(jwtConfig, new SessionProperties());
jwtService.init(); jwtService.init();
} }
@@ -78,39 +81,25 @@ class JwtServiceTest extends AbstractUnitTest {
} }
@Test @Test
void extractEmail_Success() { void parseLoginToken_ReturnsTheUser() {
var email = "test@example.com"; var token = jwtService.generateToken("0815", "test@example.com", 123456789L);
var token = jwtService.generateToken("0815", email, null);
assertEquals(email, jwtService.extractEmail(token)); assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
jwtService.parseLoginToken(token));
} }
@Test @Test
void extractUserId_Success() { void parseLoginToken_RejectsGarbage() {
var userId = "0815"; assertEquals(Optional.empty(), jwtService.parseLoginToken("invalid-token"));
var token = jwtService.generateToken(userId, "test@example.com", null);
assertEquals(userId, jwtService.extractUserId(token));
} }
@Test @Test
void extractSteamId_Success() { void generateToken_ExpiresAfterTheAccessTokenTtl() {
var steamId = 123456789L;
var token = jwtService.generateToken("0815", "test@example.com", steamId);
assertEquals(steamId, jwtService.extractSteamId(token));
}
@Test
void isTokenValid_Success() {
var token = jwtService.generateToken("0815", "test@example.com", null); var token = jwtService.generateToken("0815", "test@example.com", null);
assertTrue(jwtService.isTokenValid(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
void isTokenValid_Failure() {
assertFalse(jwtService.isTokenValid("invalid-token"));
} }
@Test @Test
@@ -126,14 +115,14 @@ class JwtServiceTest extends AbstractUnitTest {
} }
@Test @Test
void isTokenValid_ProfileTicketIsNoLoginToken() { void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L))); assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L)));
} }
@Test @Test
void init_BlankKid_FailsAtStartup() { void init_BlankKid_FailsAtStartup() {
when(jwtConfig.getKid()).thenReturn(" "); when(jwtConfig.getKid()).thenReturn(" ");
JwtService service = new JwtService(jwtConfig); JwtService service = new JwtService(jwtConfig, new SessionProperties());
assertThrows(IllegalStateException.class, service::init); assertThrows(IllegalStateException.class, service::init);
} }
@@ -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<Optional<Session>> results;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
List<Future<Optional<Session>>> futures = pool.invokeAll(List.<Callable<Optional<Session>>>of(
() -> refreshTokenService.refresh(token), () -> refreshTokenService.refresh(token)));
results = new ArrayList<>();
for (Future<Optional<Session>> future : futures) {
results.add(future.get());
}
}
List<Session> 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());
}
}
@@ -4,8 +4,13 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.modules.login.api.dto.request.LoginRequest; import auth_service.modules.login.api.dto.request.LoginRequest;
import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException; 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.model.User;
import auth_service.modules.login.service.LoginService; 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 auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.test.json.JacksonTester;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; 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.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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. * Tests for LoginController.
*/ */
@WebMvcTest(LoginController.class) @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 @AutoConfigureMockMvc(addFilters = false) // Skip security filters for simple controller test
@AutoConfigureJsonTesters @AutoConfigureJsonTesters
class LoginControllerTest extends AbstractWebMvcTest { class LoginControllerTest extends AbstractWebMvcTest {
@@ -40,6 +53,9 @@ class LoginControllerTest extends AbstractWebMvcTest {
@MockitoBean @MockitoBean
private LoginService loginService; private LoginService loginService;
@MockitoBean
private RefreshTokenService refreshTokenService;
@MockitoBean @MockitoBean
private JwtAuthenticationFilter jwtAuthenticationFilter; // Needed because it's used in SecurityConfig private JwtAuthenticationFilter jwtAuthenticationFilter; // Needed because it's used in SecurityConfig
@@ -50,7 +66,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_Success() throws Exception { void login_Success() throws Exception {
// Arrange // Arrange
var request = new LoginRequest("test@example.com", "password"); 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 // Act & Assert
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
@@ -58,14 +74,26 @@ class LoginControllerTest extends AbstractWebMvcTest {
.content(loginRequestJson.write(request).getJson())) .content(loginRequestJson.write(request).getJson()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Login successful")) .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 @Test
void login_Failure() throws Exception { void login_Failure() throws Exception {
// Arrange // Arrange
var request = new LoginRequest("test@example.com", "wrong-password"); 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 // Act & Assert
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
@@ -79,7 +107,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_UsesClientIpAddedByTraefik() throws Exception { void login_UsesClientIpAddedByTraefik() throws Exception {
// Arrange // Arrange
var request = new LoginRequest("test@example.com", "password"); 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 // Act & Assert
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
@@ -93,7 +121,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_TooManyAttempts() throws Exception { void login_TooManyAttempts() throws Exception {
// Arrange // Arrange
var request = new LoginRequest("test@example.com", "password"); 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 // Act & Assert
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
@@ -114,4 +142,45 @@ class LoginControllerTest extends AbstractWebMvcTest {
verifyNoInteractions(loginService); 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");
}
} }
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException; 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.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
@@ -44,6 +45,9 @@ class LoginServiceTest extends AbstractUnitTest {
@Mock @Mock
private LoginAttemptService loginAttemptService; private LoginAttemptService loginAttemptService;
@Mock
private RefreshTokenService refreshTokenService;
private LoginService loginService; private LoginService loginService;
private User user; private User user;
@@ -51,7 +55,8 @@ class LoginServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash"); 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() user = User.builder()
.email("test@example.com") .email("test@example.com")
.password("password") .password("password")
@@ -70,23 +75,46 @@ class LoginServiceTest extends AbstractUnitTest {
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token"); when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
// Act // Act
var result = loginService.login(user, IP); var result = loginService.login(user, IP, null);
// Assert // Assert
assertEquals("mock-token", result); assertEquals(new Session("mock-token", "mock-refresh"), result);
verify(loginAttemptService).acquire("test@example.com", IP); verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService).recordSuccess("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 @Test
void login_UnknownEmail_SameErrorAsWrongPassword() { void login_UnknownEmail_SameErrorAsWrongPassword() {
// Arrange // Arrange
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty()); when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty());
// Act & Assert // 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 // bcrypt still runs, so the response time does not reveal that the email is unknown
verify(passwordEncoder).matches("password", "dummy-hash"); verify(passwordEncoder).matches("password", "dummy-hash");
verify(loginAttemptService).acquire("test@example.com", IP); verify(loginAttemptService).acquire("test@example.com", IP);
@@ -105,7 +133,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
// Act & Assert // 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).acquire("test@example.com", IP);
verify(loginAttemptService, never()).recordSuccess(any(), any()); verify(loginAttemptService, never()).recordSuccess(any(), any());
} }
@@ -123,7 +151,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
// Act & Assert // Act & Assert
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any()); 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); doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).acquire("test@example.com", IP);
// Act & Assert // Act & Assert
assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP)); assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP, null));
verifyNoInteractions(minimalUserRepository); verifyNoInteractions(minimalUserRepository);
verify(passwordEncoder, never()).matches(any(), eq("encodedPassword")); verify(passwordEncoder, never()).matches(any(), eq("encodedPassword"));
} }
@@ -10,6 +10,8 @@ import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.service.UserService; 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 auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; 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.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 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.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class) @WebMvcTest(UserController.class)
@Import({AuthCookies.class, SessionProperties.class})
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@AutoConfigureJsonTesters @AutoConfigureJsonTesters
class UserControllerTest extends AbstractWebMvcTest { class UserControllerTest extends AbstractWebMvcTest {
@@ -148,8 +153,9 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isOk()) .andExpect(status().isNoContent())
.andExpect(jsonPath("$.token").value("new-token")); .andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token"))
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true));
} }
@Test @Test
@@ -166,7 +172,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"useDarkTheme":true,"language":"en","steamId":null}""")) {"useDarkTheme":true,"language":"en","steamId":null}"""))
.andExpect(status().isOk()); .andExpect(status().isNoContent());
verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any()); verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any());
} }
@@ -300,7 +306,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"useDarkTheme":true,"language":"en","steamId":null,"displayName":"Lobo Corp_1"}""")) {"useDarkTheme":true,"language":"en","steamId":null,"displayName":"Lobo Corp_1"}"""))
.andExpect(status().isOk()); .andExpect(status().isNoContent());
} }
@ParameterizedTest @ParameterizedTest
@@ -372,7 +378,7 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}")) .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"), verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"),
argThat(settings -> settings.isPublicProfile() == expected)); argThat(settings -> settings.isPublicProfile() == expected));