diff --git a/backend/pom.xml b/backend/pom.xml
index f1a144d6..6e9b389b 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -70,9 +70,8 @@
spring-boot-starter-security
- org.springframework.session
- spring-session-jdbc
- 4.0.3
+ org.springframework.boot
+ spring-boot-starter-session-jdbc
org.springframework.boot
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java
new file mode 100644
index 00000000..415903cd
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java
@@ -0,0 +1,22 @@
+package org.raddatz.familienarchiv.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.session.web.http.CookieSerializer;
+import org.springframework.session.web.http.DefaultCookieSerializer;
+
+@Configuration
+public class SpringSessionConfig {
+
+ @Bean
+ public CookieSerializer cookieSerializer() {
+ DefaultCookieSerializer serializer = new DefaultCookieSerializer();
+ serializer.setCookieName("fa_session");
+ serializer.setSameSite("Strict");
+ // cookieHttpOnly: true is the DefaultCookieSerializer default
+ // useSecureCookie not set: auto-detects from request.isSecure().
+ // With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https
+ // causes isSecure() → true in production; direct HTTP in dev/tests → false.
+ return serializer;
+ }
+}
diff --git a/backend/src/main/resources/application-dev.yaml b/backend/src/main/resources/application-dev.yaml
index dd6c521d..54e4a972 100644
--- a/backend/src/main/resources/application-dev.yaml
+++ b/backend/src/main/resources/application-dev.yaml
@@ -1,11 +1,9 @@
spring:
jpa:
show-sql: true
- session:
- cookie:
- # Dev runs over HTTP (port 5173 → 8080); Secure=true would prevent the
- # cookie from being sent on plain HTTP. Override to false for local dev only.
- secure: false
+ # spring.session.cookie.secure is no longer a supported Boot 4.x property.
+ # DefaultCookieSerializer auto-detects Secure from request.isSecure().
+ # Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
springdoc:
api-docs:
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
index f0df329b..2a764e8e 100644
--- a/backend/src/main/resources/application.yaml
+++ b/backend/src/main/resources/application.yaml
@@ -39,16 +39,11 @@ spring:
enable: true
session:
- store-type: jdbc
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
jdbc:
initialize-schema: never # Flyway owns schema creation (V67)
- cookie:
- name: fa_session
- same-site: strict
- http-only: true
- # secure: true is the default when forward-headers-strategy detects HTTPS behind Caddy.
- # application-dev.yaml overrides this to false for local HTTP dev.
+ # Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
+ # (spring.session.cookie.* is not supported in Spring Boot 4.x).
server:
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java
new file mode 100644
index 00000000..92ff991e
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java
@@ -0,0 +1,154 @@
+package org.raddatz.familienarchiv.auth;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.AppUserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.client.DefaultResponseErrorHandler;
+import org.springframework.web.client.RestTemplate;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+class AuthSessionIntegrationTest {
+
+ @LocalServerPort int port;
+ @MockitoBean S3Client s3Client;
+ @Autowired AppUserRepository userRepository;
+ @Autowired PasswordEncoder passwordEncoder;
+ @Autowired JdbcTemplate jdbcTemplate;
+
+ private RestTemplate http;
+ private String baseUrl;
+
+ private static final String TEST_EMAIL = "session-it@test.de";
+ private static final String TEST_PASSWORD = "pass4Session!";
+
+ @BeforeEach
+ void setUp() {
+ http = noThrowRestTemplate();
+ baseUrl = "http://localhost:" + port;
+ // spring_session_attributes cascades on delete — removing the parent row is enough
+ jdbcTemplate.update("DELETE FROM spring_session");
+ jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL);
+ userRepository.save(AppUser.builder()
+ .email(TEST_EMAIL)
+ .password(passwordEncoder.encode(TEST_PASSWORD))
+ .build());
+ }
+
+ // ─── Task 13: full session lifecycle ──────────────────────────────────────
+
+ @Test
+ void login_sets_opaque_fa_session_cookie() {
+ ResponseEntity response = doLogin();
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ String cookie = extractFaSessionCookie(response);
+ assertThat(cookie).isNotBlank();
+ // Opaque token — must not look like Basic-auth credentials (email:password)
+ assertThat(cookie).doesNotContain(":");
+ }
+
+ @Test
+ void session_cookie_authenticates_subsequent_request() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+
+ assertThat(me.getStatusCode().value()).isEqualTo(200);
+ }
+
+ @Test
+ void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ ResponseEntity logout = http.postForEntity(
+ baseUrl + "/api/auth/logout",
+ new HttpEntity<>(cookieHeaders(cookie)), Void.class);
+ assertThat(logout.getStatusCode().value()).isEqualTo(204);
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+ assertThat(me.getStatusCode().value()).isEqualTo(401);
+ }
+
+ // ─── Task 14: idle-timeout ────────────────────────────────────────────────
+
+ @Test
+ void session_expired_by_idle_timeout_returns_401() {
+ String cookie = extractFaSessionCookie(doLogin());
+
+ // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
+ long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
+ jdbcTemplate.update(
+ "UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?",
+ nineHoursAgoMs, nineHoursAgoMs);
+
+ ResponseEntity me = http.exchange(
+ baseUrl + "/api/users/me", HttpMethod.GET,
+ new HttpEntity<>(cookieHeaders(cookie)), String.class);
+ assertThat(me.getStatusCode().value()).isEqualTo(401);
+ }
+
+ // ─── helpers ─────────────────────────────────────────────────────────────
+
+ private ResponseEntity doLogin() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
+ return http.postForEntity(baseUrl + "/api/auth/login",
+ new HttpEntity<>(body, headers), String.class);
+ }
+
+ private HttpHeaders cookieHeaders(String sessionId) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("Cookie", "fa_session=" + sessionId);
+ return headers;
+ }
+
+ private String extractFaSessionCookie(ResponseEntity> response) {
+ List setCookieHeader = response.getHeaders().get("Set-Cookie");
+ if (setCookieHeader == null) return "";
+ return setCookieHeader.stream()
+ .filter(c -> c.startsWith("fa_session="))
+ .map(c -> c.split(";")[0].substring("fa_session=".length()))
+ .findFirst()
+ .orElse("");
+ }
+
+ private RestTemplate noThrowRestTemplate() {
+ RestTemplate template = new RestTemplate();
+ template.setErrorHandler(new DefaultResponseErrorHandler() {
+ @Override
+ public boolean hasError(ClientHttpResponse response) throws IOException {
+ return false;
+ }
+ });
+ return template;
+ }
+}