From fbb94e6b8aaf54a7b827a581680691307f1bdb19 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 22:39:00 -0600 Subject: [PATCH 1/4] feat(security): complete remember-me support (#351) Enabling user.security.rememberMe was previously a near no-op: no cookie configuration, no persistent token store, and no revocation. This completes the feature: - WebSecurityConfig: add tokenValiditySeconds (default 1209600), rememberMeParameter / rememberMeCookieName (default remember-me), and useSecureCookie (unset = defer to request scheme); wire an optional PersistentTokenRepository into the remember-me configurer, and exclude the signing key from the Lombok toString. - UserSecurityBeansAutoConfiguration: opt-in JdbcTokenRepositoryImpl bean gated on user.security.rememberMe.usePersistentTokens with @ConditionalOnMissingBean, backed by the consumer's DataSource. - db-scripts: persistent_logins DDL (username widened to 255 for emails). - SessionInvalidationService: remove the user's persistent tokens on both invalidateUserSessions and invalidateSessionsAfterPasswordChange, so persistent tokens cannot outlive an admin sign-out or a password change. Failures are logged and swallowed so the cleanup step can never roll back or misreport the primary account operation. Hash-based mode (the default) has no server-side state: admin revocation is documented as not possible there; password changes invalidate those cookies inherently via the signature. --- db-scripts/mariadb-schema.sql | 12 +++++ .../listener/AuthenticationEventListener.java | 3 +- .../UserSecurityBeansAutoConfiguration.java | 30 +++++++++++++ .../user/security/WebSecurityConfig.java | 39 +++++++++++++++- .../service/SessionInvalidationService.java | 44 +++++++++++++++++++ ...itional-spring-configuration-metadata.json | 29 ++++++++++++ .../config/dsspringuserconfig.properties | 24 ++++++++++ 7 files changed, 178 insertions(+), 3 deletions(-) diff --git a/db-scripts/mariadb-schema.sql b/db-scripts/mariadb-schema.sql index 21717190..fb1df23e 100644 --- a/db-scripts/mariadb-schema.sql +++ b/db-scripts/mariadb-schema.sql @@ -132,3 +132,15 @@ CREATE TABLE `user_credentials` ( KEY `FK_user_credentials_entity` (`user_entity_user_id`), CONSTRAINT `FK_user_credentials_entity` FOREIGN KEY (`user_entity_user_id`) REFERENCES `user_entities` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- Spring Security persistent remember-me tokens (JdbcTokenRepositoryImpl). +-- Only required when user.security.rememberMe.usePersistentTokens=true. +-- username holds the user's email, so it is wider than Spring's canonical 64 chars. +CREATE TABLE `persistent_logins` ( + `username` VARCHAR(255) NOT NULL, + `series` VARCHAR(64) NOT NULL, + `token` VARCHAR(64) NOT NULL, + `last_used` TIMESTAMP NOT NULL, + PRIMARY KEY (`series`), + KEY `IDX_persistent_logins_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/AuthenticationEventListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/AuthenticationEventListener.java index 387fd432..8d912a88 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/listener/AuthenticationEventListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/AuthenticationEventListener.java @@ -57,7 +57,8 @@ public void onSuccess(AuthenticationSuccessEvent success) { } log.debug("Authentication success for OAuth2User: {}", username); } else if (principal instanceof String) { - // Basic authentication or remember-me + // Basic authentication. (This library's remember-me does NOT land here: it re-authenticates through + // DSUserDetailsService, so its principal is a DSUserDetails and takes the first branch.) username = (String) principal; log.debug("Authentication success for String principal: {}", username); } else { diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index 5cec25c1..084f4536 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -3,8 +3,10 @@ import java.util.List; import java.util.Set; import org.springframework.beans.factory.annotation.Value; +import javax.sql.DataSource; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; @@ -20,6 +22,8 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.session.HttpSessionEventPublisher; @@ -180,6 +184,32 @@ public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEven return new DefaultAuthenticationEventPublisher(applicationEventPublisher); } + /** + * Creates the library's persistent remember-me token store, a {@link JdbcTokenRepositoryImpl} backed by the consuming application's + * {@link DataSource}. Only created when {@code user.security.rememberMe.usePersistentTokens=true}, so it is never instantiated unless the + * consumer has opted in — and opting in requires the {@code persistent_logins} table to exist (see {@code db-scripts/}); the repository + * does NOT create the table itself. Backs off entirely if the consuming application defines its own {@link PersistentTokenRepository}. + * + *

+ * When this bean (or a consumer-defined replacement) is present, {@link WebSecurityConfig} switches remember-me from Spring's hash-based + * {@code TokenBasedRememberMeServices} to persistent tokens, and + * {@link com.digitalsanctuary.spring.user.service.SessionInvalidationService} revokes the stored tokens on session invalidation and password + * change. Persistent tokens do not embed the password hash, so without that revocation hook they would survive a password change — which + * is why the store and the revocation ship together. + *

+ * + * @param dataSource the consuming application's {@link DataSource} + * @return the {@link JdbcTokenRepositoryImpl} + */ + @Bean + @ConditionalOnProperty(name = "user.security.rememberMe.usePersistentTokens", havingValue = "true") + @ConditionalOnMissingBean(PersistentTokenRepository.class) + public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { + JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); + tokenRepository.setDataSource(dataSource); + return tokenRepository; + } + /** * File extensions that identify static-asset fetches. Requests for these are never worth replaying after login, and browsers frequently fetch * them automatically (icons, manifests, fonts) while the login page itself is rendering — which would otherwise overwrite the user's real diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index b152b332..a3ade7d5 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -7,6 +7,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @@ -20,6 +21,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; import com.digitalsanctuary.spring.user.service.DSOAuth2UserService; @@ -29,6 +31,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; +import lombok.ToString; import lombok.extern.slf4j.Slf4j; /** @@ -101,9 +104,27 @@ public class WebSecurityConfig { @Value("${user.security.rememberMe.enabled:false}") private boolean rememberMeEnabled; + // Excluded from the Lombok-generated toString so the signing secret can never leak through bean logging. + @ToString.Exclude @Value("${user.security.rememberMe.key:#{null}}") private String rememberMeKey; + @Value("${user.security.rememberMe.tokenValiditySeconds:1209600}") + private int rememberMeTokenValiditySeconds; + + @Value("${user.security.rememberMe.rememberMeParameter:remember-me}") + private String rememberMeParameter; + + @Value("${user.security.rememberMe.rememberMeCookieName:remember-me}") + private String rememberMeCookieName; + + /** + * Whether the remember-me cookie is marked {@code Secure}. Left {@code null} (unset) by default so Spring Security's + * own behavior applies: the cookie is secure whenever the request that created it was made over HTTPS. + */ + @Value("${user.security.rememberMe.useSecureCookie:#{null}}") + private Boolean rememberMeUseSecureCookie; + @Value("${user.dev.auto-login-enabled:false}") private boolean devAutoLoginEnabled; @@ -118,6 +139,7 @@ public class WebSecurityConfig { private final Environment environment; private final ApplicationEventPublisher applicationEventPublisher; private final RequestCache requestCache; + private final ObjectProvider persistentTokenRepositoryProvider; /** * Builds the library's security filter chain for Spring Security. @@ -151,9 +173,22 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe // RequestCache bean. http.requestCache(cache -> cache.requestCache(requestCache)); - // Configure remember-me only if explicitly enabled and key is provided + // Configure remember-me only if explicitly enabled and key is provided. With no PersistentTokenRepository bean + // present this stays on Spring's hash-based TokenBasedRememberMeServices (no server-side state); when a + // repository bean exists (e.g. the JdbcTokenRepositoryImpl enabled via user.security.rememberMe.usePersistentTokens, + // or a consumer-defined bean) the configurer switches to persistent tokens, which SessionInvalidationService can revoke. if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) { - http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService)); + http.rememberMe(rememberMe -> { + rememberMe.key(rememberMeKey).userDetailsService(userDetailsService).tokenValiditySeconds(rememberMeTokenValiditySeconds) + .rememberMeParameter(rememberMeParameter).rememberMeCookieName(rememberMeCookieName); + if (rememberMeUseSecureCookie != null) { + rememberMe.useSecureCookie(rememberMeUseSecureCookie); + } + PersistentTokenRepository tokenRepository = persistentTokenRepositoryProvider.getIfAvailable(); + if (tokenRepository != null) { + rememberMe.tokenRepository(tokenRepository); + } + }); } // Use the LogoutSuccessService handler (instead of logoutSuccessUrl) so logout publishes an audit event. diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java index 65bf24f1..42d80346 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java @@ -1,9 +1,11 @@ package com.digitalsanctuary.spring.user.service; import java.util.List; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.stereotype.Service; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; @@ -23,6 +25,15 @@ * {@link #invalidateSessionsAfterPasswordChange(User)} applies the self-service password-change policy, which by * default preserves and regenerates the user's current session while invalidating their other sessions.

* + *

Remember-me tokens: when a {@link PersistentTokenRepository} bean is present (persistent + * remember-me mode), both methods also remove all of the user's stored remember-me tokens — including, + * on a password change, the token of the device the user is currently on (the preserved HTTP session survives; the + * user just has to log in again once that session ends). Without this, an expired session would be silently + * re-authenticated by the remember-me cookie, and persistent tokens would survive a password change. In the default + * hash-based remember-me mode there is no server-side token state to revoke: those cookies cannot be revoked + * server-side by admin action, but a password change invalidates them inherently because the cookie signature + * embeds the password hash.

+ * *

Race Condition Note: This service uses Spring's SessionRegistry to track * and invalidate sessions. Due to the nature of the SessionRegistry API, there is an inherent * race condition: sessions created after {@link SessionRegistry#getAllPrincipals()} is called @@ -40,6 +51,9 @@ public class SessionInvalidationService { private final SessionRegistry sessionRegistry; + /** Present only in persistent remember-me mode; empty in the default hash-based mode (nothing to revoke server-side). */ + private final ObjectProvider persistentTokenRepositoryProvider; + /** Threshold for warning about high principal count that may impact performance. */ @Value("${user.session.invalidation.warn-threshold:1000}") private int warnThreshold; @@ -98,6 +112,8 @@ public int invalidateUserSessions(User user) { } } + revokeRememberMeTokens(user); + log.info("SessionInvalidationService.invalidateUserSessions: invalidated {} sessions for user {} (scanned {} principals)", invalidatedCount, user.getEmail(), principals.size()); return invalidatedCount; @@ -165,6 +181,8 @@ public int invalidateSessionsAfterPasswordChange(User user) { regenerateCurrentSession(request, currentSessionId, currentPrincipal, user); } + revokeRememberMeTokens(user); + log.info("SessionInvalidationService.invalidateSessionsAfterPasswordChange: invalidated {} other session(s) for user {}; " + "current session preserved and regenerated: {}", invalidatedCount, user.getEmail(), currentPrincipal != null); return invalidatedCount; @@ -196,6 +214,32 @@ private void regenerateCurrentSession(HttpServletRequest request, String oldSess } } + /** + * Removes all persistent remember-me tokens for the given user when a {@link PersistentTokenRepository} is + * present. Tokens are keyed by the remember-me username, which is {@link DSUserDetails#getUsername()} — the + * user's email. No-op in the default hash-based remember-me mode (no repository bean, no server-side state). + * + * @param user the user whose remember-me tokens should be removed (non-null; callers null-check first) + */ + private void revokeRememberMeTokens(User user) { + PersistentTokenRepository tokenRepository = persistentTokenRepositoryProvider.getIfAvailable(); + if (tokenRepository == null) { + return; + } + // Failure isolation: this runs inside password-change transactions and after-commit account-deletion + // callbacks. A repository failure must not roll back or misreport the primary operation, so it is logged + // (loudly) and swallowed. A systematically missing persistent_logins table also surfaces on the first + // remember-me login itself (Spring's createNewToken is not wrapped), so this cannot hide misconfiguration. + try { + tokenRepository.removeUserTokens(user.getEmail()); + log.debug("SessionInvalidationService.revokeRememberMeTokens: removed persistent remember-me tokens for user {}", user.getEmail()); + } catch (RuntimeException ex) { + log.error("SessionInvalidationService.revokeRememberMeTokens: FAILED to remove persistent remember-me tokens for user {} - " + + "outstanding remember-me cookies for this user remain valid until they expire. If this persists, verify the " + + "persistent_logins table exists and the database is reachable.", user.getEmail(), ex); + } + } + /** * Returns the current servlet request bound to this thread, or {@code null} if the call is not happening on a * request-bound thread (e.g. a background job). diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index cea086e3..9be1e00f 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -314,6 +314,35 @@ "type": "java.lang.String", "description": "Secret key for remember-me token generation" }, + { + "name": "user.security.rememberMe.tokenValiditySeconds", + "type": "java.lang.Integer", + "description": "How long a remember-me token stays valid, in seconds", + "defaultValue": 1209600 + }, + { + "name": "user.security.rememberMe.rememberMeParameter", + "type": "java.lang.String", + "description": "Request parameter the login form posts to opt into remember-me", + "defaultValue": "remember-me" + }, + { + "name": "user.security.rememberMe.rememberMeCookieName", + "type": "java.lang.String", + "description": "Name of the remember-me cookie", + "defaultValue": "remember-me" + }, + { + "name": "user.security.rememberMe.useSecureCookie", + "type": "java.lang.Boolean", + "description": "Force the Secure flag on the remember-me cookie. Unset (default) marks the cookie secure only when the request that created it used HTTPS." + }, + { + "name": "user.security.rememberMe.usePersistentTokens", + "type": "java.lang.Boolean", + "description": "Store remember-me tokens in the database (JdbcTokenRepositoryImpl) instead of the default hash-based cookies. Requires the persistent_logins table (see db-scripts). Enables server-side revocation on session invalidation and password change.", + "defaultValue": false + }, { "name": "user.security.alwaysUseDefaultTargetUrl", "type": "java.lang.Boolean", diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index e05091f2..563f4092 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -91,6 +91,30 @@ user.security.trustedHosts= user.security.requireCanonicalAppUrl=false # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. user.security.testHashTime=true + +# Remember-me ("stay signed in") support. Disabled by default. To enable it you must set BOTH enabled=true and a +# secret key, AND your login form must post the remember-me request parameter (a checkbox named "remember-me" by +# default) - without the parameter no cookie is ever issued. +user.security.rememberMe.enabled=false +# Secret used to sign remember-me tokens. Required when enabled. Keep it stable across restarts and instances; +# changing it invalidates every outstanding remember-me cookie. +# user.security.rememberMe.key= +# How long a remember-me token stays valid, in seconds. Default 1209600 (14 days). +user.security.rememberMe.tokenValiditySeconds=1209600 +# Request parameter the login form posts to opt into remember-me. +user.security.rememberMe.rememberMeParameter=remember-me +# Name of the remember-me cookie. +user.security.rememberMe.rememberMeCookieName=remember-me +# Force the Secure flag on the remember-me cookie. Leave unset (commented out) for Spring's default: the cookie is +# secure whenever the request that created it used HTTPS. NOTE: behind a TLS-terminating reverse proxy the request +# reaches the app as plain HTTP, so the default requires forwarded-header processing to be configured +# (server.forward-headers-strategy=framework or native) - otherwise set this to true explicitly. +# user.security.rememberMe.useSecureCookie=true +# Store remember-me tokens in the database instead of the default hash-based (stateless) cookies. Requires the +# persistent_logins table (see db-scripts). Persistent tokens can be revoked server-side: the library removes a +# user's tokens when their sessions are invalidated (e.g. account disable/delete) and on password change. +# Hash-based cookies cannot be revoked by admin action, but are inherently invalidated by a password change. +user.security.rememberMe.usePersistentTokens=false # The default action for all requests. This can be either deny or allow. user.security.defaultAction=deny # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. From 0bd5c09cc9f9d115299b52304605a2a6f31c67b1 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 22:39:13 -0600 Subject: [PATCH 2/4] test(security): cover remember-me issuance, re-auth, revocation, and bean gating (#351) - RememberMeIntegrationTest (hash-based, real formLogin path): cookie issued only when the remember-me parameter is posted; session-less cookie re-auth yields a RememberMeAuthenticationToken with a DSUserDetails principal; auto-login publishes InteractiveAuthenticationSuccessEvent; cookie rejected after a password change. Deliberately NO test for admin revocation of hash-based tokens - not implementable, per the ticket. - RememberMePersistentTokenIntegrationTest: opt-in property creates JdbcTokenRepositoryImpl; login stores a token row keyed by email; both SessionInvalidationService paths remove the rows and the old cookie stops authenticating. - RememberMeCustomConfigIntegrationTest: non-default parameter/cookie names, validity, and forced Secure flag all bind and take effect. - CoreBeanOverrideTest: bean absent when the property is unset/false, present when true, consumer-defined PersistentTokenRepository wins; annotation contract asserted. - SessionInvalidationServiceTest: removeUserTokens called on both invalidation paths, no-op without the bean, and a repository failure is swallowed without breaking session invalidation. --- .../user/security/CoreBeanOverrideTest.java | 67 ++++++ ...RememberMeCustomConfigIntegrationTest.java | 115 ++++++++++ .../security/RememberMeIntegrationTest.java | 207 ++++++++++++++++++ ...emberMePersistentTokenIntegrationTest.java | 183 ++++++++++++++++ .../SessionInvalidationServiceTest.java | 79 ++++++- 5 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/RememberMeCustomConfigIntegrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/RememberMeIntegrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/RememberMePersistentTokenIntegrationTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java index 4087e5fd..57811e2c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java @@ -4,13 +4,16 @@ import java.lang.reflect.Method; import java.util.List; +import javax.sql.DataSource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; @@ -22,6 +25,9 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; import com.digitalsanctuary.spring.user.util.AppUrlResolver; @@ -219,6 +225,47 @@ void authProviderUsesConsumerEncoder() { } } + @Nested + @DisplayName("PersistentTokenRepository gating (remember-me persistent tokens, #351)") + class PersistentTokenRepositoryGating { + + @Test + @DisplayName("No PersistentTokenRepository bean when usePersistentTokens is unset (hash-based default)") + void absentWhenPropertyUnset() { + contextRunner.run(context -> assertThat(context).hasNotFailed().doesNotHaveBean(PersistentTokenRepository.class)); + } + + @Test + @DisplayName("No PersistentTokenRepository bean when usePersistentTokens=false") + void absentWhenPropertyFalse() { + contextRunner.withPropertyValues("user.security.rememberMe.usePersistentTokens=false") + .run(context -> assertThat(context).hasNotFailed().doesNotHaveBean(PersistentTokenRepository.class)); + } + + @Test + @DisplayName("usePersistentTokens=true creates a JdbcTokenRepositoryImpl wired to the DataSource") + void presentWhenPropertyTrue() { + contextRunner.withBean(DataSource.class, () -> new DriverManagerDataSource("jdbc:h2:mem:persistentTokenGatingTest")) + .withPropertyValues("user.security.rememberMe.usePersistentTokens=true").run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(PersistentTokenRepository.class); + assertThat(context.getBean(PersistentTokenRepository.class)).isInstanceOf(JdbcTokenRepositoryImpl.class); + }); + } + + @Test + @DisplayName("Consumer PersistentTokenRepository replaces the library's JdbcTokenRepositoryImpl") + void consumerPersistentTokenRepositoryWins() { + contextRunner.withBean(DataSource.class, () -> new DriverManagerDataSource("jdbc:h2:mem:persistentTokenOverrideTest")) + .withPropertyValues("user.security.rememberMe.usePersistentTokens=true") + .withUserConfiguration(ConsumerPersistentTokenRepositoryConfig.class).run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(PersistentTokenRepository.class); + PersistentTokenRepository active = context.getBean(PersistentTokenRepository.class); + assertThat(active).as("consumer's token repository must win").isSameAs(ConsumerPersistentTokenRepositoryConfig.CONSUMER_REPOSITORY); + assertThat(active).isNotInstanceOf(JdbcTokenRepositoryImpl.class); + }); + } + } + @Nested @DisplayName("Annotation contract on the auto-configuration bean methods") class AnnotationContract { @@ -259,6 +306,17 @@ void appUrlResolverIsConditional() throws Exception { Method method = UserSecurityBeansAutoConfiguration.class.getMethod("appUrlResolver", String.class, List.class, boolean.class); assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); } + + @Test + @DisplayName("persistentTokenRepository() is @ConditionalOnMissingBean AND @ConditionalOnProperty(usePersistentTokens)") + void persistentTokenRepositoryIsConditionalAndGated() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("persistentTokenRepository", DataSource.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).as("@ConditionalOnMissingBean must be present").isNotNull(); + ConditionalOnProperty onProperty = method.getAnnotation(ConditionalOnProperty.class); + assertThat(onProperty).as("@ConditionalOnProperty must gate the bean so it is never created without opt-in").isNotNull(); + assertThat(onProperty.name()).contains("user.security.rememberMe.usePersistentTokens"); + assertThat(onProperty.havingValue()).isEqualTo("true"); + } } // ---- Consumer-supplied stand-in configurations. Not @Configuration so the integration tests' component scan does not pick them up. ---- @@ -300,6 +358,15 @@ DaoAuthenticationProvider consumerAuthProvider() { } } + static class ConsumerPersistentTokenRepositoryConfig { + static final PersistentTokenRepository CONSUMER_REPOSITORY = new InMemoryTokenRepositoryImpl(); + + @Bean + PersistentTokenRepository consumerPersistentTokenRepository() { + return CONSUMER_REPOSITORY; + } + } + static class ConsumerAppUrlResolverConfig { static final AppUrlResolver CONSUMER_RESOLVER = new AppUrlResolver("https://consumer.example.com", List.of()); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeCustomConfigIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeCustomConfigIntegrationTest.java new file mode 100644 index 00000000..65b8c498 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeCustomConfigIntegrationTest.java @@ -0,0 +1,115 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import jakarta.servlet.http.Cookie; + +/** + * Proves the remember-me cookie/parameter configuration properties actually bind — a silently dropped {@code @Value} + * on {@code rememberMeParameter}, {@code rememberMeCookieName}, {@code tokenValiditySeconds}, or + * {@code useSecureCookie} would pass every default-value test, so this class overrides all of them and asserts the + * issued cookie reflects each override. + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import(BaseTestConfiguration.class) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:remembermecustomtest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "user.security.rememberMe.enabled=true", + "user.security.rememberMe.key=remember-me-custom-config-test-key", + "user.security.rememberMe.tokenValiditySeconds=3600", + "user.security.rememberMe.rememberMeParameter=keep-me-signed-in", + "user.security.rememberMe.rememberMeCookieName=stay-signed-in", + "user.security.rememberMe.useSecureCookie=true" +}) +@DisplayName("Remember-Me Integration Tests (non-default cookie/parameter configuration)") +class RememberMeCustomConfigIntegrationTest { + + private static final String LOGIN_URL = "/user/login"; + private static final String TEST_EMAIL = "custom-remember-me-user@test.com"; + private static final String PASSWORD = "CorrectPass1!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void seedFreshUser() { + deleteTestUser(); + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("Custom"); + user.setLastName("RememberMe"); + user.setPassword(passwordEncoder.encode(PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + userRepository.save(user); + } + + @AfterEach + void cleanup() { + deleteTestUser(); + } + + private void deleteTestUser() { + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + } + + @Test + @DisplayName("should issue a cookie honoring the custom name, validity, and Secure flag when the custom parameter is posted") + void shouldHonorCustomParameterCookieNameValidityAndSecureFlag() throws Exception { + MvcResult result = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD) + .param("keep-me-signed-in", "true").with(csrf())) + .andExpect(authenticated()) + .andReturn(); + + Cookie customCookie = result.getResponse().getCookie("stay-signed-in"); + assertThat(customCookie).as("cookie should be issued under the configured custom name").isNotNull(); + assertThat(customCookie.getMaxAge()).as("cookie lifetime should honor tokenValiditySeconds").isEqualTo(3600); + assertThat(customCookie.getSecure()).as("useSecureCookie=true should force the Secure flag even on an HTTP test request").isTrue(); + assertThat(result.getResponse().getCookie("remember-me")).as("nothing should be issued under the default cookie name").isNull(); + } + + @Test + @DisplayName("should ignore the default remember-me parameter when a custom parameter name is configured") + void shouldIgnoreDefaultParameterName() throws Exception { + MvcResult result = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD) + .param("remember-me", "true").with(csrf())) + .andExpect(authenticated()) + .andReturn(); + + assertThat(result.getResponse().getCookie("stay-signed-in")).as("the default parameter name must not trigger issuance").isNull(); + assertThat(result.getResponse().getCookie("remember-me")).isNull(); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeIntegrationTest.java new file mode 100644 index 00000000..1564fced --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeIntegrationTest.java @@ -0,0 +1,207 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.security.authentication.RememberMeAuthenticationToken; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import jakarta.servlet.http.Cookie; + +/** + * Integration tests for hash-based remember-me (the default mode, Spring's {@code TokenBasedRememberMeServices}) + * through the real form-login path. + * + *

+ * What this proves end-to-end: a login that posts the {@code remember-me} parameter is issued a remember-me cookie + * (and one that omits the parameter is not — the reason a consumer's login form MUST include the checkbox); a + * session-less request bearing only that cookie is auto-authenticated with a {@link RememberMeAuthenticationToken} + * whose principal is a {@link DSUserDetails} (so {@code @AuthenticationPrincipal DSUserDetails} controller signatures + * work on remember-me logins); the auto-login publishes {@link InteractiveAuthenticationSuccessEvent} (which + * {@code BaseAuthenticationListener} relies on to populate session profiles); and a password change invalidates the + * cookie inherently, because the hash-based cookie signature embeds the password hash. + *

+ * + *

+ * Like {@link AccountLockoutIntegrationTest}, this class avoids {@code @SecurityTest} (whose {@code @Primary} + * in-memory user details manager would bypass the DB-backed {@code DSUserDetailsService} that remember-me + * re-authentication must exercise) and uses an isolated in-memory database so its committed rows cannot race other + * test classes' {@code deleteAll()} calls. + *

+ */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import({BaseTestConfiguration.class, RememberMeIntegrationTest.EventCaptureConfiguration.class}) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:remembermetest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "user.security.rememberMe.enabled=true", + "user.security.rememberMe.key=remember-me-integration-test-key" +}) +@DisplayName("Remember-Me Integration Tests (hash-based mode, real formLogin path)") +class RememberMeIntegrationTest { + + private static final String LOGIN_URL = "/user/login"; + /** Protected under the test profile's defaultAction=deny (not in unprotectedURIs). */ + private static final String PROTECTED_URL = "/protected.html"; + private static final String REMEMBER_ME_COOKIE = "remember-me"; + private static final String REMEMBER_ME_PARAMETER = "remember-me"; + + private static final String TEST_EMAIL = "remember-me-user@test.com"; + private static final String PASSWORD = "CorrectPass1!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private CapturedEvents capturedEvents; + + @BeforeEach + void seedFreshUser() { + deleteTestUser(); + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("Remember"); + user.setLastName("Me"); + user.setPassword(passwordEncoder.encode(PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + userRepository.save(user); + capturedEvents.interactiveAuthenticationSuccessEvents.clear(); + } + + @AfterEach + void cleanup() { + deleteTestUser(); + } + + private void deleteTestUser() { + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + } + + @Test + @DisplayName("should issue a remember-me cookie when the login form posts the remember-me parameter") + void shouldIssueCookieWhenLoginPostsRememberMeParameter() throws Exception { + Cookie cookie = loginWithRememberMe(); + + assertThat(cookie).as("remember-me cookie should be issued").isNotNull(); + assertThat(cookie.getValue()).isNotBlank(); + assertThat(cookie.getMaxAge()).as("cookie lifetime should be the configured 14-day default").isEqualTo(1209600); + } + + @Test + @DisplayName("should NOT issue a remember-me cookie when the login form omits the remember-me parameter") + void shouldNotIssueCookieWhenParameterOmitted() throws Exception { + Cookie cookie = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD).with(csrf())) + .andExpect(authenticated()) + .andReturn().getResponse().getCookie(REMEMBER_ME_COOKIE); + + // This is why enabling the properties alone is not enough: the consumer's login form must post the parameter. + assertThat(cookie).as("no remember-me cookie without the request parameter").isNull(); + } + + @Test + @DisplayName("should auto-authenticate a session-less request from the cookie with a DSUserDetails principal") + void shouldAutoAuthenticateFromCookieWithDSUserDetailsPrincipal() throws Exception { + Cookie cookie = loginWithRememberMe(); + + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)) + .andExpect(authenticated().withAuthentication(auth -> { + assertThat(auth).isInstanceOf(RememberMeAuthenticationToken.class); + assertThat(auth.getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) auth.getPrincipal()).getUsername()).isEqualTo(TEST_EMAIL); + })); + } + + @Test + @DisplayName("should publish InteractiveAuthenticationSuccessEvent on remember-me auto-login") + void shouldPublishInteractiveAuthenticationSuccessEventOnAutoLogin() throws Exception { + Cookie cookie = loginWithRememberMe(); + capturedEvents.interactiveAuthenticationSuccessEvents.clear(); + + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)).andExpect(authenticated()); + + // BaseAuthenticationListener populates session profiles from this event, so remember-me logins must fire it. + assertThat(capturedEvents.interactiveAuthenticationSuccessEvents) + .as("remember-me auto-login should publish InteractiveAuthenticationSuccessEvent") + .anySatisfy(event -> assertThat(event.getAuthentication()).isInstanceOf(RememberMeAuthenticationToken.class)); + } + + @Test + @DisplayName("should reject the remember-me cookie after a password change (signature embeds the password hash)") + void shouldRejectCookieAfterPasswordChange() throws Exception { + Cookie cookie = loginWithRememberMe(); + + User user = userRepository.findByEmail(TEST_EMAIL); + user.setPassword(passwordEncoder.encode("CompletelyNewPass2!")); + userRepository.save(user); + + // The hash-based cookie signature is computed over the password hash, so the old cookie no longer verifies. + // This inherent protection is the only revocation hash-based mode has (there is no server-side state to + // remove) — admin-initiated "sign out everywhere" cannot kill these cookies; that requires persistent mode. + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)).andExpect(unauthenticated()); + } + + private Cookie loginWithRememberMe() throws Exception { + Cookie cookie = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD) + .param(REMEMBER_ME_PARAMETER, "true").with(csrf())) + .andExpect(authenticated()) + .andReturn().getResponse().getCookie(REMEMBER_ME_COOKIE); + assertThat(cookie).as("login with remember-me parameter should issue the cookie").isNotNull(); + return cookie; + } + + /** Captures InteractiveAuthenticationSuccessEvent publications so tests can assert remember-me auto-login fires it. */ + static class CapturedEvents { + final List interactiveAuthenticationSuccessEvents = new CopyOnWriteArrayList<>(); + + @EventListener + void onInteractiveAuthenticationSuccess(InteractiveAuthenticationSuccessEvent event) { + interactiveAuthenticationSuccessEvents.add(event); + } + } + + @TestConfiguration + static class EventCaptureConfiguration { + @Bean + CapturedEvents capturedEvents() { + return new CapturedEvents(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMePersistentTokenIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMePersistentTokenIntegrationTest.java new file mode 100644 index 00000000..b05c60c4 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMePersistentTokenIntegrationTest.java @@ -0,0 +1,183 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.service.SessionInvalidationService; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.RememberMeAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import jakarta.servlet.http.Cookie; + +/** + * Integration tests for persistent-token remember-me ({@code user.security.rememberMe.usePersistentTokens=true}). + * + *

+ * What this proves end-to-end: the opt-in property auto-configures a {@link JdbcTokenRepositoryImpl} backed by the + * application {@code DataSource}; a remember-me login stores a token row in {@code persistent_logins}; the cookie + * auto-authenticates a session-less request with a {@link DSUserDetails} principal; and — the reason the token store + * and revocation ship together — {@link SessionInvalidationService} removes the stored tokens on both invalidation + * paths, after which the old cookie is rejected. Persistent tokens do not embed the password hash, so without that + * revocation they would survive a password change. + *

+ * + *

+ * The {@code persistent_logins} table is created in {@code @BeforeEach} because it is not a JPA entity (Hibernate's + * {@code ddl-auto} does not know it); in production the DDL ships in {@code db-scripts/}. + *

+ */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import(BaseTestConfiguration.class) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:remembermepersistenttest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "user.security.rememberMe.enabled=true", + "user.security.rememberMe.key=remember-me-persistent-test-key", + "user.security.rememberMe.usePersistentTokens=true" +}) +@DisplayName("Remember-Me Integration Tests (persistent-token mode)") +class RememberMePersistentTokenIntegrationTest { + + private static final String LOGIN_URL = "/user/login"; + private static final String PROTECTED_URL = "/protected.html"; + private static final String REMEMBER_ME_COOKIE = "remember-me"; + + private static final String TEST_EMAIL = "persistent-remember-me-user@test.com"; + private static final String PASSWORD = "CorrectPass1!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PersistentTokenRepository persistentTokenRepository; + + @Autowired + private SessionInvalidationService sessionInvalidationService; + + @BeforeEach + void setUp() { + // Mirrors db-scripts DDL; not a JPA entity, so Hibernate ddl-auto cannot create it. + jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS persistent_logins (" + + "username VARCHAR(255) NOT NULL, series VARCHAR(64) PRIMARY KEY, " + + "token VARCHAR(64) NOT NULL, last_used TIMESTAMP NOT NULL)"); + jdbcTemplate.execute("DELETE FROM persistent_logins"); + + deleteTestUser(); + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("Persistent"); + user.setLastName("RememberMe"); + user.setPassword(passwordEncoder.encode(PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + userRepository.save(user); + } + + @AfterEach + void cleanup() { + deleteTestUser(); + } + + private void deleteTestUser() { + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + } + + private int storedTokenCount() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM persistent_logins WHERE username = ?", Integer.class, TEST_EMAIL); + return count != null ? count : 0; + } + + @Test + @DisplayName("should auto-configure JdbcTokenRepositoryImpl when usePersistentTokens is enabled") + void shouldAutoConfigureJdbcTokenRepository() { + assertThat(persistentTokenRepository).isInstanceOf(JdbcTokenRepositoryImpl.class); + } + + @Test + @DisplayName("should store a token row on remember-me login and auto-authenticate from the cookie") + void shouldStoreTokenAndAutoAuthenticateFromCookie() throws Exception { + Cookie cookie = loginWithRememberMe(); + + assertThat(storedTokenCount()).as("a persistent token row should be stored, keyed by email").isEqualTo(1); + + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)) + .andExpect(authenticated().withAuthentication(auth -> { + assertThat(auth).isInstanceOf(RememberMeAuthenticationToken.class); + assertThat(auth.getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) auth.getPrincipal()).getUsername()).isEqualTo(TEST_EMAIL); + })); + } + + @Test + @DisplayName("should revoke stored tokens on invalidateUserSessions and reject the old cookie") + void shouldRevokeTokensOnInvalidateUserSessions() throws Exception { + Cookie cookie = loginWithRememberMe(); + assertThat(storedTokenCount()).isEqualTo(1); + + // Admin-initiated "sign this user out everywhere": without token revocation the remember-me cookie would + // silently re-authenticate the user on the next request, defeating the invalidation. + sessionInvalidationService.invalidateUserSessions(userRepository.findByEmail(TEST_EMAIL)); + + assertThat(storedTokenCount()).as("invalidateUserSessions should remove the user's persistent tokens").isZero(); + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)).andExpect(unauthenticated()); + } + + @Test + @DisplayName("should revoke stored tokens on invalidateSessionsAfterPasswordChange and reject the old cookie") + void shouldRevokeTokensOnPasswordChange() throws Exception { + Cookie cookie = loginWithRememberMe(); + assertThat(storedTokenCount()).isEqualTo(1); + + // Persistent tokens do NOT embed the password hash (unlike hash-based cookies), so this explicit revocation + // is what keeps a password change meaningful in persistent mode — the reason store + revocation ship together. + sessionInvalidationService.invalidateSessionsAfterPasswordChange(userRepository.findByEmail(TEST_EMAIL)); + + assertThat(storedTokenCount()).as("password change should remove the user's persistent tokens").isZero(); + mockMvc.perform(get(PROTECTED_URL).cookie(cookie)).andExpect(unauthenticated()); + } + + private Cookie loginWithRememberMe() throws Exception { + Cookie cookie = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD) + .param("remember-me", "true").with(csrf())) + .andExpect(authenticated()) + .andReturn().getResponse().getCookie(REMEMBER_ME_COOKIE); + assertThat(cookie).as("login with remember-me parameter should issue the cookie").isNotNull(); + return cookie; + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java index e2441806..28748064 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java @@ -17,10 +17,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -35,6 +37,9 @@ class SessionInvalidationServiceTest { @Mock private SessionRegistry sessionRegistry; + @Mock + private ObjectProvider persistentTokenRepositoryProvider; + @InjectMocks private SessionInvalidationService sessionInvalidationService; @@ -252,7 +257,7 @@ void doesNotWarnWhenPrincipalCountBelowThreshold() { @DisplayName("uses default threshold of 1000") void usesDefaultThresholdOf1000() { // Given - create a new service without setting threshold (should use default) - SessionInvalidationService newService = new SessionInvalidationService(sessionRegistry); + SessionInvalidationService newService = new SessionInvalidationService(sessionRegistry, persistentTokenRepositoryProvider); // Verify the default value is set correctly via reflection Integer threshold = (Integer) ReflectionTestUtils.getField(newService, "warnThreshold"); @@ -372,4 +377,76 @@ void returnsZeroWhenUserIsNull() { verify(sessionRegistry, never()).getAllPrincipals(); } } + + @Nested + @DisplayName("Remember-Me Token Revocation Tests") + class RememberMeTokenRevocationTests { + + @Mock + private PersistentTokenRepository persistentTokenRepository; + + @Test + @DisplayName("invalidateUserSessions removes the user's persistent remember-me tokens, keyed by email") + void invalidateUserSessionsRevokesPersistentTokens() { + when(persistentTokenRepositoryProvider.getIfAvailable()).thenReturn(persistentTokenRepository); + when(sessionRegistry.getAllPrincipals()).thenReturn(Collections.emptyList()); + + sessionInvalidationService.invalidateUserSessions(testUser); + + // Revocation must happen even with zero active sessions: the remember-me cookie alone would otherwise + // silently re-authenticate the user on their next request. + verify(persistentTokenRepository).removeUserTokens(testUser.getEmail()); + } + + @Test + @DisplayName("invalidateSessionsAfterPasswordChange removes the user's persistent remember-me tokens") + void passwordChangeRevokesPersistentTokens() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + when(persistentTokenRepositoryProvider.getIfAvailable()).thenReturn(persistentTokenRepository); + when(sessionRegistry.getAllPrincipals()).thenReturn(Collections.emptyList()); + + sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + verify(persistentTokenRepository).removeUserTokens(testUser.getEmail()); + } + + @Test + @DisplayName("is a no-op in hash-based mode (no PersistentTokenRepository bean present)") + void noOpWithoutPersistentTokenRepository() { + // Default hash-based remember-me has no server-side token state; the provider resolves to null. + when(persistentTokenRepositoryProvider.getIfAvailable()).thenReturn(null); + when(sessionRegistry.getAllPrincipals()).thenReturn(Collections.emptyList()); + + int invalidated = sessionInvalidationService.invalidateUserSessions(testUser); + + assertThat(invalidated).isEqualTo(0); + verifyNoInteractions(persistentTokenRepository); + } + + @Test + @DisplayName("does not touch tokens when user is null (no username to key on)") + void doesNotRevokeWhenUserIsNull() { + sessionInvalidationService.invalidateUserSessions(null); + + verifyNoInteractions(persistentTokenRepositoryProvider); + } + + @Test + @DisplayName("a repository failure is logged and swallowed - session invalidation still completes") + void repositoryFailureDoesNotBreakInvalidation() { + // Revocation runs inside password-change transactions and after-commit deletion callbacks; a thrown + // DataAccessException there would roll back or misreport the primary operation. + when(persistentTokenRepositoryProvider.getIfAvailable()).thenReturn(persistentTokenRepository); + doThrow(new RuntimeException("persistent_logins table missing")).when(persistentTokenRepository) + .removeUserTokens(testUser.getEmail()); + SessionInformation session = mock(SessionInformation.class); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(List.of(session)); + + int invalidated = sessionInvalidationService.invalidateUserSessions(testUser); + + assertThat(invalidated).isEqualTo(1); + verify(session).expireNow(); + } + } } From bef6b415b171e2d5ae42a158f023ffc3dca69cd7 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 22:39:22 -0600 Subject: [PATCH 3/4] docs: document remember-me configuration, revocation semantics, and migration (#351) - CONFIG.md: full property reference; the two mandatory setup steps (enabled+key AND the login form posting the remember-me parameter); hash-based vs persistent trade-offs including the admin-revocation limitation of hash-based mode; the persistent_logins requirement; secure-cookie behavior behind TLS-terminating proxies. - README.md: replace the bare 'Remember-me functionality' bullet, which implied the feature worked out of the box. - MIGRATION.md: 5.2.x note - no behavior change when disabled; new ObjectProvider constructor parameter on WebSecurityConfig and SessionInvalidationService for subclasses. --- CONFIG.md | 28 ++++++++++++++++++++++++++++ MIGRATION.md | 20 ++++++++++++++++++++ README.md | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CONFIG.md b/CONFIG.md index c149e23c..27daf650 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -109,6 +109,34 @@ Verification and password-reset tokens are **hashed at rest**. The raw token is Only one active token per user is kept for each token type: requesting a new password reset or verification email invalidates the previous one. +### Remember-Me ("Stay Signed In") + +Disabled by default. Two things are required to make it work, and both are on you as the consumer: + +1. Set `user.security.rememberMe.enabled=true` **and** a `user.security.rememberMe.key`. Without a key the feature stays off. +2. Your login form must post the remember-me request parameter (a checkbox named `remember-me` by default). **Without the parameter, no cookie is ever issued** — enabling the properties alone does nothing visible. + +```html + Remember me +``` + +- **Enabled (`user.security.rememberMe.enabled`)**: Master switch. Default `false`. +- **Key (`user.security.rememberMe.key`)**: Secret used to sign remember-me tokens. Required. Keep it stable across restarts and instances — changing it invalidates every outstanding remember-me cookie. +- **Token Validity (`user.security.rememberMe.tokenValiditySeconds`)**: How long a token stays valid. Default `1209600` (14 days). +- **Parameter Name (`user.security.rememberMe.rememberMeParameter`)**: Request parameter the login form posts. Default `remember-me`. +- **Cookie Name (`user.security.rememberMe.rememberMeCookieName`)**: Default `remember-me`. +- **Secure Cookie (`user.security.rememberMe.useSecureCookie`)**: Unset by default, which means the cookie is marked `Secure` whenever the request that created it used HTTPS. **Behind a TLS-terminating reverse proxy the request reaches the app as plain HTTP**, so the default only works if forwarded-header processing is configured (e.g. `server.forward-headers-strategy=framework` or `native`). If you terminate TLS at a proxy, either configure forwarded headers or set this to `true` explicitly. +- **Persistent Tokens (`user.security.rememberMe.usePersistentTokens`)**: Default `false` (hash-based cookies). See below. + +**Hash-based vs. persistent tokens.** By default remember-me uses Spring Security's hash-based `TokenBasedRememberMeServices`: the cookie is a self-contained signature and nothing is stored server-side. Setting `user.security.rememberMe.usePersistentTokens=true` switches to database-backed tokens (`JdbcTokenRepositoryImpl`), which **requires the `persistent_logins` table** — the DDL is in `db-scripts/`; the library does not create it for you. You can also supply your own `PersistentTokenRepository` bean, which takes precedence. + +**Revocation semantics — read this before choosing a mode:** + +- **Persistent tokens** are revoked server-side by the library: when a user's sessions are invalidated (account disable/delete, admin-initiated sign-out) and on password change, all of the user's stored tokens are removed. On a self-service password change this includes the current device's token — the current session stays alive, but the user logs in again once it ends. +- **Hash-based tokens cannot be revoked by admin action.** There is no server-side state; a cookie stays valid until it expires. A password change does invalidate them (the signature embeds the password hash). If "sign this user out everywhere, now" must also kill remember-me cookies, use persistent tokens. + +Remember-me works with all of the library's authentication paths (form login, OAuth2/OIDC, passkeys) because they all converge on the same `DSUserDetails` principal. One caveat for OAuth2/OIDC consumers: on a remember-me auto-login, provider claims are not available — `getIdToken()`/`getUserInfo()` return `null` and `getAttributes()` falls back to values from the local `User` entity. + ## WebAuthn / Passkey Settings Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function. diff --git a/MIGRATION.md b/MIGRATION.md index c0949768..048eb58c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,6 +6,8 @@ This guide covers migrating applications using the Spring User Framework between - [Migration Guide](#migration-guide) - [Table of Contents](#table-of-contents) + - [Migrating to 5.2.x](#migrating-to-52x) + - [Remember-me completed; two constructors gained parameters](#remember-me-completed-two-constructors-gained-parameters) - [Migrating to 5.0.x](#migrating-to-50x) - [⚠️ ACTION REQUIRED: Reverse-proxy deployments must configure a canonical app URL](#-action-required-reverse-proxy-deployments-must-configure-a-canonical-app-url) - [Database schema: unique token constraint](#database-schema-unique-token-constraint) @@ -42,6 +44,24 @@ This guide covers migrating applications using the Spring User Framework between - [Common Issues](#common-issues) - [Version Compatibility Matrix](#version-compatibility-matrix) +## Migrating to 5.2.x + +### Remember-me completed; two constructors gained parameters + +Remember-me support is now fully functional (persistent token store, cookie configuration, token +revocation on session invalidation and password change). See +[CONFIG.md → Remember-Me](CONFIG.md#remember-me-stay-signed-in) for setup — note in particular +that your login form must post the `remember-me` parameter, and that +`user.security.rememberMe.usePersistentTokens=true` requires the new `persistent_logins` table +(DDL in `db-scripts/`). + +No behavior changes for applications that leave remember-me disabled. + +**Breaking for subclasses/direct instantiation only:** `WebSecurityConfig` and +`SessionInvalidationService` each gained an `ObjectProvider` constructor +parameter. If you subclass or directly instantiate either (uncommon — most consumers interact with +them only as Spring beans, which are unaffected), add and pass through the new parameter. + ## Migrating to 5.0.x This section covers migrating from Spring User Framework 4.4.x to 5.0.x. Version 5.0.0 is a **major release** containing breaking changes. Spring Boot compatibility is unchanged from 4.4.x (Spring Boot 4.0 on Java 21+, and Spring Boot 3.5 on Java 17+); the major-version bump reflects this library's own API/contract changes, not a Spring Boot major change. diff --git a/README.md b/README.md index e56400c1..12a59a8a 100644 --- a/README.md +++ b/README.md @@ -643,7 +643,7 @@ The framework includes a complete email verification system: Username/password authentication with: - Secure password hashing (bcrypt) - Account lockout protection -- Remember-me functionality +- Remember-me ("stay signed in") with hash-based or database-backed persistent tokens — requires enabling `user.security.rememberMe.*` properties **and** a `remember-me` checkbox on your login form (see [CONFIG.md](CONFIG.md#remember-me-stay-signed-in)) ### WebAuthn / Passkeys From fe63545ddac8c96d6f0d5af896e98d61a5eb1cb5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Tue, 11 Aug 2026 23:10:11 -0600 Subject: [PATCH 4/4] test(security): prove remember-me stays inert at its disabled default (#351) Second review pass: all existing remember-me tests set enabled=true, so a regression in the WebSecurityConfig guard (enabled + non-blank key) would silently start issuing persistent-auth cookies to every consumer on upgrade. New test class logs in with the remember-me parameter under shipped defaults and asserts no cookie and no PersistentTokenRepository bean. Also fixes javax.sql.DataSource import ordering in UserSecurityBeansAutoConfiguration. --- .../UserSecurityBeansAutoConfiguration.java | 2 +- ...berMeDisabledByDefaultIntegrationTest.java | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/RememberMeDisabledByDefaultIntegrationTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index 084f4536..852705ca 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -2,8 +2,8 @@ import java.util.List; import java.util.Set; -import org.springframework.beans.factory.annotation.Value; import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeDisabledByDefaultIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeDisabledByDefaultIntegrationTest.java new file mode 100644 index 00000000..dcb4a442 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeDisabledByDefaultIntegrationTest.java @@ -0,0 +1,106 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Proves the shipped default — remember-me disabled — stays inert. Every consuming application upgrades with + * {@code user.security.rememberMe.enabled=false} (and no key), so the guard in + * {@code WebSecurityConfig.buildSecurityFilterChain} must keep the feature fully off: a login that posts the + * {@code remember-me} parameter anyway succeeds normally but is issued no remember-me cookie, and no + * {@link PersistentTokenRepository} bean exists. A regression that dropped or inverted that guard would silently + * start issuing 14-day persistent-auth cookies to every consumer on upgrade; the other remember-me test classes all + * set {@code enabled=true}, so only this class would catch it. + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import(BaseTestConfiguration.class) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:remembermedisabledtest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + // Deliberately NO user.security.rememberMe.* properties: this class tests the shipped defaults. +}) +@DisplayName("Remember-Me Integration Tests (disabled by default)") +class RememberMeDisabledByDefaultIntegrationTest { + + private static final String LOGIN_URL = "/user/login"; + private static final String TEST_EMAIL = "no-remember-me-user@test.com"; + private static final String PASSWORD = "CorrectPass1!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired(required = false) + private PersistentTokenRepository persistentTokenRepository; + + @BeforeEach + void seedFreshUser() { + deleteTestUser(); + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("NoRemember"); + user.setLastName("Me"); + user.setPassword(passwordEncoder.encode(PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + userRepository.save(user); + } + + @AfterEach + void cleanup() { + deleteTestUser(); + } + + private void deleteTestUser() { + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + } + + @Test + @DisplayName("should log in normally but issue NO remember-me cookie when the feature is left at its disabled default") + void shouldNotIssueCookieWithDefaultConfigEvenWhenParameterPosted() throws Exception { + MvcResult result = mockMvc + .perform(post(LOGIN_URL).param("username", TEST_EMAIL).param("password", PASSWORD) + .param("remember-me", "true").with(csrf())) + .andExpect(authenticated()) + .andReturn(); + + assertThat(result.getResponse().getCookie("remember-me")) + .as("default config (enabled=false, no key) must never issue a remember-me cookie").isNull(); + } + + @Test + @DisplayName("should not create a PersistentTokenRepository bean under default configuration") + void shouldNotCreatePersistentTokenRepositoryByDefault() { + assertThat(persistentTokenRepository).as("no token repository bean without usePersistentTokens opt-in").isNull(); + } +}