Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<input type="checkbox" name="remember-me"> 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.
Expand Down
20 changes: 20 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 &rarr; 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<PersistentTokenRepository>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions db-scripts/mariadb-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 &mdash; 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}.
*
* <p>
* 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 &mdash; which
* is why the store and the revocation ship together.
* </p>
*
* @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 &mdash; which would otherwise overwrite the user's real
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -29,6 +31,7 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

/**
Expand Down Expand Up @@ -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;

Expand All @@ -118,6 +139,7 @@ public class WebSecurityConfig {
private final Environment environment;
private final ApplicationEventPublisher applicationEventPublisher;
private final RequestCache requestCache;
private final ObjectProvider<PersistentTokenRepository> persistentTokenRepositoryProvider;

/**
* Builds the library's security filter chain for Spring Security.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading