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
+1096 -151

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
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
@@ -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;
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<UserPrincipal> 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<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.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<UserPrincipal> 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();
}
}
@@ -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();
@@ -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<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 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> 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;
/**
* 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) {
}
@@ -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> findById(long userId);
void updateLoginDate(long userId);
}
@@ -35,6 +35,11 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
.map(EntityMapper::mapToModel);
}
@Override
public Optional<User> 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);
@@ -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;
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());
}
}
@@ -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()));
}
}
@@ -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;
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<UserSettingsResponse> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody UserSettingsRequest request) {
public ResponseEntity<Void> 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")
@@ -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:
# 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:
@@ -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;
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);
}
@@ -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.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");
}
}
@@ -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"));
}
@@ -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));