test(auth): integration tests for full session lifecycle and idle-timeout
Also switches pom.xml to spring-boot-starter-session-jdbc (Spring Boot 4.x split the session auto-config into a separate starter; spring-session-jdbc alone does not register JdbcSessionAutoConfiguration). Adds SpringSessionConfig#cookieSerializer bean to configure fa_session name and SameSite=Strict (spring.session.cookie.* properties are no longer supported by the Boot 4.x auto-configuration layer). Cleans up application.yaml / application-dev.yaml: removes store-type: jdbc and the unsupported cookie.* keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,9 +70,8 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.session</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-session-jdbc</artifactId>
|
<artifactId>spring-boot-starter-session-jdbc</artifactId>
|
||||||
<version>4.0.3</version> <!-- pinned; managed by Spring Boot 4.0.6 BOM -->
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
spring:
|
spring:
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
session:
|
# spring.session.cookie.secure is no longer a supported Boot 4.x property.
|
||||||
cookie:
|
# DefaultCookieSerializer auto-detects Secure from request.isSecure().
|
||||||
# Dev runs over HTTP (port 5173 → 8080); Secure=true would prevent the
|
# Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
|
||||||
# cookie from being sent on plain HTTP. Override to false for local dev only.
|
|
||||||
secure: false
|
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
|
|||||||
@@ -39,16 +39,11 @@ spring:
|
|||||||
enable: true
|
enable: true
|
||||||
|
|
||||||
session:
|
session:
|
||||||
store-type: jdbc
|
|
||||||
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
||||||
jdbc:
|
jdbc:
|
||||||
initialize-schema: never # Flyway owns schema creation (V67)
|
initialize-schema: never # Flyway owns schema creation (V67)
|
||||||
cookie:
|
# Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
|
||||||
name: fa_session
|
# (spring.session.cookie.* is not supported in Spring Boot 4.x).
|
||||||
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.
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
|
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
|
||||||
|
|||||||
@@ -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<String> 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<String> 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<Void> logout = http.postForEntity(
|
||||||
|
baseUrl + "/api/auth/logout",
|
||||||
|
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||||
|
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String> me = http.exchange(
|
||||||
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||||
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ResponseEntity<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user