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
-
🗺️Ett tydligt flöde
-
🎯Klara ansvar
-
📍Spårbara händelser
+
Ett tydligt flöde
+
Klara ansvar
+
Spårbara händelser
-
- 🔄 Enkelt flöde, tydligt ansvar
+
+ Enkelt flöde, tydligt ansvar
-
-
-
-
+
+
+
+
-
- 👥 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
-
+
Felaktiga filtyper stoppas innan lagring.
-
+
Tillfälliga lagringsfel hanteras med retry och återhämtning.
-
+
-
🕵️ 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);
+ }
+}