Public Access
feat(login): cookie session with rotating refresh token
The login token moves out of localStorage into an HttpOnly cookie (15 min), so a script on the page can no longer read it. A random refresh token (30 days, sliding) renews it; only its SHA-256 is stored. Each refresh replaces the token, and a replaced token that comes back revokes every token of that login. Unsafe requests need an X-TBP-Client or Authorization header, and CORS allows only the exact frontend origin. A missing or expired token now answers 401 instead of 403, so the frontend can refresh.
This commit is contained in:
1 parent
600b0b143f
commit
930e08e6dd
30 files changed
+1096
-151
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user