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
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..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,9 +2,11 @@
import java.util.List;
import java.util.Set;
+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;
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.
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/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();
+ }
+}
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();
+ }
+ }
}