@@ -38,6 +45,21 @@ public class SecurityConfig {
private final CustomAuthSuccessHandler customAuthSuccessHandler;
private final CustomAuthFailureHandler customAuthFailureHandler;
private final RateLimitingFilter rateLimitingFilter;
+ private final DataSource dataSource;
+ private final UserDetailsService userDetailsService;
+
+ /**
+ * Secret used to sign remember-me tokens. Must be a long, random,
+ * environment-specific value (e.g. {@code openssl rand -hex 32}) —
+ * never left at a default in production, since anyone who knows it
+ * could forge a valid remember-me cookie.
+ */
+ @Value("${security.remember-me.key}")
+ private String rememberMeKey;
+
+ /** How long a remember-me cookie stays valid before the user must sign in again. Defaults to 14 days. */
+ @Value("${security.remember-me.token-validity-seconds:1209600}")
+ private int rememberMeTokenValiditySeconds;
/**
* Defines the security filter chain: public routes (sign-in/up, static
@@ -81,7 +103,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.logoutUrl("/sign-out")
.invalidateHttpSession(true)
.clearAuthentication(true)
- .deleteCookies("JSESSIONID")
+ .deleteCookies("JSESSIONID", "remember-me")
+ .addLogoutHandler(rememberMeServices())
.logoutSuccessUrl("/sign-in")
)
.sessionManagement(session -> session
@@ -90,13 +113,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.sessionRegistry(sessionRegistry())
.expiredUrl("/sign-in?expired")
)
+ .rememberMe(rememberMe -> rememberMe
+ .rememberMeServices(rememberMeServices())
+ .key(rememberMeKey)
+ )
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
- "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; " +
+ "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://use.fontawesome.com https://cdnjs.cloudflare.com; " +
"connect-src 'self' https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:;"
)
@@ -132,4 +159,37 @@ public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
+ /**
+ * Backs remember-me tokens with the {@code persistent_logins} table
+ * (schema defined in the {@code 06-add-persistent-logins.xml} Liquibase
+ * changeset) instead of Spring Security's simpler hash-based cookie.
+ * This lets a stolen/leaked cookie be invalidated server-side and
+ * rotates the token on every use, so a captured cookie is only valid
+ * for a single re-use before it's detected and the whole series is
+ * revoked.
+ */
+ @Bean
+ public PersistentTokenRepository persistentTokenRepository() {
+ JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
+ repository.setDataSource(dataSource);
+ return repository;
+ }
+
+ /**
+ * Issues and validates the {@code remember-me} cookie. Token validity
+ * is configurable via {@code security.remember-me.token-validity-seconds}
+ * (default 14 days); the signing key comes from
+ * {@code security.remember-me.key} and must be set per-environment.
+ */
+ @Bean
+ public PersistentTokenBasedRememberMeServices rememberMeServices() {
+ PersistentTokenBasedRememberMeServices services =
+ new PersistentTokenBasedRememberMeServices(rememberMeKey, userDetailsService, persistentTokenRepository());
+ services.setTokenValiditySeconds(rememberMeTokenValiditySeconds);
+ services.setParameter("remember-me");
+ services.setCookieName("remember-me");
+ services.setUseSecureCookie(true);
+ return services;
+ }
+
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index f273614..1e10548 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -20,6 +20,10 @@ spring.jpa.properties.hibernate.jdbc.time_zone=UTC
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
+# --- Remember-me ---
+security.remember-me.key=${REMEMBER_ME_KEY}
+security.remember-me.token-validity-seconds=1209600
+
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
@@ -74,13 +78,18 @@ app.base-url=${APP_BASE_URL:http://localhost:8080}
app.verification.token-ttl-hours=24
app.password-reset.token-ttl-hours=1
-# --- Actuator / health ---
-management.endpoints.web.exposure.include=health
+# --- Actuator / health / metrics ---
+management.server.port=${MANAGEMENT_SERVER_PORT:8081}
+management.endpoints.web.exposure.include=health,prometheus,info
management.endpoint.health.show-details=when-authorized
management.endpoint.health.probes.enabled=true
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true
+management.metrics.tags.application=${spring.application.name}
+management.metrics.distribution.percentiles-histogram.http.server.requests=true
+management.metrics.distribution.slo.http.server.requests=50ms,100ms,200ms,500ms,1s,2s
+
# --- Graceful shutdown ---
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
@@ -112,4 +121,4 @@ rate-limit.rules[4].limit=60
rate-limit.rules[4].window-seconds=60
management.tracing.sampling.probability=1.0
-logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}]
+logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}]
\ No newline at end of file
diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml
index 0a4206f..51be9e7 100644
--- a/src/main/resources/liquibase/changelog.xml
+++ b/src/main/resources/liquibase/changelog.xml
@@ -8,5 +8,6 @@