fix(auth): tighten API URL match, add Retry-After header, and add missing tests
Some checks failed
CI / fail2ban Regex (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled

- frontend/hooks.server.ts: replace request.url.includes('/api/') with
  new URL(request.url).pathname.startsWith('/api/') so a page named
  /my-api/something cannot accidentally match the API gate
- DomainException: add optional retryAfterSeconds field and a new
  tooManyRequests() factory overload that carries the value
- LoginRateLimiter: pass windowMinutes * 60 as retryAfterSeconds when
  throwing TOO_MANY_LOGIN_ATTEMPTS (RFC 6585 §4 SHOULD)
- GlobalExceptionHandler: emit Retry-After header when retryAfterSeconds
  is set on a DomainException
- RateLimitInterceptor: emit Retry-After: 60 on 429 responses (1-min
  window matches the existing MAX_REQUESTS_PER_MINUTE logic)
- LoginRateLimiterTest: assert retryAfterSeconds equals window duration
- RateLimitInterceptorTest: assert Retry-After header is set on 429
- JdbcSessionRevocationAdapterIntegrationTest: new @SpringBootTest +
  Testcontainers test verifying revokeAll deletes all spring_session rows
  and revokeOther leaves the current session intact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #617.
This commit is contained in:
Marcel
2026-05-19 09:08:26 +02:00
committed by marcel
parent 96c0aa592c
commit 164a917d95
8 changed files with 186 additions and 6 deletions

View File

@@ -42,16 +42,17 @@ public class LoginRateLimiter {
// For the current single-VPS setup this is the correct, simplest implementation.
public void checkAndConsume(String ip, String email) {
long retryAfterSeconds = windowMinutes * 60L;
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
if (!byIpEmail.get(key).tryConsume(1)) {
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip);
"Too many login attempts from " + ip, retryAfterSeconds);
}
if (!byIp.get(ip).tryConsume(1)) {
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
byIpEmail.get(key).addTokens(1);
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip);
"Too many login attempts from " + ip, retryAfterSeconds);
}
}

View File

@@ -28,6 +28,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
return false;
}

View File

@@ -10,11 +10,21 @@ public class DomainException extends RuntimeException {
private final ErrorCode code;
private final HttpStatus status;
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
private final Long retryAfterSeconds;
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = null;
}
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = retryAfterSeconds;
}
public ErrorCode getCode() {
@@ -25,6 +35,11 @@ public class DomainException extends RuntimeException {
return status;
}
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
public Long getRetryAfterSeconds() {
return retryAfterSeconds;
}
// --- Static factories for common cases ---
public static DomainException notFound(ErrorCode code, String message) {
@@ -59,4 +74,8 @@ public class DomainException extends RuntimeException {
public static DomainException tooManyRequests(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
}
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
}

View File

@@ -23,9 +23,11 @@ public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
return ResponseEntity
.status(ex.getStatus())
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
var builder = ResponseEntity.status(ex.getStatus());
if (ex.getRetryAfterSeconds() != null) {
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
}
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)