Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600b0b143f | ||
|
|
3a48ce0ff1 |
No files matched your search
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
@@ -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());
|
||||||
|
|||||||
+13
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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));
|
||||||
|
|||||||
+41
-7
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+98
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-6
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user