Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 600b0b143f feat(login): publish verify key as JWKS
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
Build, Test & Push Auth Service / quality-check (push) Successful in 26s
Auth serves its Ed25519 public key at GET /.well-known/jwks.json, so
Core can find the key by the token kid. Startup fails on a blank
JWT_KID, because compose turns an unset value into "" and Core finds
keys only by kid.
2026-09-25 11:01:28 +02:00
lobo 3a48ce0ff1 feat(auth): rate limit registration, atomic login limit
10 failed registrations per IP block it for 15 minutes (429 TOO_MANY_ATTEMPTS with Retry-After). Login and registration now take an attempt before the check, so a burst of parallel requests cannot pass the limit.
2026-09-25 09:06:30 +02:00
23 changed files with 555 additions and 86 deletions

No files matched your search

+5 -3
View File
@@ -14,8 +14,12 @@ To Be Played is split into three repositories:
## Features ## Features
- Registration with an invitation code, login with email and password (BCrypt). - Registration with an invitation code, login with email and password (BCrypt).
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
wrong password, and every failed login gets the same error.
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
tokens but never create them. Tokens carry a `kid` header for key rotation. 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.
- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode. - User settings: display name (unique, case-insensitive), Steam ID, language, dark mode.
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard - Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
@@ -99,8 +103,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap ## Roadmap
- Rate limit `/api/auth/login` and `/api/registration/**`.
- JWKS endpoint (`/.well-known/jwks.json`), so Core fetches the public key and keys rotate without a redeploy.
- Short-lived access token plus rotating refresh token in an httpOnly cookie, with CSRF protection. Today the token - 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. lives 24 h in `localStorage` and cannot be revoked.
- Account deletion and data export. - Account deletion and data export.
@@ -1,8 +1,10 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Jwks;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -13,13 +15,15 @@ import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Map;
/** /**
* Service responsible for generating and validating JSON Web Tokens (JWT). * Service responsible for generating and validating JSON Web Tokens (JWT).
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies. * Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/ */
@Service @Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer { public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher {
// Token validity: 24 hours in milliseconds // Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000; private static final long JWT_EXPIRATION_MS = 86400000;
@@ -39,6 +43,10 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
@PostConstruct @PostConstruct
void init() { void init() {
// Compose passes an unset JWT_KID as "", which hides the yml default. Core finds keys only by kid.
if (jwtConfig.getKid() == null || jwtConfig.getKid().isBlank()) {
throw new IllegalStateException("JWT_KID must not be blank");
}
this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey()); this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
} }
@@ -74,6 +82,16 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
.compact(); .compact();
} }
@Override
public List<Map<String, ?>> publicJwks() {
return List.of(Jwks.builder()
.key(publicKey)
.id(jwtConfig.getKid())
.publicKeyUse("sig")
.algorithm("EdDSA")
.build());
}
public String extractEmail(String token) { public String extractEmail(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(publicKey) .verifyWith(publicKey)
@@ -38,6 +38,7 @@ public class SecurityConfig {
"/api/stats/public", // Public landing-page counters (cached, no PII) "/api/stats/public", // Public landing-page counters (cached, no PII)
"/api/public/**", // Opt-in public profiles, see PublicProfileController "/api/public/**", // Opt-in public profiles, see PublicProfileController
"/actuator/health", // Docker health check; details are off "/actuator/health", // Docker health check; details are off
"/.well-known/jwks.json", // Public verify keys for Core
"/error" // Default Spring Boot error page routing "/error" // Default Spring Boot error page routing
}; };
@@ -0,0 +1,26 @@
package auth_service.modules.login.api;
import auth_service.shared.security.PublicKeyPublisher;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class JwksController {
private final PublicKeyPublisher publicKeyPublisher;
@GetMapping(value = "/.well-known/jwks.json", produces = "application/json")
public ResponseEntity<Map<String, List<Map<String, ?>>>> jwks() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(Map.of("keys", publicKeyPublisher.publicJwks()));
}
}
@@ -4,6 +4,7 @@ import auth_service.modules.login.api.dto.request.LoginRequest;
import auth_service.modules.login.api.dto.response.LoginResponse; import auth_service.modules.login.api.dto.response.LoginResponse;
import auth_service.modules.login.api.mapper.ApiMapper; import auth_service.modules.login.api.mapper.ApiMapper;
import auth_service.modules.login.service.LoginService; import auth_service.modules.login.service.LoginService;
import auth_service.shared.api.ClientIp;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -35,20 +36,7 @@ public class LoginController {
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
var user = ApiMapper.toModel(request); var user = ApiMapper.toModel(request);
var token = loginService.login(user, clientIp(httpRequest)); var token = loginService.login(user, ClientIp.of(httpRequest));
return ResponseEntity.ok(new LoginResponse("Login successful", token)); return ResponseEntity.ok(new LoginResponse("Login successful", token));
} }
/**
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
* from the client and can be faked, so only the last one counts.
*/
private static String clientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor == null || forwardedFor.isBlank()) {
return request.getRemoteAddr();
}
String[] hops = forwardedFor.split(",");
return hops[hops.length - 1].trim();
}
} }
@@ -1,13 +1,11 @@
package auth_service.modules.login.service; package auth_service.modules.login.service;
import auth_service.modules.login.exception.TooManyLoginAttemptsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import com.github.benmanes.caffeine.cache.Cache; import auth_service.shared.security.AttemptLimiter;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* Counts failed logins per email and per client IP in memory. A key is blocked after too many * Counts failed logins per email and per client IP in memory. A key is blocked after too many
@@ -20,36 +18,22 @@ public class LoginAttemptService {
static final int MAX_FAILURES_PER_IP = 20; static final int MAX_FAILURES_PER_IP = 20;
static final Duration LOCK_DURATION = Duration.ofMinutes(15); static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final Cache<String, AtomicInteger> failures = Caffeine.newBuilder() private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
.expireAfterWrite(LOCK_DURATION)
.maximumSize(100_000)
.build();
public void checkAllowed(String email, String clientIp) { public void acquire(String email, String clientIp) {
if (count(emailKey(email)) >= MAX_FAILURES_PER_EMAIL || count(ipKey(clientIp)) >= MAX_FAILURES_PER_IP) { String emailKey = emailKey(email);
if (!limiter.tryAcquire(emailKey, MAX_FAILURES_PER_EMAIL)) {
throw new TooManyLoginAttemptsException(LOCK_DURATION.toSeconds());
}
if (!limiter.tryAcquire(ipKey(clientIp), MAX_FAILURES_PER_IP)) {
limiter.release(emailKey);
throw new TooManyLoginAttemptsException(LOCK_DURATION.toSeconds()); throw new TooManyLoginAttemptsException(LOCK_DURATION.toSeconds());
} }
} }
public void recordFailure(String email, String clientIp) { public void recordSuccess(String email, String clientIp) {
increment(emailKey(email)); limiter.reset(emailKey(email));
increment(ipKey(clientIp)); limiter.release(ipKey(clientIp));
}
public void recordSuccess(String email) {
failures.invalidate(emailKey(email));
}
private int count(String key) {
AtomicInteger counter = failures.getIfPresent(key);
return counter == null ? 0 : counter.get();
}
private void increment(String key) {
// put() again so expireAfterWrite restarts the lock window with every failure
AtomicInteger counter = failures.get(key, k -> new AtomicInteger());
counter.incrementAndGet();
failures.put(key, counter);
} }
private static String emailKey(String email) { private static String emailKey(String email) {
@@ -39,19 +39,18 @@ public class LoginService {
*/ */
public String login(User userToLogin, String clientIp) { public String login(User userToLogin, String clientIp) {
String email = userToLogin.getEmail(); String email = userToLogin.getEmail();
loginAttemptService.checkAllowed(email, clientIp); loginAttemptService.acquire(email, clientIp);
Optional<User> foundUser = minimalUserRepository.findByEmail(email); Optional<User> foundUser = minimalUserRepository.findByEmail(email);
String hash = foundUser.map(User::getPassword).orElse(dummyHash); String hash = foundUser.map(User::getPassword).orElse(dummyHash);
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash); boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) { if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) {
loginAttemptService.recordFailure(email, clientIp);
throw new InvalidCredentialsException("Invalid email or password"); throw new InvalidCredentialsException("Invalid email or password");
} }
User user = foundUser.get(); User user = foundUser.get();
loginAttemptService.recordSuccess(email); loginAttemptService.recordSuccess(email, clientIp);
minimalUserRepository.updateLoginDate(user.getId()); minimalUserRepository.updateLoginDate(user.getId());
return tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId()); return tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId());
} }
@@ -4,7 +4,9 @@ import auth_service.modules.registration.api.dto.mapper.ApiMapper;
import auth_service.modules.registration.api.dto.request.RegistrationRequest; import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.api.dto.response.RegistrationResponse; import auth_service.modules.registration.api.dto.response.RegistrationResponse;
import auth_service.modules.registration.service.RegistrationService; import auth_service.modules.registration.service.RegistrationService;
import auth_service.shared.api.ClientIp;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -22,8 +24,8 @@ public class RegistrationController {
@PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json") @PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json")
@Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests") @Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests")
public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request) { public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request, HttpServletRequest httpRequest) {
var userId = registrationService.register(ApiMapper.mapToModel(request)); var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build()); return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
} }
} }
@@ -5,9 +5,11 @@ import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -24,6 +26,15 @@ public class RegistrationExceptionController {
.body(body); .body(body);
} }
@ExceptionHandler(TooManyRegistrationAttemptsException.class)
public ResponseEntity<ErrorResponse> clientError(TooManyRegistrationAttemptsException ex) {
ErrorResponse body = new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(body);
}
@ExceptionHandler(EmailAlreadyInUseException.class) @ExceptionHandler(EmailAlreadyInUseException.class)
public ResponseEntity<ErrorResponse> clientError(EmailAlreadyInUseException ex) { public ResponseEntity<ErrorResponse> clientError(EmailAlreadyInUseException ex) {
var body = new ErrorResponse("EMAIL_ALREADY_IN_USE", ex.getMessage()); var body = new ErrorResponse("EMAIL_ALREADY_IN_USE", ex.getMessage());
@@ -0,0 +1,13 @@
package auth_service.modules.registration.exception;
import lombok.Getter;
@Getter
public class TooManyRegistrationAttemptsException extends RuntimeException {
private final long retryAfterSeconds;
public TooManyRegistrationAttemptsException(long retryAfterSeconds) {
super("Too many failed registration attempts. Try again later.");
this.retryAfterSeconds = retryAfterSeconds;
}
}
@@ -0,0 +1,30 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.shared.security.AttemptLimiter;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* Blocks a client IP after too many failed registrations. Every rejection counts, so guessing the
* invitation code and probing which emails have an account are both limited.
*/
@Service
public class RegistrationAttemptService {
static final int MAX_FAILURES_PER_IP = 10;
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
public void acquire(String clientIp) {
if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
}
}
public void release(String clientIp) {
limiter.release(clientIp);
}
}
@@ -17,8 +17,20 @@ public class RegistrationService {
private final CodeValidationService codeValidationService; private final CodeValidationService codeValidationService;
private final RegisteredUserRepository registeredUserRepository; private final RegisteredUserRepository registeredUserRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RegistrationAttemptService registrationAttemptService;
public long register(Registration registration) { public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp);
validate(registration);
registrationAttemptService.release(clientIp);
var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
return registeredUserRepository.save(registration);
}
private void validate(Registration registration) {
if (!codeValidationService.isCodeValid(registration.getInviteCode())) { if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
throw new InvalidInvitationCodeException("The provided invite code is invalid or has already been used."); throw new InvalidInvitationCodeException("The provided invite code is invalid or has already been used.");
} }
@@ -34,10 +46,5 @@ public class RegistrationService {
if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) { if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) {
throw new DisplayNameAlreadyRegisteredException("The display name is already in use."); throw new DisplayNameAlreadyRegisteredException("The display name is already in use.");
} }
var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
return registeredUserRepository.save(registration);
} }
} }
@@ -0,0 +1,23 @@
package auth_service.shared.api;
import jakarta.servlet.http.HttpServletRequest;
public final class ClientIp {
private ClientIp() {
}
/**
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
* from the client and can be faked, so only the last one counts. Without a usable last entry
* (no proxy in front) the connection address counts.
*/
public static String of(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor == null) {
return request.getRemoteAddr();
}
String lastHop = forwardedFor.substring(forwardedFor.lastIndexOf(',') + 1).trim();
return lastHop.isEmpty() ? request.getRemoteAddr() : lastHop;
}
}
@@ -0,0 +1,49 @@
package auth_service.shared.security;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Counts attempts per key in memory. Callers take an attempt before the check and give it back on
* success, so only failures stay counted. A key is forgotten lockDuration after its last counted
* attempt. A restart resets all counters.
*/
public class AttemptLimiter {
private final Cache<String, AtomicInteger> counters;
public AttemptLimiter(Duration lockDuration) {
this.counters = Caffeine.newBuilder()
.expireAfterWrite(lockDuration)
.maximumSize(100_000)
.build();
}
/**
* Check and count are one atomic step, so parallel requests cannot pass more than max.
*/
public boolean tryAcquire(String key, int max) {
AtomicInteger counter = counters.get(key, k -> new AtomicInteger());
if (counter.incrementAndGet() > max) {
counter.decrementAndGet();
return false;
}
// put() again so expireAfterWrite restarts the lock window with every counted attempt
counters.put(key, counter);
return true;
}
public void release(String key) {
AtomicInteger counter = counters.getIfPresent(key);
if (counter != null) {
counter.updateAndGet(n -> Math.max(0, n - 1));
}
}
public void reset(String key) {
counters.invalidate(key);
}
}
@@ -0,0 +1,15 @@
package auth_service.shared.security;
import java.util.List;
import java.util.Map;
/**
* Port for the public keys that verify this service's tokens, published as JWKS for Core.
*/
public interface PublicKeyPublisher {
/**
* @return one JWK (RFC 8037, OKP / Ed25519) per key that can verify a current token
*/
List<Map<String, ?>> publicJwks();
}
@@ -12,13 +12,18 @@ import java.security.KeyPairGenerator;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/** /**
* Tests for JwtService (EdDSA / Ed25519). * Tests for JwtService (EdDSA / Ed25519).
@@ -124,4 +129,36 @@ class JwtServiceTest extends AbstractUnitTest {
void isTokenValid_ProfileTicketIsNoLoginToken() { void isTokenValid_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L))); assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L)));
} }
@Test
void init_BlankKid_FailsAtStartup() {
when(jwtConfig.getKid()).thenReturn(" ");
JwtService service = new JwtService(jwtConfig);
assertThrows(IllegalStateException.class, service::init);
}
@Test
void publicJwks_PublishesTheVerifyKeyAsOkpJwk() throws Exception {
List<Map<String, ?>> jwks = jwtService.publicJwks();
assertEquals(1, jwks.size());
Map<String, ?> jwk = jwks.getFirst();
assertEquals("OKP", jwk.get("kty"));
assertEquals("Ed25519", jwk.get("crv"));
assertEquals("EdDSA", jwk.get("alg"));
assertEquals("sig", jwk.get("use"));
assertEquals(KID, jwk.get("kid"));
byte[] raw = Base64.getUrlDecoder().decode((String) jwk.get("x"));
assertEquals(32, raw.length);
// A verifier that builds the key from "x" alone accepts a token of this service.
byte[] prefix = HexFormat.of().parseHex("302a300506032b6570032100");
byte[] der = new byte[prefix.length + raw.length];
System.arraycopy(prefix, 0, der, 0, prefix.length);
System.arraycopy(raw, 0, der, prefix.length, raw.length);
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
String token = jwtService.generateToken("7", "a@b.de", null);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
}
} }
@@ -11,6 +11,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -27,6 +28,15 @@ class PublicEndpointsTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.components").doesNotExist()); .andExpect(jsonPath("$.components").doesNotExist());
} }
@Test
void jwksIsPublicAndCacheable() throws Exception {
mockMvc.perform(get("/.well-known/jwks.json"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("max-age=300")))
.andExpect(jsonPath("$.keys[0].kty").value("OKP"))
.andExpect(jsonPath("$.keys[0].kid").exists());
}
@Test @Test
void prometheusIsNotPublic() throws Exception { void prometheusIsNotPublic() throws Exception {
int status = mockMvc.perform(get("/actuator/prometheus")).andReturn().getResponse().getStatus(); int status = mockMvc.perform(get("/actuator/prometheus")).andReturn().getResponse().getStatus();
@@ -5,8 +5,18 @@ import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LoginAttemptServiceTest extends AbstractUnitTest { class LoginAttemptServiceTest extends AbstractUnitTest {
@@ -20,33 +30,91 @@ class LoginAttemptServiceTest extends AbstractUnitTest {
@Test @Test
void blocksEmailAfterFiveFailures() { void blocksEmailAfterFiveFailures() {
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL; i++) { for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL; i++) {
attempts.checkAllowed("a@b.de", "1.1.1." + i); attempts.acquire("a@b.de", "1.1.1." + i);
attempts.recordFailure("a@b.de", "1.1.1." + i);
} }
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("a@b.de", "9.9.9.9")); assertThrows(TooManyLoginAttemptsException.class, () -> attempts.acquire("a@b.de", "9.9.9.9"));
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("A@B.DE", "9.9.9.9")); assertThrows(TooManyLoginAttemptsException.class, () -> attempts.acquire("A@B.DE", "9.9.9.9"));
assertDoesNotThrow(() -> attempts.checkAllowed("other@b.de", "9.9.9.9")); assertDoesNotThrow(() -> attempts.acquire("other@b.de", "9.9.9.9"));
} }
@Test @Test
void blocksIpAfterTwentyFailures() { void blocksIpAfterTwentyFailures() {
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_IP; i++) { for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_IP; i++) {
attempts.recordFailure("user" + i + "@b.de", "1.2.3.4"); attempts.acquire("user" + i + "@b.de", "1.2.3.4");
} }
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("new@b.de", "1.2.3.4")); assertThrows(TooManyLoginAttemptsException.class, () -> attempts.acquire("new@b.de", "1.2.3.4"));
assertDoesNotThrow(() -> attempts.checkAllowed("new@b.de", "5.6.7.8")); assertDoesNotThrow(() -> attempts.acquire("new@b.de", "5.6.7.8"));
}
@Test
void blockedIpDoesNotUseUpAnEmailAttempt() {
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_IP; i++) {
attempts.acquire("user" + i + "@b.de", "1.2.3.4");
}
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL; i++) {
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.acquire("victim@b.de", "1.2.3.4"));
}
assertDoesNotThrow(() -> attempts.acquire("victim@b.de", "5.6.7.8"));
} }
@Test @Test
void successResetsEmailCounter() { void successResetsEmailCounter() {
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL - 1; i++) { for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL - 1; i++) {
attempts.recordFailure("a@b.de", "1.1.1.1"); attempts.acquire("a@b.de", "1.1.1.1");
} }
attempts.recordSuccess("a@b.de"); attempts.acquire("a@b.de", "1.1.1.1");
attempts.recordFailure("a@b.de", "1.1.1.1"); attempts.recordSuccess("a@b.de", "1.1.1.1");
attempts.acquire("a@b.de", "1.1.1.1");
assertDoesNotThrow(() -> attempts.checkAllowed("a@b.de", "1.1.1.1")); assertDoesNotThrow(() -> attempts.acquire("a@b.de", "1.1.1.1"));
}
@Test
void successDoesNotUseUpAnIpAttempt() {
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
attempts.acquire("user" + i + "@b.de", "1.2.3.4");
attempts.recordSuccess("user" + i + "@b.de", "1.2.3.4");
}
assertDoesNotThrow(() -> attempts.acquire("new@b.de", "1.2.3.4"));
}
@Test
void concurrentBurstCannotExceedEmailLimit() throws Exception {
int requests = 50;
CyclicBarrier allChecked = new CyclicBarrier(requests);
AtomicInteger passed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(requests);
List<Future<?>> results = new ArrayList<>();
try {
for (int i = 0; i < requests; i++) {
String ip = "10.0.0." + i;
results.add(pool.submit(() -> {
boolean allowed = true;
try {
attempts.acquire("a@b.de", ip);
} catch (TooManyLoginAttemptsException e) {
allowed = false;
}
allChecked.await(5, TimeUnit.SECONDS);
if (allowed) {
passed.incrementAndGet();
}
return null;
}));
}
for (Future<?> result : results) {
result.get(10, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
}
assertTrue(passed.get() <= LoginAttemptService.MAX_FAILURES_PER_EMAIL,
"attempts that passed the limit: " + passed.get());
} }
} }
@@ -76,7 +76,8 @@ class LoginServiceTest extends AbstractUnitTest {
// Assert // Assert
assertEquals("mock-token", result); assertEquals("mock-token", result);
verify(loginAttemptService).recordSuccess("test@example.com"); verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService).recordSuccess("test@example.com", IP);
} }
@Test @Test
@@ -88,7 +89,8 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
// bcrypt still runs, so the response time does not reveal that the email is unknown // bcrypt still runs, so the response time does not reveal that the email is unknown
verify(passwordEncoder).matches("password", "dummy-hash"); verify(passwordEncoder).matches("password", "dummy-hash");
verify(loginAttemptService).recordFailure("test@example.com", IP); verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService, never()).recordSuccess(any(), any());
} }
@Test @Test
@@ -104,7 +106,8 @@ class LoginServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
verify(loginAttemptService).recordFailure("test@example.com", IP); verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService, never()).recordSuccess(any(), any());
} }
@Test @Test
@@ -127,7 +130,7 @@ class LoginServiceTest extends AbstractUnitTest {
@Test @Test
void login_TooManyAttempts() { void login_TooManyAttempts() {
// Arrange // Arrange
doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).checkAllowed("test@example.com", IP); doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).acquire("test@example.com", IP);
// Act & Assert // Act & Assert
assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP)); assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP));
@@ -4,6 +4,7 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.modules.registration.api.dto.request.RegistrationRequest; import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.service.RegistrationService; import auth_service.modules.registration.service.RegistrationService;
import auth_service.shared.test.AbstractWebMvcTest; import auth_service.shared.test.AbstractWebMvcTest;
@@ -21,12 +22,14 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -67,7 +70,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
long expectedUserId = 42L; long expectedUserId = 42L;
// Mock the service call, ignoring the exact static mapping output // Mock the service call, ignoring the exact static mapping output
when(registrationService.register(any(Registration.class))).thenReturn(expectedUserId); when(registrationService.register(any(Registration.class), any())).thenReturn(expectedUserId);
// Act & Assert // Act & Assert
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
@@ -87,7 +90,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.steamId("76561198000000001") .steamId("76561198000000001")
.build(); .build();
when(registrationService.register(any(Registration.class))) when(registrationService.register(any(Registration.class), any()))
.thenThrow(new EmailAlreadyInUseException("The provided email address is already in use.")); .thenThrow(new EmailAlreadyInUseException("The provided email address is already in use."));
// Act & Assert // Act & Assert
@@ -108,7 +111,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.inviteCode("code") .inviteCode("code")
.steamId("76561198000000001") .steamId("76561198000000001")
.build(); .build();
when(registrationService.register(any(Registration.class))).thenReturn(42L); when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
// Act // Act
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
@@ -118,7 +121,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Assert // Assert
var captor = ArgumentCaptor.forClass(Registration.class); var captor = ArgumentCaptor.forClass(Registration.class);
verify(registrationService).register(captor.capture()); verify(registrationService).register(captor.capture(), any());
assertEquals(76561198000000001L, captor.getValue().getSteamId()); assertEquals(76561198000000001L, captor.getValue().getSteamId());
} }
@@ -160,7 +163,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("code") .inviteCode("code")
.build(); .build();
when(registrationService.register(any(Registration.class))) when(registrationService.register(any(Registration.class), any()))
.thenThrow(new DataIntegrityViolationException("users_email_key")); .thenThrow(new DataIntegrityViolationException("users_email_key"));
// Act & Assert: the user module's 409 mapping must not apply to registration // Act & Assert: the user module's 409 mapping must not apply to registration
@@ -183,7 +186,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Test @Test
void register_PassesDisplayName() throws Exception { void register_PassesDisplayName() throws Exception {
// Arrange // Arrange
when(registrationService.register(any(Registration.class))).thenReturn(42L); when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
// Act // Act
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
@@ -194,7 +197,38 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Assert // Assert
var captor = ArgumentCaptor.forClass(Registration.class); var captor = ArgumentCaptor.forClass(Registration.class);
verify(registrationService).register(captor.capture()); verify(registrationService).register(captor.capture(), any());
assertEquals("Lobo", captor.getValue().getDisplayName()); assertEquals("Lobo", captor.getValue().getDisplayName());
} }
@Test
void register_UsesLastForwardedForHopAsClientIp() throws Exception {
// Arrange
when(registrationService.register(any(Registration.class), eq("203.0.113.7"))).thenReturn(42L);
// Act & Assert
mockMvc.perform(post("/api/registration/new")
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42L));
}
@Test
void register_TooManyAttempts() throws Exception {
// Arrange
when(registrationService.register(any(Registration.class), any()))
.thenThrow(new TooManyRegistrationAttemptsException(900));
// Act & Assert
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
}
} }
@@ -0,0 +1,98 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RegistrationAttemptServiceTest extends AbstractUnitTest {
private RegistrationAttemptService attempts;
@BeforeEach
void setUp() {
attempts = new RegistrationAttemptService();
}
@Test
void blocksIpAfterTenFailures() {
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP; i++) {
attempts.acquire("1.2.3.4");
}
TooManyRegistrationAttemptsException blocked =
assertThrows(TooManyRegistrationAttemptsException.class, () -> attempts.acquire("1.2.3.4"));
assertEquals(RegistrationAttemptService.LOCK_DURATION.toSeconds(), blocked.getRetryAfterSeconds());
assertDoesNotThrow(() -> attempts.acquire("5.6.7.8"));
}
@Test
void successDoesNotUseUpAnAttempt() {
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
attempts.acquire("1.2.3.4");
attempts.release("1.2.3.4");
}
assertDoesNotThrow(() -> attempts.acquire("1.2.3.4"));
}
@Test
void blockedAttemptIsNotCounted() {
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP; i++) {
attempts.acquire("1.2.3.4");
}
assertThrows(TooManyRegistrationAttemptsException.class, () -> attempts.acquire("1.2.3.4"));
attempts.release("1.2.3.4");
assertDoesNotThrow(() -> attempts.acquire("1.2.3.4"));
}
@Test
void concurrentBurstCannotExceedLimit() throws Exception {
int requests = 50;
CyclicBarrier allChecked = new CyclicBarrier(requests);
AtomicInteger passed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(requests);
List<Future<?>> results = new ArrayList<>();
try {
for (int i = 0; i < requests; i++) {
results.add(pool.submit(() -> {
boolean allowed = true;
try {
attempts.acquire("1.2.3.4");
} catch (TooManyRegistrationAttemptsException e) {
allowed = false;
}
allChecked.await(5, TimeUnit.SECONDS);
if (allowed) {
passed.incrementAndGet();
}
return null;
}));
}
for (Future<?> result : results) {
result.get(10, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
}
assertTrue(passed.get() <= RegistrationAttemptService.MAX_FAILURES_PER_IP,
"attempts that passed the limit: " + passed.get());
}
}
@@ -4,6 +4,7 @@ import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
@@ -23,6 +24,8 @@ import static org.mockito.Mockito.*;
*/ */
class RegistrationServiceTest extends AbstractUnitTest { class RegistrationServiceTest extends AbstractUnitTest {
private static final String IP = "203.0.113.7";
@Mock @Mock
private CodeValidationService codeValidationService; private CodeValidationService codeValidationService;
@@ -32,6 +35,9 @@ class RegistrationServiceTest extends AbstractUnitTest {
@Mock @Mock
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Mock
private RegistrationAttemptService registrationAttemptService;
@InjectMocks @InjectMocks
private RegistrationService registrationService; private RegistrationService registrationService;
@@ -58,13 +64,15 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId); when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
// Act // Act
long result = registrationService.register(validRegistration); long result = registrationService.register(validRegistration, IP);
// Assert // Assert
assertEquals(expectedUserId, result); assertEquals(expectedUserId, result);
verify(passwordEncoder).encode("SuperSecret123!"); verify(passwordEncoder).encode("SuperSecret123!");
verify(registeredUserRepository).save(validRegistration); verify(registeredUserRepository).save(validRegistration);
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword()); assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
} }
@Test @Test
@@ -73,10 +81,11 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false); when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false);
// Act & Assert // Act & Assert
assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration)); assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).existsByEmail(anyString()); verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString());
} }
@Test @Test
@@ -86,10 +95,11 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true);
// Act & Assert // Act & Assert
assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration)); assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).existsBySteamId(anyLong()); verify(registeredUserRepository, never()).existsBySteamId(anyLong());
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString());
} }
@Test @Test
@@ -100,9 +110,10 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(true); when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(true);
// Act & Assert // Act & Assert
assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration)); assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString());
} }
@Test @Test
@@ -115,7 +126,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L); when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L);
// Act // Act
long result = registrationService.register(validRegistration); long result = registrationService.register(validRegistration, IP);
// Assert // Assert
assertEquals(42L, result); assertEquals(42L, result);
@@ -132,7 +143,19 @@ class RegistrationServiceTest extends AbstractUnitTest {
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
// Act & Assert // Act & Assert
assertThrows(DisplayNameAlreadyRegisteredException.class, () -> registrationService.register(validRegistration)); assertThrows(DisplayNameAlreadyRegisteredException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString());
}
@Test
void register_BlockedIp_ChecksNothingElse() {
// Arrange
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationAttemptService).acquire(IP);
// Act & Assert
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
verifyNoInteractions(codeValidationService, registeredUserRepository);
verify(registrationAttemptService, never()).release(anyString());
} }
} }
@@ -0,0 +1,28 @@
package auth_service.shared.api;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ClientIpTest extends AbstractUnitTest {
@Test
void commaOnlyForwardedForFallsBackToRemoteAddr() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("198.51.100.9");
request.addHeader("X-Forwarded-For", ",");
assertEquals("198.51.100.9", ClientIp.of(request));
}
@Test
void blankLastHopFallsBackToRemoteAddr() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRemoteAddr("198.51.100.9");
request.addHeader("X-Forwarded-For", "10.0.0.1, ");
assertEquals("198.51.100.9", ClientIp.of(request));
}
}