diff --git a/pom.xml b/pom.xml index b5def9a..d800dc5 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,14 @@ org.springframework.boot spring-boot-starter-webmvc + + 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/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/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 new file mode 100644 index 0000000..567ebe7 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java @@ -0,0 +1,245 @@ +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.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +/** + * Tracks login failures and applies temporary lockout for local form login. + * + *

Phase 2 MVP behavior: + *

+ */ +@Service +@Slf4j +public class LoginAttemptService { + + 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(), logPseudonymizationSalt, 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, + 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) { + 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); + String pseudonymizedEmail = pseudonymizeEmailForLogs(email); + String pseudonymizedIp = pseudonymizeIpForLogs(clientIp); + if (retryAfter > 0) { + securityObservabilityService.recordLoginLocked(); + log.warn("security_event=LOGIN_LOCKED email={} clientIp={} retryAfterSeconds={}", pseudonymizedEmail, pseudonymizedIp, retryAfter); + } else { + log.info("security_event=LOGIN_FAILED email={} clientIp={}", pseudonymizedEmail, pseudonymizedIp); + } + securityObservabilityService.setActiveLoginLocks(countActiveLocks(now)); + return new LockDecision(retryAfter > 0, retryAfter); + } + + public void recordSuccess(String email, String clientIp) { + emailAttempts.remove(normalizeEmail(email)); + // 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={}", pseudonymizeEmailForLogs(email), pseudonymizeIpForLogs(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(); + } + + 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) { + } + + 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..eff5426 --- /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 = ClientIpResolver.resolve(request); + 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..cb1b326 --- /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(), 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 new file mode 100644 index 0000000..7c8f85e --- /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 = ClientIpResolver.resolve(request); + 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..52b2d87 --- /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 = ClientIpResolver.resolve(request); + 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..95fd7b8 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java @@ -0,0 +1,125 @@ +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.TimeUnit; + +/** + * 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 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, + @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; + 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) { + 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.get(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..4922cc6 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java @@ -0,0 +1,71 @@ +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.beans.factory.annotation.Autowired; +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); + } + } + + @Autowired + 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..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} @@ -36,4 +39,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 +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..3fc9670 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 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 shimmer-sweep { + 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: panel-glow 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: shimmer-sweep 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..b4e5087 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", () => { + syncActiveSlideFromViewport(); + }); 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/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"); + } +} 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..5da54d5 --- /dev/null +++ b/src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java @@ -0,0 +1,81 @@ +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"); + // 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 { + 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); + } +}