From e46e3e42e6d2c6acec15f96c7a0c1b1958f4354d Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Fri, 24 Apr 2026 12:52:36 +0200 Subject: [PATCH 1/3] Rate limit and brute force protection. Some updates to landing page aswell --- pom.xml | 4 + .../infrastructure/config/SecurityConfig.java | 20 +- .../security/LoginAttemptService.java | 167 +++++++++++++++ .../LoginAuthenticationFailureHandler.java | 44 ++++ .../LoginAuthenticationSuccessHandler.java | 31 +++ .../security/LoginLockoutFilter.java | 54 +++++ .../security/RateLimitFilter.java | 65 ++++++ .../security/RateLimitService.java | 115 +++++++++++ .../SecurityObservabilityService.java | 69 +++++++ src/main/resources/application.properties | 16 +- src/main/resources/static/app.css | 77 ++++++- src/main/resources/static/app.js | 190 ++++++++++++------ src/main/resources/templates/landing.html | 126 ++++++------ src/main/resources/templates/login/login.html | 3 + .../security/LoginAttemptServiceTest.java | 79 ++++++++ .../LoginAuthenticationHandlersTest.java | 48 +++++ .../security/LoginLockoutFilterTest.java | 50 +++++ .../security/RateLimitFilterTest.java | 85 ++++++++ .../security/RateLimitServiceTest.java | 80 ++++++++ .../SecurityObservabilityServiceTest.java | 33 +++ 20 files changed, 1221 insertions(+), 135 deletions(-) create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilterTest.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityServiceTest.java diff --git a/pom.xml b/pom.xml index b5def9a..a5033ad 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,10 @@ org.springframework.boot spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java b/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java index 0dcb02b..7e54fce 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java @@ -1,7 +1,11 @@ package org.example.projektarendehantering.infrastructure.config; import org.example.projektarendehantering.infrastructure.security.CustomOAuth2UserService; +import org.example.projektarendehantering.infrastructure.security.LoginAuthenticationFailureHandler; +import org.example.projektarendehantering.infrastructure.security.LoginAuthenticationSuccessHandler; +import org.example.projektarendehantering.infrastructure.security.LoginLockoutFilter; import org.example.projektarendehantering.infrastructure.security.LocalUserDetailsService; +import org.example.projektarendehantering.infrastructure.security.RateLimitFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -11,6 +15,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.http.HttpStatus; @Configuration @@ -25,7 +30,12 @@ public SecurityConfig(LocalUserDetailsService localUserDetailsService) { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2UserService customOAuth2UserService) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + CustomOAuth2UserService customOAuth2UserService, + RateLimitFilter rateLimitFilter, + LoginLockoutFilter loginLockoutFilter, + LoginAuthenticationSuccessHandler loginAuthenticationSuccessHandler, + LoginAuthenticationFailureHandler loginAuthenticationFailureHandler) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/", "/login**", "/register", "/error**", "/static/**", "/app.css", "/app.js", "/webjars/**").permitAll() @@ -41,8 +51,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2Us .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") - .defaultSuccessUrl("/home", true) - .failureUrl("/login?error=true") + .successHandler(loginAuthenticationSuccessHandler) + .failureHandler(loginAuthenticationFailureHandler) ) .logout(logout -> logout .logoutSuccessUrl("/login?logout=true") @@ -55,7 +65,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2Us request -> request.getRequestURI().startsWith("/api/") ) ) - .userDetailsService(localUserDetailsService); + .userDetailsService(localUserDetailsService) + .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(loginLockoutFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java new file mode 100644 index 0000000..900caeb --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java @@ -0,0 +1,167 @@ +package org.example.projektarendehantering.infrastructure.security; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Tracks login failures and applies temporary lockout for local form login. + * + *

Phase 2 MVP behavior: + *

+ */ +@Service +@Slf4j +public class LoginAttemptService { + + private final ConcurrentMap emailAttempts = new ConcurrentHashMap<>(); + private final ConcurrentMap ipAttempts = new ConcurrentHashMap<>(); + private final int failureThreshold; + private final int failureWindowSeconds; + private final int lockDurationSeconds; + private final Clock clock; + private final SecurityObservabilityService securityObservabilityService; + + public LoginAttemptService( + @Value("${app.security.login-attempt.failure-threshold:5}") int failureThreshold, + @Value("${app.security.login-attempt.failure-window-seconds:900}") int failureWindowSeconds, + @Value("${app.security.login-attempt.lock-duration-seconds:900}") int lockDurationSeconds, + SecurityObservabilityService securityObservabilityService) { + this(failureThreshold, failureWindowSeconds, lockDurationSeconds, Clock.systemUTC(), securityObservabilityService); + } + + LoginAttemptService(int failureThreshold, int failureWindowSeconds, int lockDurationSeconds, Clock clock) { + this(failureThreshold, failureWindowSeconds, lockDurationSeconds, clock, SecurityObservabilityService.noop()); + } + + LoginAttemptService(int failureThreshold, + int failureWindowSeconds, + int lockDurationSeconds, + Clock clock, + SecurityObservabilityService securityObservabilityService) { + this.failureThreshold = failureThreshold; + this.failureWindowSeconds = failureWindowSeconds; + this.lockDurationSeconds = lockDurationSeconds; + this.clock = clock; + this.securityObservabilityService = securityObservabilityService; + } + + public LockDecision currentLockDecision(String email, String clientIp) { + long now = nowEpochSecond(); + long emailRetry = retryAfterSeconds(emailAttempts.get(normalizeEmail(email)), now); + long ipRetry = retryAfterSeconds(ipAttempts.get(normalizeIp(clientIp)), now); + long retryAfter = Math.max(emailRetry, ipRetry); + return new LockDecision(retryAfter > 0, retryAfter); + } + + public LockDecision recordFailure(String email, String clientIp) { + long now = nowEpochSecond(); + securityObservabilityService.recordLoginFailure(); + long emailRetry = registerFailure(emailAttempts, normalizeEmail(email), now); + long ipRetry = registerFailure(ipAttempts, normalizeIp(clientIp), now); + long retryAfter = Math.max(emailRetry, ipRetry); + if (retryAfter > 0) { + securityObservabilityService.recordLoginLocked(); + log.warn("security_event=LOGIN_LOCKED email={} clientIp={} retryAfterSeconds={}", normalizeEmail(email), normalizeIp(clientIp), retryAfter); + } else { + log.info("security_event=LOGIN_FAILED email={} clientIp={}", normalizeEmail(email), normalizeIp(clientIp)); + } + securityObservabilityService.setActiveLoginLocks(countActiveLocks(now)); + return new LockDecision(retryAfter > 0, retryAfter); + } + + public void recordSuccess(String email, String clientIp) { + emailAttempts.remove(normalizeEmail(email)); + ipAttempts.remove(normalizeIp(clientIp)); + securityObservabilityService.recordLoginSuccessReset(); + securityObservabilityService.setActiveLoginLocks(countActiveLocks(nowEpochSecond())); + log.info("security_event=LOGIN_SUCCESS_RESET email={} clientIp={}", normalizeEmail(email), normalizeIp(clientIp)); + } + + private long registerFailure(ConcurrentMap stateMap, String key, long nowEpochSecond) { + AttemptState state = stateMap.computeIfAbsent(key, ignored -> new AttemptState(nowEpochSecond)); + synchronized (state) { + if (state.lockedUntilEpochSecond > nowEpochSecond) { + return state.lockedUntilEpochSecond - nowEpochSecond; + } + + if ((nowEpochSecond - state.windowStartEpochSecond) >= failureWindowSeconds) { + state.windowStartEpochSecond = nowEpochSecond; + state.failureCount = 0; + } + + state.failureCount++; + if (state.failureCount >= failureThreshold) { + state.lockedUntilEpochSecond = nowEpochSecond + lockDurationSeconds; + return lockDurationSeconds; + } + return 0; + } + } + + private long retryAfterSeconds(AttemptState state, long nowEpochSecond) { + if (state == null) { + return 0; + } + synchronized (state) { + if (state.lockedUntilEpochSecond <= nowEpochSecond) { + state.lockedUntilEpochSecond = 0; + return 0; + } + return state.lockedUntilEpochSecond - nowEpochSecond; + } + } + + private long nowEpochSecond() { + return Instant.now(clock).getEpochSecond(); + } + + private int countActiveLocks(long nowEpochSecond) { + int emailLocks = countActiveLocks(emailAttempts, nowEpochSecond); + int ipLocks = countActiveLocks(ipAttempts, nowEpochSecond); + return emailLocks + ipLocks; + } + + private int countActiveLocks(ConcurrentMap stateMap, long nowEpochSecond) { + int activeLocks = 0; + for (AttemptState state : stateMap.values()) { + synchronized (state) { + if (state.lockedUntilEpochSecond > nowEpochSecond) { + activeLocks++; + } + } + } + return activeLocks; + } + + private String normalizeEmail(String email) { + return email == null ? "" : email.trim().toLowerCase(Locale.ROOT); + } + + private String normalizeIp(String clientIp) { + return clientIp == null ? "" : clientIp.trim(); + } + + public record LockDecision(boolean locked, long retryAfterSeconds) { + } + + private static final class AttemptState { + private long windowStartEpochSecond; + private int failureCount; + private long lockedUntilEpochSecond; + + private AttemptState(long windowStartEpochSecond) { + this.windowStartEpochSecond = windowStartEpochSecond; + } + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java new file mode 100644 index 0000000..e1d859f --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java @@ -0,0 +1,44 @@ +package org.example.projektarendehantering.infrastructure.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Records local-login failures and redirects with a generic error outcome. + */ +@Component +@Slf4j +public class LoginAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private final LoginAttemptService loginAttemptService; + + public LoginAuthenticationFailureHandler(LoginAttemptService loginAttemptService) { + this.loginAttemptService = loginAttemptService; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + String username = request.getParameter("username"); + String clientIp = request.getRemoteAddr(); + LoginAttemptService.LockDecision decision = loginAttemptService.recordFailure(username, clientIp); + + String redirectUrl = "/login?error=true"; + if (decision.locked()) { + redirectUrl = redirectUrl + "&locked=true&retryAfter=" + URLEncoder.encode(String.valueOf(decision.retryAfterSeconds()), StandardCharsets.UTF_8); + } + log.info("security_event=LOGIN_FAILURE_REDIRECT clientIp={} locked={} retryAfterSeconds={}", + clientIp, decision.locked(), decision.retryAfterSeconds()); + response.sendRedirect(redirectUrl); + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java new file mode 100644 index 0000000..d9c560b --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java @@ -0,0 +1,31 @@ +package org.example.projektarendehantering.infrastructure.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Resets tracked failure state for local login after successful authentication. + */ +@Component +public class LoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private final LoginAttemptService loginAttemptService; + + public LoginAuthenticationSuccessHandler(LoginAttemptService loginAttemptService) { + this.loginAttemptService = loginAttemptService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + loginAttemptService.recordSuccess(authentication.getName(), request.getRemoteAddr()); + response.sendRedirect("/home"); + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java new file mode 100644 index 0000000..46f8633 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java @@ -0,0 +1,54 @@ +package org.example.projektarendehantering.infrastructure.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Blocks local login requests while lockout is active. + */ +@Component +@Slf4j +public class LoginLockoutFilter extends OncePerRequestFilter { + + private final LoginAttemptService loginAttemptService; + + public LoginLockoutFilter(LoginAttemptService loginAttemptService) { + this.loginAttemptService = loginAttemptService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!isLocalLoginAttempt(request)) { + filterChain.doFilter(request, response); + return; + } + + String username = request.getParameter("username"); + String clientIp = request.getRemoteAddr(); + LoginAttemptService.LockDecision decision = loginAttemptService.currentLockDecision(username, clientIp); + if (!decision.locked()) { + filterChain.doFilter(request, response); + return; + } + + log.warn("security_event=LOGIN_LOCKOUT_BLOCKED clientIp={} username={} retryAfterSeconds={}", + clientIp, username == null ? "" : username.trim().toLowerCase(), decision.retryAfterSeconds()); + String retryAfter = URLEncoder.encode(String.valueOf(decision.retryAfterSeconds()), StandardCharsets.UTF_8); + response.sendRedirect("/login?error=true&locked=true&retryAfter=" + retryAfter); + } + + private boolean isLocalLoginAttempt(HttpServletRequest request) { + return "/login".equals(request.getRequestURI()) && "POST".equalsIgnoreCase(request.getMethod()); + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java new file mode 100644 index 0000000..fa361eb --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java @@ -0,0 +1,65 @@ +package org.example.projektarendehantering.infrastructure.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Servlet filter that enforces per-IP rate limiting policies for MVP Phase 1. + * + *

Policy mapping: + *

    + *
  • `/api/**` -> global API policy
  • + *
  • `/login`, `/register` -> auth endpoint policy
  • + *
+ */ +@Component +@Slf4j +public class RateLimitFilter extends OncePerRequestFilter { + + private static final String RETRY_AFTER_HEADER = "Retry-After"; + + private final RateLimitService rateLimitService; + private final SecurityObservabilityService securityObservabilityService; + + public RateLimitFilter(RateLimitService rateLimitService, SecurityObservabilityService securityObservabilityService) { + this.rateLimitService = rateLimitService; + this.securityObservabilityService = securityObservabilityService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String requestPath = request.getRequestURI(); + var policy = rateLimitService.policyForPath(requestPath, request.getMethod()); + if (policy.isEmpty()) { + filterChain.doFilter(request, response); + return; + } + + String clientIp = request.getRemoteAddr(); + var decision = rateLimitService.evaluate(policy.get(), clientIp); + if (decision.allowed()) { + filterChain.doFilter(request, response); + return; + } + + securityObservabilityService.recordRateLimitDenied(policy.get().name()); + log.info("security_event=RATE_LIMIT_DENIED policy={} path={} method={} clientIp={} retryAfterSeconds={}", + policy.get().name(), requestPath, request.getMethod(), clientIp, decision.retryAfterSeconds()); + + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setHeader(RETRY_AFTER_HEADER, String.valueOf(decision.retryAfterSeconds())); + response.setContentType(MediaType.TEXT_PLAIN_VALUE); + response.getWriter().write("Too many requests. Please try again later."); + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java new file mode 100644 index 0000000..d9ece37 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java @@ -0,0 +1,115 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * In-memory, IP-based rate limiter for MVP Phase 1. + * + *

Implementation notes: + *

    + *
  • Uses fixed time windows per policy and IP key.
  • + *
  • Stores counters in memory, so state is per-node and reset on restart.
  • + *
  • Returns retry-after hints used by the web filter when request is denied.
  • + *
+ */ +@Service +public class RateLimitService { + + private final ConcurrentMap counters = new ConcurrentHashMap<>(); + private final int globalApiLimit; + private final int globalApiWindowSeconds; + private final int authEndpointLimit; + private final int authEndpointWindowSeconds; + private final Clock clock; + + public RateLimitService( + @Value("${app.security.rate-limit.global-api.limit:100}") int globalApiLimit, + @Value("${app.security.rate-limit.global-api.window-seconds:60}") int globalApiWindowSeconds, + @Value("${app.security.rate-limit.auth-endpoint.limit:10}") int authEndpointLimit, + @Value("${app.security.rate-limit.auth-endpoint.window-seconds:60}") int authEndpointWindowSeconds) { + this(globalApiLimit, globalApiWindowSeconds, authEndpointLimit, authEndpointWindowSeconds, Clock.systemUTC()); + } + + RateLimitService(int globalApiLimit, + int globalApiWindowSeconds, + int authEndpointLimit, + int authEndpointWindowSeconds, + Clock clock) { + this.globalApiLimit = globalApiLimit; + this.globalApiWindowSeconds = globalApiWindowSeconds; + this.authEndpointLimit = authEndpointLimit; + this.authEndpointWindowSeconds = authEndpointWindowSeconds; + this.clock = clock; + } + + public Optional policyForPath(String requestPath, String httpMethod) { + if (requestPath == null) { + return Optional.empty(); + } + if (requestPath.startsWith("/api/")) { + return Optional.of(RateLimitPolicy.GLOBAL_API); + } + if ("POST".equalsIgnoreCase(httpMethod) && ("/login".equals(requestPath) || "/register".equals(requestPath))) { + return Optional.of(RateLimitPolicy.AUTH_ENDPOINT); + } + return Optional.empty(); + } + + public RateLimitDecision evaluate(RateLimitPolicy policy, String clientIp) { + PolicyConfig policyConfig = policyConfig(policy); + long nowEpochSecond = Instant.now(clock).getEpochSecond(); + String counterKey = policy.name() + ":" + clientIp; + CounterWindow window = counters.computeIfAbsent(counterKey, ignored -> new CounterWindow(nowEpochSecond)); + + synchronized (window) { + long elapsedSeconds = nowEpochSecond - window.windowStartEpochSecond; + if (elapsedSeconds >= policyConfig.windowSeconds()) { + window.windowStartEpochSecond = nowEpochSecond; + window.requestCount = 0; + } + + if (window.requestCount >= policyConfig.limit()) { + long retryAfterSeconds = Math.max(1, policyConfig.windowSeconds() - (nowEpochSecond - window.windowStartEpochSecond)); + return new RateLimitDecision(false, 0, retryAfterSeconds); + } + + window.requestCount++; + int remaining = Math.max(0, policyConfig.limit() - window.requestCount); + return new RateLimitDecision(true, remaining, 0); + } + } + + private PolicyConfig policyConfig(RateLimitPolicy policy) { + return switch (policy) { + case GLOBAL_API -> new PolicyConfig(globalApiLimit, globalApiWindowSeconds); + case AUTH_ENDPOINT -> new PolicyConfig(authEndpointLimit, authEndpointWindowSeconds); + }; + } + + public enum RateLimitPolicy { + GLOBAL_API, + AUTH_ENDPOINT + } + + public record RateLimitDecision(boolean allowed, int remainingRequests, long retryAfterSeconds) { + } + + private record PolicyConfig(int limit, int windowSeconds) { + } + + private static final class CounterWindow { + private long windowStartEpochSecond; + private int requestCount; + + private CounterWindow(long windowStartEpochSecond) { + this.windowStartEpochSecond = windowStartEpochSecond; + } + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java new file mode 100644 index 0000000..a6bb54e --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java @@ -0,0 +1,69 @@ +package org.example.projektarendehantering.infrastructure.security; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Centralized security telemetry for Phase 3 metrics. + * + *

When a {@link MeterRegistry} is available, counters and gauges are published. + * Without a registry, methods still work as no-ops for easy testing. + */ +@Service +public class SecurityObservabilityService { + + private final MeterRegistry meterRegistry; + private final AtomicInteger activeLoginLocks = new AtomicInteger(); + + private SecurityObservabilityService(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + if (meterRegistry != null) { + Gauge.builder("security.login.active_locks", activeLoginLocks, AtomicInteger::get) + .description("Current number of active login lockouts") + .register(meterRegistry); + } + } + + public SecurityObservabilityService(ObjectProvider meterRegistryProvider) { + this(meterRegistryProvider.getIfAvailable()); + } + + public static SecurityObservabilityService noop() { + return new SecurityObservabilityService((MeterRegistry) null); + } + + public void recordRateLimitDenied(String policy) { + incrementCounter("security.rate_limit.denied", "policy", policy); + } + + public void recordLoginFailure() { + incrementCounter("security.login.failures", null, null); + } + + public void recordLoginLocked() { + incrementCounter("security.login.locked", null, null); + } + + public void recordLoginSuccessReset() { + incrementCounter("security.login.success_reset", null, null); + } + + public void setActiveLoginLocks(int activeLocks) { + activeLoginLocks.set(Math.max(activeLocks, 0)); + } + + private void incrementCounter(String name, String tagKey, String tagValue) { + if (meterRegistry == null) { + return; + } + Counter counter = (tagKey == null) + ? meterRegistry.counter(name) + : meterRegistry.counter(name, tagKey, tagValue); + counter.increment(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 494cd67..12c0efd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -36,4 +36,18 @@ app.s3.retry.max-backoff-ms=2000 app.s3.failed-delete.batch-size=20 app.s3.failed-delete.retry-delay-seconds=60 app.s3.failed-delete.max-attempts=10 -app.s3.failed-delete.scheduler-delay-ms=30000 \ No newline at end of file +app.s3.failed-delete.scheduler-delay-ms=30000 + +# Security rate limiting +app.security.rate-limit.global-api.limit=100 +app.security.rate-limit.global-api.window-seconds=60 +app.security.rate-limit.auth-endpoint.limit=10 +app.security.rate-limit.auth-endpoint.window-seconds=60 + +# Login brute-force prevention +app.security.login-attempt.failure-threshold=5 +app.security.login-attempt.failure-window-seconds=900 +app.security.login-attempt.lock-duration-seconds=900 + +# Observability (Phase 3 MVP) +management.endpoints.web.exposure.include=health,info,metrics,prometheus \ No newline at end of file diff --git a/src/main/resources/static/app.css b/src/main/resources/static/app.css index 90cb613..7363839 100644 --- a/src/main/resources/static/app.css +++ b/src/main/resources/static/app.css @@ -676,6 +676,12 @@ textarea.input { padding: 12px; resize: vertical; } margin-top: 4px; } +.landing-slide { + transition: opacity 280ms ease, transform 280ms ease, filter 280ms ease; + position: relative; + overflow: hidden; +} + .button-strong { border-color: rgba(143, 190, 255, 0.95); background: linear-gradient(180deg, rgba(110, 168, 255, 0.42), rgba(110, 168, 255, 0.24)); @@ -708,6 +714,17 @@ textarea.input { padding: 12px; resize: vertical; } 70% { transform: translateY(0); } } +@keyframes panelGlow { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.38; } + 50% { transform: translate3d(0, -4px, 0) scale(1.03); opacity: 0.62; } +} + +@keyframes shimmerSweep { + 0% { transform: translateX(-120%); opacity: 0; } + 30% { opacity: 0.2; } + 100% { transform: translateX(120%); opacity: 0; } +} + .cta-attention { animation: ctaPulse 500ms ease-in-out 2; } @@ -738,7 +755,8 @@ textarea.input { padding: 12px; resize: vertical; } @media (prefers-reduced-motion: reduce) { .landing-progress-bar, .landing-nav-link, - .js-reveal.revealed { + .js-reveal.revealed, + .landing-slide { transition: none; } @@ -746,6 +764,11 @@ textarea.input { padding: 12px; resize: vertical; } opacity: 1; transform: none; } + + .landing-slide::before, + .landing-slide::after { + animation: none; + } } /* Calm presentation overrides */ @@ -1003,6 +1026,30 @@ textarea.input { padding: 12px; resize: vertical; } border-color: rgba(143, 190, 255, 0.48); } +.landing-slide::before { + content: ""; + position: absolute; + inset: auto -18% -80px auto; + width: 180px; + height: 180px; + border-radius: 999px; + background: radial-gradient(circle, rgba(143, 190, 255, 0.35), rgba(143, 190, 255, 0)); + pointer-events: none; + animation: panelGlow 7s ease-in-out infinite; +} + +.landing-slide::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 36%; + height: 2px; + background: linear-gradient(90deg, rgba(143, 190, 255, 0), rgba(180, 214, 255, 0.8), rgba(143, 190, 255, 0)); + pointer-events: none; + animation: shimmerSweep 8s ease-in-out infinite; +} + h1, h2 { color: #f4f7ff; } @@ -1070,6 +1117,7 @@ h1, h2 { transform: translateY(-2px); border-color: rgba(143, 190, 255, 0.56); background: rgba(255, 255, 255, 0.07); + box-shadow: 0 12px 22px rgba(5, 14, 40, 0.35); } .icon-dot { @@ -1113,3 +1161,30 @@ h1, h2 { .accordion__trigger:hover { background: rgba(110, 168, 255, 0.14); } + +@media (min-width: 981px) { + .js-enhanced .landing-page { + padding-top: 10px; + padding-bottom: 18px; + } + + .js-enhanced .landing-slide { + min-height: clamp(480px, 72vh, 760px); + opacity: 0.86; + transform: translateY(8px) scale(0.995); + filter: saturate(0.88); + } + + .js-enhanced .landing-slide.is-active-slide { + opacity: 1; + transform: translateY(0) scale(1); + filter: none; + box-shadow: 0 18px 44px rgba(2, 8, 28, 0.52); + } + + .js-enhanced .landing-slide.is-inactive-slide { + opacity: 0.84; + transform: translateY(8px) scale(0.995); + filter: saturate(0.88); + } +} diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 15de759..607163e 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -6,39 +6,16 @@ return; } - const sectionIds = [ - "problem-solution", - "case-lifecycle", - "role-based-access", - "documents-reliability", - "audit-trust", - "architecture-quality", - ]; - const navLinks = Array.from(document.querySelectorAll(".landing-nav-link")); - const sections = sectionIds - .map((id) => document.getElementById(id)) - .filter((section) => section !== null); + const slides = Array.from(document.querySelectorAll(".landing-slide")); const progressBar = document.getElementById("landingProgressBar"); const revealTargets = Array.from(document.querySelectorAll(".js-reveal")); const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const slideIds = slides.map((slide) => slide.id).filter(Boolean); + let activeSlideIndex = 0; - // Smooth scroll between module anchors for presentation flow. - navLinks.forEach((link) => { - link.addEventListener("click", (event) => { - const href = link.getAttribute("href"); - if (!href || !href.startsWith("#")) { - return; - } - const target = document.querySelector(href); - if (!target) { - return; - } - - event.preventDefault(); - target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "start" }); - history.replaceState(null, "", href); - }); + slides.forEach((slide, index) => { + slide.setAttribute("data-slide-index", String(index)); }); const updateActiveLink = (activeId) => { @@ -53,39 +30,110 @@ }); }; - const sectionObserver = new IntersectionObserver( - (entries) => { - let topVisible = null; - for (const entry of entries) { - if (!entry.isIntersecting) { - continue; - } - if (!topVisible || entry.boundingClientRect.top < topVisible.boundingClientRect.top) { - topVisible = entry; - } - } - if (topVisible && topVisible.target.id) { - updateActiveLink(topVisible.target.id); - } - }, - { threshold: 0.45, rootMargin: "-10% 0px -45% 0px" } - ); - - sections.forEach((section) => sectionObserver.observe(section)); + const updateSlideVisualState = () => { + slides.forEach((slide, index) => { + const isActive = index === activeSlideIndex; + slide.classList.toggle("is-active-slide", isActive); + slide.classList.toggle("is-inactive-slide", !isActive); + }); + }; const updateProgress = () => { if (!progressBar) { return; } + const scrollTop = window.scrollY || window.pageYOffset; const documentHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = documentHeight <= 0 ? 100 : Math.min(100, Math.max(0, (scrollTop / documentHeight) * 100)); progressBar.style.width = `${progress}%`; }; - window.addEventListener("scroll", updateProgress, { passive: true }); - window.addEventListener("resize", updateProgress); - updateProgress(); + const syncBySlide = () => { + const activeSlide = slides[activeSlideIndex]; + if (!activeSlide || !activeSlide.id) { + return; + } + updateActiveLink(activeSlide.id); + updateSlideVisualState(); + updateProgress(); + }; + + const goToSlide = (index, options = {}) => { + const { behavior = "smooth", shouldScroll = true, updateHash = true } = options; + if (slides.length === 0) { + return; + } + + const clampedIndex = Math.min(Math.max(index, 0), slides.length - 1); + if (clampedIndex === activeSlideIndex && !shouldScroll) { + syncBySlide(); + return; + } + + activeSlideIndex = clampedIndex; + syncBySlide(); + + const target = slides[activeSlideIndex]; + if (shouldScroll && target) { + target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : behavior, block: "start" }); + } + + if (updateHash && target && target.id) { + history.replaceState(null, "", `#${target.id}`); + } + }; + + // Smooth scroll between module anchors for presentation flow. + navLinks.forEach((link) => { + link.addEventListener("click", (event) => { + const href = link.getAttribute("href"); + if (!href || !href.startsWith("#")) { + return; + } + const target = document.querySelector(href); + if (!target) { + return; + } + + event.preventDefault(); + const targetIndex = slides.indexOf(target); + if (targetIndex >= 0) { + goToSlide(targetIndex, { behavior: "smooth", shouldScroll: true, updateHash: true }); + } else { + target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "start" }); + history.replaceState(null, "", href); + } + }); + }); + + const syncActiveSlideFromViewport = () => { + if (slides.length === 0) { + return; + } + const viewportCenter = window.innerHeight * 0.42; + let closestIndex = activeSlideIndex; + let closestDistance = Number.POSITIVE_INFINITY; + slides.forEach((slide, index) => { + const rect = slide.getBoundingClientRect(); + const distance = Math.abs(rect.top - viewportCenter); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = index; + } + }); + if (closestIndex !== activeSlideIndex) { + activeSlideIndex = closestIndex; + syncBySlide(); + } else { + updateProgress(); + } + }; + + window.addEventListener("scroll", syncActiveSlideFromViewport, { passive: true }); + window.addEventListener("resize", () => { + syncBySlide(); + }); if (prefersReducedMotion) { revealTargets.forEach((node) => node.classList.add("revealed")); @@ -152,20 +200,20 @@ const flowData = { create: { - title: "🆕 Skapa ärende", - chips: ["🧑 Patientkontext", "🏁 Startstatus", "🧭 Tydlig start"], + title: "Skapa ärende", + chips: ["Patientkontext", "Startstatus", "Tydlig start"], }, assign: { - title: "👤 Tilldela ansvar", - chips: ["👑 Ägare", "🛠️ Handläggare", "🎯 Klart ansvar"], + title: "Tilldela ansvar", + chips: ["Ägare", "Handläggare", "Klart ansvar"], }, update: { - title: "📝 Uppdatera och kommunicera", - chips: ["🗒️ Anteckningar", "📍 Status", "🤝 Samarbete"], + title: "Uppdatera och kommunicera", + chips: ["Anteckningar", "Status", "Samarbete"], }, close: { - title: "✅ Avsluta med spårbarhet", - chips: ["🏁 Avslut", "📚 Historik", "📈 Uppföljning"], + title: "Avsluta med spårbarhet", + chips: ["Avslut", "Historik", "Uppföljning"], }, }; @@ -193,20 +241,20 @@ const roleData = { manager: { - allow: ["👀 Se alla ärenden", "🧩 Hantera tilldelning", "📜 Granska loggar"], - deny: ["🚫 Ingen patientimitation"], + allow: ["Se alla ärenden", "Hantera tilldelning", "Granska loggar"], + deny: ["Ingen patientimitation"], }, doctor: { - allow: ["✏️ Uppdatera tilldelade", "🗒️ Lägga anteckningar", "📁 Hantera dokument"], - deny: ["🚫 Ingen användaradministration"], + allow: ["Uppdatera tilldelade", "Lägga anteckningar", "Hantera dokument"], + deny: ["Ingen användaradministration"], }, nurse: { - allow: ["👀 Se handlagda ärenden", "🗒️ Lägga anteckningar"], - deny: ["🚫 Ingen full adminbehörighet"], + allow: ["Se handlagda ärenden", "Lägga anteckningar"], + deny: ["Ingen full adminbehörighet"], }, patient: { - allow: ["📲 Följa egna ärenden", "💬 Lägga kommunikationsnotis"], - deny: ["🚫 Ingen åtkomst till andras ärenden"], + allow: ["Följa egna ärenden", "Lägga kommunikationsnotis"], + deny: ["Ingen åtkomst till andras ärenden"], }, }; @@ -250,4 +298,14 @@ }); }); }); + + const hash = window.location.hash.replace("#", ""); + const initialIndex = hash ? slideIds.indexOf(hash) : 0; + activeSlideIndex = initialIndex >= 0 ? initialIndex : 0; + goToSlide(activeSlideIndex, { behavior: "auto", shouldScroll: false, updateHash: false }); + if (hash) { + goToSlide(activeSlideIndex, { behavior: "auto", shouldScroll: true, updateHash: false }); + } else { + updateProgress(); + } })(); diff --git a/src/main/resources/templates/landing.html b/src/main/resources/templates/landing.html index 5a2811b..8e8f09c 100644 --- a/src/main/resources/templates/landing.html +++ b/src/main/resources/templates/landing.html @@ -8,138 +8,138 @@

-
+
- 🎓 Projektpresentation - ⏱️ 5-7 minuter + Projektpresentation ✦ + 5-7 minuter
-

🏥 Vårdens ärenden i ett tryggt flöde

+

Vårdens ärenden i ett tryggt flöde

Ett samlat system för registrering, ansvar och uppföljning utan onödiga överlämningar.

-
+
-

🧭 Från splittrat till samlat

+

Från splittrat till samlat

Två lägen: problem och lösning.

- - + +
-

Många verktyg

-

Tappar kontext

-

Långsamma överlämningar

+

Många verktyg

+

Tappar kontext

+

Långsamma överlämningar

-
-

🔄 Enkelt flöde, tydligt ansvar

+
+

Enkelt flöde, tydligt ansvar

    -
  1. -
  2. -
  3. -
  4. +
  5. +
  6. +
  7. +
-
-

👥 Rätt åtkomst för rätt roll

+
+

Rätt åtkomst för rätt roll

- - - - + + + +
-
+
-

📄 Dokument som håller

+

Dokument som håller

Mindre text, mer överblick.

- 🧪 MIME-kontroll - 🔁 Automatisk retry - 🛡️ Säker uppföljning + MIME-kontroll + Automatisk retry + Säker uppföljning
- + - +
-
+
-

🕵️ Full spårbarhet

+

Full spårbarhet

Vem, vad, när - direkt synligt.

- 👤 Aktör - 📌 Händelse - ⏰ Tidpunkt + Aktör + Händelse + Tidpunkt
-

🔎 Filter

Chef ser relevanta händelser snabbt.

-

📡 Live

Uppdateringar kan följas i nära realtid.

-

📊 Kontroll

Bättre underlag för uppföljning och kvalitet.

+

Filter

Chef ser relevanta händelser snabbt.

+

Live

Uppdateringar kan följas i nära realtid.

+

Kontroll

Bättre underlag för uppföljning och kvalitet.

-
-

🧱 Byggt för stabil utveckling

+
+

Byggt för stabil utveckling

-

🏗️ Tydliga lager

-

🔐 Säker standard

-

🧪 Automatiska tester

+

Tydliga lager

+

Säker standard

+

Automatiska tester

- 🔍 Visa tekniska bevis + Visa tekniska bevis

MVC-struktur, servicevalidering och testade kärnflöden ger tryggare ändringar över tid.

-
-

🎬 Avslut

+
+

Avslut

Redo att se helheten?

En plattform för säkrare samarbete och snabbare beslut i vården.

- ⚡ Snabbare samordning - 🔒 Säkrare åtkomst - 🧾 Full spårbarhet + Snabbare samordning + Säkrare åtkomst + Full spårbarhet
diff --git a/src/main/resources/templates/login/login.html b/src/main/resources/templates/login/login.html index 915da84..dcf5e80 100644 --- a/src/main/resources/templates/login/login.html +++ b/src/main/resources/templates/login/login.html @@ -21,6 +21,9 @@

Patient Login

Invalid email or password.
+
+ Too many login attempts. Please try again in a few minutes. +
You have been logged out.
diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java new file mode 100644 index 0000000..7077551 --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java @@ -0,0 +1,79 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginAttemptServiceTest { + + @Test + void recordFailure_shouldLockAfterThreshold() { + MutableClock clock = new MutableClock(Instant.parse("2026-04-24T11:00:00Z")); + LoginAttemptService service = new LoginAttemptService(5, 900, 900, clock); + + for (int i = 0; i < 4; i++) { + assertThat(service.recordFailure("user@example.com", "127.0.0.1").locked()).isFalse(); + } + + LoginAttemptService.LockDecision decision = service.recordFailure("user@example.com", "127.0.0.1"); + assertThat(decision.locked()).isTrue(); + assertThat(decision.retryAfterSeconds()).isEqualTo(900); + } + + @Test + void currentLockDecision_shouldUnlockAfterTtlExpires() { + MutableClock clock = new MutableClock(Instant.parse("2026-04-24T11:00:00Z")); + LoginAttemptService service = new LoginAttemptService(2, 900, 900, clock); + + service.recordFailure("user@example.com", "127.0.0.1"); + service.recordFailure("user@example.com", "127.0.0.1"); + assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isTrue(); + + clock.advanceSeconds(901); + assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isFalse(); + } + + @Test + void recordSuccess_shouldClearFailureState() { + MutableClock clock = new MutableClock(Instant.parse("2026-04-24T11:00:00Z")); + LoginAttemptService service = new LoginAttemptService(2, 900, 900, clock); + + service.recordFailure("user@example.com", "127.0.0.1"); + service.recordFailure("user@example.com", "127.0.0.1"); + assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isTrue(); + + service.recordSuccess("user@example.com", "127.0.0.1"); + assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isFalse(); + } + + private static final class MutableClock extends Clock { + private Instant current; + + private MutableClock(Instant initial) { + this.current = initial; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + + private void advanceSeconds(long seconds) { + current = current.plusSeconds(seconds); + } + } +} diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java new file mode 100644 index 0000000..d8c403f --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java @@ -0,0 +1,48 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LoginAuthenticationHandlersTest { + + @Test + void failureHandler_shouldRedirectWithLockedFlagWhenThresholdReached() throws Exception { + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + when(loginAttemptService.recordFailure("user@example.com", "127.0.0.1")) + .thenReturn(new LoginAttemptService.LockDecision(true, 300)); + LoginAuthenticationFailureHandler handler = new LoginAuthenticationFailureHandler(loginAttemptService); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login"); + request.setRemoteAddr("127.0.0.1"); + request.addParameter("username", "user@example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.onAuthenticationFailure(request, response, new org.springframework.security.authentication.BadCredentialsException("bad")); + + assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=true&locked=true&retryAfter=300"); + } + + @Test + void successHandler_shouldClearAttemptsAndRedirectHome() throws Exception { + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + LoginAuthenticationSuccessHandler handler = new LoginAuthenticationSuccessHandler(loginAttemptService); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken("user@example.com", "pw"); + + handler.onAuthenticationSuccess(request, response, authentication); + + verify(loginAttemptService).recordSuccess("user@example.com", "127.0.0.1"); + assertThat(response.getRedirectedUrl()).isEqualTo("/home"); + } +} diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java new file mode 100644 index 0000000..c3b4497 --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java @@ -0,0 +1,50 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoginLockoutFilterTest { + + @Test + void doFilter_shouldRedirectWhenLockIsActive() throws Exception { + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + when(loginAttemptService.currentLockDecision("blocked@example.com", "127.0.0.1")) + .thenReturn(new LoginAttemptService.LockDecision(true, 120)); + + LoginLockoutFilter filter = new LoginLockoutFilter(loginAttemptService); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login"); + request.setRemoteAddr("127.0.0.1"); + request.addParameter("username", "blocked@example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=true&locked=true&retryAfter=120"); + } + + @Test + void doFilter_shouldPassWhenNotLocked() throws Exception { + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + when(loginAttemptService.currentLockDecision("ok@example.com", "10.0.0.10")) + .thenReturn(new LoginAttemptService.LockDecision(false, 0)); + + LoginLockoutFilter filter = new LoginLockoutFilter(loginAttemptService); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login"); + request.setRemoteAddr("10.0.0.10"); + request.addParameter("username", "ok@example.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(response.getRedirectedUrl()).isNull(); + assertThat(response.getStatus()).isEqualTo(200); + } +} diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilterTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilterTest.java new file mode 100644 index 0000000..68bb770 --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilterTest.java @@ -0,0 +1,85 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RateLimitFilterTest { + + @Test + void doFilterInternal_shouldAllowWhenNoPolicyApplies() throws Exception { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.policyForPath("/home", "GET")).thenReturn(Optional.empty()); + + RateLimitFilter filter = new RateLimitFilter(rateLimitService, SecurityObservabilityService.noop()); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/home"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(200); + verify(rateLimitService).policyForPath("/home", "GET"); + } + + @Test + void doFilterInternal_shouldReturnTooManyRequestsWhenPolicyDenied() throws Exception { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.policyForPath("/api/cases", "GET")) + .thenReturn(Optional.of(RateLimitService.RateLimitPolicy.GLOBAL_API)); + when(rateLimitService.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1")) + .thenReturn(new RateLimitService.RateLimitDecision(false, 0, 42)); + + RateLimitFilter filter = new RateLimitFilter(rateLimitService, SecurityObservabilityService.noop()); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cases"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getHeader("Retry-After")).isEqualTo("42"); + assertThat(response.getContentAsString()).isEqualTo("Too many requests. Please try again later."); + verify(rateLimitService).evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + } + + @Test + void doFilterInternal_shouldPassThroughWhenPolicyAllowed() throws Exception { + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.policyForPath("/login", "POST")) + .thenReturn(Optional.of(RateLimitService.RateLimitPolicy.AUTH_ENDPOINT)); + when(rateLimitService.evaluate(RateLimitService.RateLimitPolicy.AUTH_ENDPOINT, "10.0.0.5")) + .thenReturn(new RateLimitService.RateLimitDecision(true, 9, 0)); + + RateLimitFilter filter = new RateLimitFilter(rateLimitService, SecurityObservabilityService.noop()); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/login"); + request.setRemoteAddr("10.0.0.5"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RecordingFilterChain chain = new RecordingFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.wasCalled).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + private static final class RecordingFilterChain extends MockFilterChain { + private boolean wasCalled; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response) + throws IOException, jakarta.servlet.ServletException { + wasCalled = true; + } + } +} diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java new file mode 100644 index 0000000..e2a74a7 --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java @@ -0,0 +1,80 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class RateLimitServiceTest { + + @Test + void policyForPath_shouldMapApiAndAuthEndpoints() { + RateLimitService service = new RateLimitService(2, 60, 1, 60, new MutableClock(Instant.parse("2026-04-24T10:00:00Z"))); + + assertThat(service.policyForPath("/api/patients", "GET")) + .contains(RateLimitService.RateLimitPolicy.GLOBAL_API); + assertThat(service.policyForPath("/login", "GET")).isEmpty(); + assertThat(service.policyForPath("/login", "POST")) + .contains(RateLimitService.RateLimitPolicy.AUTH_ENDPOINT); + assertThat(service.policyForPath("/register", "POST")) + .contains(RateLimitService.RateLimitPolicy.AUTH_ENDPOINT); + assertThat(service.policyForPath("/app.css", "GET")).isEmpty(); + } + + @Test + void evaluate_shouldAllowUntilLimitAndThenDenyWithRetryAfter() { + MutableClock clock = new MutableClock(Instant.parse("2026-04-24T10:00:00Z")); + RateLimitService service = new RateLimitService(2, 60, 1, 60, clock); + + var first = service.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + var second = service.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + var third = service.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + + assertThat(first.allowed()).isTrue(); + assertThat(first.remainingRequests()).isEqualTo(1); + assertThat(second.allowed()).isTrue(); + assertThat(second.remainingRequests()).isEqualTo(0); + assertThat(third.allowed()).isFalse(); + assertThat(third.retryAfterSeconds()).isGreaterThan(0); + } + + @Test + void evaluate_shouldUseIndependentCountersPerPolicy() { + MutableClock clock = new MutableClock(Instant.parse("2026-04-24T10:00:00Z")); + RateLimitService service = new RateLimitService(1, 60, 2, 60, clock); + + var apiAllowed = service.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + var authAllowed = service.evaluate(RateLimitService.RateLimitPolicy.AUTH_ENDPOINT, "127.0.0.1"); + var apiDenied = service.evaluate(RateLimitService.RateLimitPolicy.GLOBAL_API, "127.0.0.1"); + + assertThat(apiAllowed.allowed()).isTrue(); + assertThat(authAllowed.allowed()).isTrue(); + assertThat(apiDenied.allowed()).isFalse(); + } + + private static final class MutableClock extends Clock { + private Instant current; + + private MutableClock(Instant initial) { + this.current = initial; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } +} diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityServiceTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityServiceTest.java new file mode 100644 index 0000000..274c27c --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityServiceTest.java @@ -0,0 +1,33 @@ +package org.example.projektarendehantering.infrastructure.security; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityObservabilityServiceTest { + + @Test + void shouldRecordCountersAndGauge() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(meterRegistry); + + SecurityObservabilityService service = new SecurityObservabilityService(provider); + service.recordRateLimitDenied("GLOBAL_API"); + service.recordLoginFailure(); + service.recordLoginLocked(); + service.recordLoginSuccessReset(); + service.setActiveLoginLocks(3); + + assertThat(meterRegistry.get("security.rate_limit.denied").tag("policy", "GLOBAL_API").counter().count()).isEqualTo(1.0); + assertThat(meterRegistry.get("security.login.failures").counter().count()).isEqualTo(1.0); + assertThat(meterRegistry.get("security.login.locked").counter().count()).isEqualTo(1.0); + assertThat(meterRegistry.get("security.login.success_reset").counter().count()).isEqualTo(1.0); + assertThat(meterRegistry.get("security.login.active_locks").gauge().value()).isEqualTo(3.0); + } +} From a5b72df93a7f04c70d7ad35139996162f96b42d5 Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Fri, 24 Apr 2026 13:13:20 +0200 Subject: [PATCH 2/3] rabbit feedback fix --- pom.xml | 4 + .../security/ClientIpResolver.java | 133 ++++++++++++++++++ .../security/LoginAttemptService.java | 96 +++++++++++-- .../LoginAuthenticationFailureHandler.java | 2 +- .../LoginAuthenticationSuccessHandler.java | 2 +- .../security/LoginLockoutFilter.java | 2 +- .../security/RateLimitFilter.java | 2 +- .../security/RateLimitService.java | 18 ++- .../SecurityObservabilityService.java | 2 + src/main/resources/application.properties | 5 +- src/main/resources/static/app.css | 8 +- src/main/resources/static/app.js | 2 +- .../security/ClientIpResolverTest.java | 42 ++++++ 13 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolver.java create mode 100644 src/test/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolverTest.java diff --git a/pom.xml b/pom.xml index a5033ad..d800dc5 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,10 @@ org.springframework.boot spring-boot-starter-actuator + + io.micrometer + micrometer-registry-prometheus + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolver.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolver.java new file mode 100644 index 0000000..169a443 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolver.java @@ -0,0 +1,133 @@ +package org.example.projektarendehantering.infrastructure.security; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Resolves the originating client IP for proxied requests. + */ +final class ClientIpResolver { + + private ClientIpResolver() { + } + + static String resolve(HttpServletRequest request) { + String remoteAddr = request.getRemoteAddr(); + String forwardedFor = request.getHeader("X-Forwarded-For"); + // Trust forwarded headers only when the direct peer is an internal/trusted proxy. + if (isTrustedProxy(remoteAddr) && forwardedFor != null && !forwardedFor.isBlank()) { + String[] parts = forwardedFor.split(","); + for (String part : parts) { + String candidate = part.trim(); + String normalized = normalizeIpToken(candidate); + if (normalized != null) { + return normalized; + } + } + } + return remoteAddr; + } + + private static boolean isTrustedProxy(String remoteAddr) { + String normalizedRemote = normalizeIpToken(remoteAddr); + if (normalizedRemote == null) { + return false; + } + return isLoopback(normalizedRemote) || isPrivateIpv4(normalizedRemote); + } + + private static String normalizeIpToken(String value) { + if (value == null) { + return null; + } + String candidate = value.trim(); + if (candidate.isEmpty() || "unknown".equalsIgnoreCase(candidate)) { + return null; + } + if (candidate.startsWith("[") && candidate.endsWith("]")) { + candidate = candidate.substring(1, candidate.length() - 1); + } + int colonCount = countOccurrences(candidate, ':'); + if (colonCount == 1 && candidate.contains(".")) { + candidate = candidate.substring(0, candidate.lastIndexOf(':')); + } + if (isIpv4Literal(candidate)) { + return candidate; + } + if (isIpv6Literal(candidate)) { + return candidate.toLowerCase(); + } + return null; + } + + private static int countOccurrences(String value, char needle) { + int count = 0; + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) == needle) { + count++; + } + } + return count; + } + + private static boolean isLoopback(String ip) { + return "127.0.0.1".equals(ip) || "::1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip); + } + + private static boolean isPrivateIpv4(String ip) { + String[] octets = ip.split("\\."); + if (octets.length != 4) { + return false; + } + try { + int first = Integer.parseInt(octets[0]); + int second = Integer.parseInt(octets[1]); + if (first == 10) { + return true; + } + if (first == 172) { + return second >= 16 && second <= 31; + } + return first == 192 && second == 168; + } catch (NumberFormatException ex) { + return false; + } + } + + private static boolean isIpv4Literal(String ip) { + String[] octets = ip.split("\\."); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (octet.isEmpty() || octet.length() > 3) { + return false; + } + for (int i = 0; i < octet.length(); i++) { + if (!Character.isDigit(octet.charAt(i))) { + return false; + } + } + int value = Integer.parseInt(octet); + if (value < 0 || value > 255) { + return false; + } + } + return true; + } + + private static boolean isIpv6Literal(String ip) { + if (!ip.contains(":") || ip.contains(".")) { + return false; + } + for (int i = 0; i < ip.length(); i++) { + char c = ip.charAt(i); + boolean isHex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!(isHex || c == ':')) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java index 900caeb..567ebe7 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java @@ -1,14 +1,20 @@ package org.example.projektarendehantering.infrastructure.security; +import com.github.benmanes.caffeine.cache.Caffeine; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Instant; +import java.util.HexFormat; import java.util.Locale; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; /** * Tracks login failures and applies temporary lockout for local form login. @@ -24,36 +30,54 @@ @Slf4j public class LoginAttemptService { - private final ConcurrentMap emailAttempts = new ConcurrentHashMap<>(); - private final ConcurrentMap ipAttempts = new ConcurrentHashMap<>(); + private static final int ATTEMPT_CACHE_MAX_SIZE = 10_000; + + private final ConcurrentMap emailAttempts; + private final ConcurrentMap ipAttempts; private final int failureThreshold; private final int failureWindowSeconds; private final int lockDurationSeconds; private final Clock clock; private final SecurityObservabilityService securityObservabilityService; + private final String logPseudonymizationSalt; + @Autowired public LoginAttemptService( @Value("${app.security.login-attempt.failure-threshold:5}") int failureThreshold, @Value("${app.security.login-attempt.failure-window-seconds:900}") int failureWindowSeconds, @Value("${app.security.login-attempt.lock-duration-seconds:900}") int lockDurationSeconds, + @Value("${app.security.log-pseudonymization-salt:}") String logPseudonymizationSalt, SecurityObservabilityService securityObservabilityService) { - this(failureThreshold, failureWindowSeconds, lockDurationSeconds, Clock.systemUTC(), securityObservabilityService); + this(failureThreshold, failureWindowSeconds, lockDurationSeconds, Clock.systemUTC(), logPseudonymizationSalt, securityObservabilityService); } LoginAttemptService(int failureThreshold, int failureWindowSeconds, int lockDurationSeconds, Clock clock) { - this(failureThreshold, failureWindowSeconds, lockDurationSeconds, clock, SecurityObservabilityService.noop()); + this(failureThreshold, failureWindowSeconds, lockDurationSeconds, clock, "", SecurityObservabilityService.noop()); } LoginAttemptService(int failureThreshold, int failureWindowSeconds, int lockDurationSeconds, Clock clock, + String logPseudonymizationSalt, SecurityObservabilityService securityObservabilityService) { this.failureThreshold = failureThreshold; this.failureWindowSeconds = failureWindowSeconds; this.lockDurationSeconds = lockDurationSeconds; this.clock = clock; this.securityObservabilityService = securityObservabilityService; + this.logPseudonymizationSalt = logPseudonymizationSalt == null ? "" : logPseudonymizationSalt; + long attemptStateTtlSeconds = (long) failureWindowSeconds + lockDurationSeconds; + this.emailAttempts = Caffeine.newBuilder() + .expireAfterAccess(attemptStateTtlSeconds, TimeUnit.SECONDS) + .maximumSize(ATTEMPT_CACHE_MAX_SIZE) + .build() + .asMap(); + this.ipAttempts = Caffeine.newBuilder() + .expireAfterAccess(attemptStateTtlSeconds, TimeUnit.SECONDS) + .maximumSize(ATTEMPT_CACHE_MAX_SIZE) + .build() + .asMap(); } public LockDecision currentLockDecision(String email, String clientIp) { @@ -70,11 +94,13 @@ public LockDecision recordFailure(String email, String clientIp) { long emailRetry = registerFailure(emailAttempts, normalizeEmail(email), now); long ipRetry = registerFailure(ipAttempts, normalizeIp(clientIp), now); long retryAfter = Math.max(emailRetry, ipRetry); + String pseudonymizedEmail = pseudonymizeEmailForLogs(email); + String pseudonymizedIp = pseudonymizeIpForLogs(clientIp); if (retryAfter > 0) { securityObservabilityService.recordLoginLocked(); - log.warn("security_event=LOGIN_LOCKED email={} clientIp={} retryAfterSeconds={}", normalizeEmail(email), normalizeIp(clientIp), retryAfter); + log.warn("security_event=LOGIN_LOCKED email={} clientIp={} retryAfterSeconds={}", pseudonymizedEmail, pseudonymizedIp, retryAfter); } else { - log.info("security_event=LOGIN_FAILED email={} clientIp={}", normalizeEmail(email), normalizeIp(clientIp)); + log.info("security_event=LOGIN_FAILED email={} clientIp={}", pseudonymizedEmail, pseudonymizedIp); } securityObservabilityService.setActiveLoginLocks(countActiveLocks(now)); return new LockDecision(retryAfter > 0, retryAfter); @@ -82,10 +108,12 @@ public LockDecision recordFailure(String email, String clientIp) { public void recordSuccess(String email, String clientIp) { emailAttempts.remove(normalizeEmail(email)); - ipAttempts.remove(normalizeIp(clientIp)); + // Do NOT clear IP-level counters on success: a legitimate login from a + // shared/NATted IP would otherwise reset brute-force protection for + // every other account on that IP. securityObservabilityService.recordLoginSuccessReset(); securityObservabilityService.setActiveLoginLocks(countActiveLocks(nowEpochSecond())); - log.info("security_event=LOGIN_SUCCESS_RESET email={} clientIp={}", normalizeEmail(email), normalizeIp(clientIp)); + log.info("security_event=LOGIN_SUCCESS_RESET email={} clientIp={}", pseudonymizeEmailForLogs(email), pseudonymizeIpForLogs(clientIp)); } private long registerFailure(ConcurrentMap stateMap, String key, long nowEpochSecond) { @@ -152,6 +180,56 @@ private String normalizeIp(String clientIp) { return clientIp == null ? "" : clientIp.trim(); } + private String pseudonymizeEmailForLogs(String email) { + String sanitizedEmail = sanitizeForLogs(normalizeEmail(email)); + if (sanitizedEmail.isEmpty()) { + return ""; + } + + int atIndex = sanitizedEmail.lastIndexOf('@'); + if (atIndex > 0 && atIndex < sanitizedEmail.length() - 1) { + return "@".concat(sanitizedEmail.substring(atIndex + 1)); + } + + return "hash:".concat(pseudonymizeForLogs(sanitizedEmail, logPseudonymizationSalt)); + } + + private String pseudonymizeIpForLogs(String clientIp) { + return "hash:".concat(pseudonymizeForLogs(normalizeIp(clientIp), logPseudonymizationSalt)); + } + + private String pseudonymizeForLogs(String value, String appSalt) { + String sanitizedValue = sanitizeForLogs(value); + if (sanitizedValue.isEmpty()) { + return ""; + } + String saltedValue = sanitizeForLogs(appSalt).concat("|").concat(sanitizedValue); + return sha256Hex(saltedValue).substring(0, 12); + } + + private String sanitizeForLogs(String value) { + if (value == null || value.isBlank()) { + return ""; + } + StringBuilder sanitized = new StringBuilder(value.length()); + for (char c : value.toCharArray()) { + if (!Character.isISOControl(c)) { + sanitized.append(c); + } + } + return sanitized.toString().trim(); + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 unavailable for log pseudonymization", ex); + } + } + public record LockDecision(boolean locked, long retryAfterSeconds) { } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java index e1d859f..eff5426 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java @@ -30,7 +30,7 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String username = request.getParameter("username"); - String clientIp = request.getRemoteAddr(); + String clientIp = ClientIpResolver.resolve(request); LoginAttemptService.LockDecision decision = loginAttemptService.recordFailure(username, clientIp); String redirectUrl = "/login?error=true"; diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java index d9c560b..cb1b326 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java @@ -25,7 +25,7 @@ public LoginAuthenticationSuccessHandler(LoginAttemptService loginAttemptService public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - loginAttemptService.recordSuccess(authentication.getName(), request.getRemoteAddr()); + loginAttemptService.recordSuccess(authentication.getName(), ClientIpResolver.resolve(request)); response.sendRedirect("/home"); } } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java index 46f8633..7c8f85e 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java @@ -35,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, } String username = request.getParameter("username"); - String clientIp = request.getRemoteAddr(); + String clientIp = ClientIpResolver.resolve(request); LoginAttemptService.LockDecision decision = loginAttemptService.currentLockDecision(username, clientIp); if (!decision.locked()) { filterChain.doFilter(request, response); diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java index fa361eb..52b2d87 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal(HttpServletRequest request, return; } - String clientIp = request.getRemoteAddr(); + String clientIp = ClientIpResolver.resolve(request); var decision = rateLimitService.evaluate(policy.get(), clientIp); if (decision.allowed()) { filterChain.doFilter(request, response); diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java index d9ece37..95fd7b8 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java @@ -1,13 +1,15 @@ package org.example.projektarendehantering.infrastructure.security; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.Clock; import java.time.Instant; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; /** * In-memory, IP-based rate limiter for MVP Phase 1. @@ -22,13 +24,16 @@ @Service public class RateLimitService { - private final ConcurrentMap counters = new ConcurrentHashMap<>(); + private static final long MAX_COUNTER_ENTRIES = 10_000; + + private final Cache counters; private final int globalApiLimit; private final int globalApiWindowSeconds; private final int authEndpointLimit; private final int authEndpointWindowSeconds; private final Clock clock; + @Autowired public RateLimitService( @Value("${app.security.rate-limit.global-api.limit:100}") int globalApiLimit, @Value("${app.security.rate-limit.global-api.window-seconds:60}") int globalApiWindowSeconds, @@ -47,6 +52,11 @@ public RateLimitService( this.authEndpointLimit = authEndpointLimit; this.authEndpointWindowSeconds = authEndpointWindowSeconds; this.clock = clock; + long evictionWindowSeconds = Math.max(globalApiWindowSeconds, authEndpointWindowSeconds); + this.counters = Caffeine.newBuilder() + .expireAfterAccess(evictionWindowSeconds, TimeUnit.SECONDS) + .maximumSize(MAX_COUNTER_ENTRIES) + .build(); } public Optional policyForPath(String requestPath, String httpMethod) { @@ -66,7 +76,7 @@ public RateLimitDecision evaluate(RateLimitPolicy policy, String clientIp) { PolicyConfig policyConfig = policyConfig(policy); long nowEpochSecond = Instant.now(clock).getEpochSecond(); String counterKey = policy.name() + ":" + clientIp; - CounterWindow window = counters.computeIfAbsent(counterKey, ignored -> new CounterWindow(nowEpochSecond)); + CounterWindow window = counters.get(counterKey, ignored -> new CounterWindow(nowEpochSecond)); synchronized (window) { long elapsedSeconds = nowEpochSecond - window.windowStartEpochSecond; diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java b/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java index a6bb54e..4922cc6 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java @@ -4,6 +4,7 @@ import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.concurrent.atomic.AtomicInteger; @@ -29,6 +30,7 @@ private SecurityObservabilityService(MeterRegistry meterRegistry) { } } + @Autowired public SecurityObservabilityService(ObjectProvider meterRegistryProvider) { this(meterRegistryProvider.getIfAvailable()); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 12c0efd..1063f35 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,6 +11,9 @@ spring.jpa.defer-datasource-initialization=true spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect +# Honor X-Forwarded-* only through container-native trusted proxy processing. +server.forward-headers-strategy=native + # GitHub OAuth2 Login(hide credentials in production) spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID} spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET} @@ -49,5 +52,5 @@ app.security.login-attempt.failure-threshold=5 app.security.login-attempt.failure-window-seconds=900 app.security.login-attempt.lock-duration-seconds=900 -# Observability (Phase 3 MVP) +# Observability management.endpoints.web.exposure.include=health,info,metrics,prometheus \ No newline at end of file diff --git a/src/main/resources/static/app.css b/src/main/resources/static/app.css index 7363839..3fc9670 100644 --- a/src/main/resources/static/app.css +++ b/src/main/resources/static/app.css @@ -714,12 +714,12 @@ textarea.input { padding: 12px; resize: vertical; } 70% { transform: translateY(0); } } -@keyframes panelGlow { +@keyframes panel-glow { 0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.38; } 50% { transform: translate3d(0, -4px, 0) scale(1.03); opacity: 0.62; } } -@keyframes shimmerSweep { +@keyframes shimmer-sweep { 0% { transform: translateX(-120%); opacity: 0; } 30% { opacity: 0.2; } 100% { transform: translateX(120%); opacity: 0; } @@ -1035,7 +1035,7 @@ textarea.input { padding: 12px; resize: vertical; } border-radius: 999px; background: radial-gradient(circle, rgba(143, 190, 255, 0.35), rgba(143, 190, 255, 0)); pointer-events: none; - animation: panelGlow 7s ease-in-out infinite; + animation: panel-glow 7s ease-in-out infinite; } .landing-slide::after { @@ -1047,7 +1047,7 @@ textarea.input { padding: 12px; resize: vertical; } height: 2px; background: linear-gradient(90deg, rgba(143, 190, 255, 0), rgba(180, 214, 255, 0.8), rgba(143, 190, 255, 0)); pointer-events: none; - animation: shimmerSweep 8s ease-in-out infinite; + animation: shimmer-sweep 8s ease-in-out infinite; } h1, h2 { diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 607163e..b4e5087 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -132,7 +132,7 @@ window.addEventListener("scroll", syncActiveSlideFromViewport, { passive: true }); window.addEventListener("resize", () => { - syncBySlide(); + syncActiveSlideFromViewport(); }); if (prefersReducedMotion) { diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolverTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolverTest.java new file mode 100644 index 0000000..06704ca --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolverTest.java @@ -0,0 +1,42 @@ +package org.example.projektarendehantering.infrastructure.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class ClientIpResolverTest { + + @Test + void shouldUseForwardedClientIpWhenRemoteAddressIsTrustedProxy() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.10"); + request.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.10"); + + String resolved = ClientIpResolver.resolve(request); + + assertThat(resolved).isEqualTo("203.0.113.9"); + } + + @Test + void shouldIgnoreForwardedHeaderWhenRemoteAddressIsUntrusted() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("198.51.100.20"); + request.addHeader("X-Forwarded-For", "203.0.113.9"); + + String resolved = ClientIpResolver.resolve(request); + + assertThat(resolved).isEqualTo("198.51.100.20"); + } + + @Test + void shouldFallbackToRemoteAddressWhenForwardedHeaderIsInvalid() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("127.0.0.1"); + request.addHeader("X-Forwarded-For", "unknown, invalid-ip"); + + String resolved = ClientIpResolver.resolve(request); + + assertThat(resolved).isEqualTo("127.0.0.1"); + } +} From 45a211b083083f74844d665df84a1369dad01d2c Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Fri, 24 Apr 2026 13:16:27 +0200 Subject: [PATCH 3/3] fixed the test --- .../infrastructure/security/LoginAttemptServiceTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java index 7077551..5da54d5 100644 --- a/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java @@ -47,7 +47,9 @@ void recordSuccess_shouldClearFailureState() { assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isTrue(); service.recordSuccess("user@example.com", "127.0.0.1"); - assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isFalse(); + // Success clears account-level failures, but keeps IP-level protection intact. + assertThat(service.currentLockDecision("user@example.com", "127.0.0.1").locked()).isTrue(); + assertThat(service.currentLockDecision("user@example.com", "127.0.0.2").locked()).isFalse(); } private static final class MutableClock extends Clock {