From b501d47dbfb8ad70a2bcdf438c67889d8f093982 Mon Sep 17 00:00:00 2001 From: MCCarmo Date: Wed, 28 Oct 2026 18:34:43 +0000 Subject: [PATCH] Add "Keep me signed in" to the website's sign-in A box on the sign-in form keeps the browser signed in for 30 days with Spring Security's persistent remember-me tokens (persistent_logins, V7). Each use replaces the token; an old token coming back ends every remembered login of the account, and the visit carries on signed out instead of failing. Signing out deletes them. --- docs/decisions/0015-website.md | 10 ++- .../extpulse/auth/RememberedLogins.java | 40 ++++++++++ .../extpulse/auth/SecurityConfiguration.java | 36 ++++++++- .../V7__create_persistent_logins.sql | 13 +++ src/main/resources/static/css/app.css | 8 ++ src/main/resources/templates/auth/login.html | 3 + .../extpulse/auth/WebsiteSecurityTests.java | 79 +++++++++++++++++++ 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/mccprogrammer/extpulse/auth/RememberedLogins.java create mode 100644 src/main/resources/db/migration/V7__create_persistent_logins.sql diff --git a/docs/decisions/0015-website.md b/docs/decisions/0015-website.md index 7852b24..7fd9b83 100644 --- a/docs/decisions/0015-website.md +++ b/docs/decisions/0015-website.md @@ -30,12 +30,18 @@ stats and reviews and to manage alerts without crafting requests. email stay private. Badges are SVG, cached for an hour. - **Errors as pages:** the Problem Details handler now applies only to REST controllers; pages get `error.html`. +- **"Keep me signed in"** (added 2026-10-28): a box on the sign-in form keeps the + browser signed in for 30 days with Spring Security's persistent remember-me tokens + (`persistent_logins`, V7). Each use replaces the token; an old token coming back + means a copy exists, so every remembered login of the account ends and the visit + carries on signed out (`RememberedLogins`). Signing out deletes them. ## Consequences - One deployable for API and website; a change to both ships in one PR. -- Sessions live in memory: a deploy signs everyone out. Fine for one owner; Spring - Session (JDBC) would keep them across restarts. +- Sessions live in memory: a deploy signs everyone out, except browsers that ticked + "Keep me signed in", which sign back in on their next visit. Spring Session (JDBC) + would keep every session across restarts. - Pages are tested with MockMvc like the API, including CSRF and redirects. ## Alternatives considered diff --git a/src/main/java/com/mccprogrammer/extpulse/auth/RememberedLogins.java b/src/main/java/com/mccprogrammer/extpulse/auth/RememberedLogins.java new file mode 100644 index 0000000..015ea7d --- /dev/null +++ b/src/main/java/com/mccprogrammer/extpulse/auth/RememberedLogins.java @@ -0,0 +1,40 @@ +package com.mccprogrammer.extpulse.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.rememberme.CookieTheftException; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; + +/** + * "Keep me signed in" with persistent tokens. When a remembered cookie comes back with an + * old token, someone else has a copy: Spring Security then deletes every remembered login + * of the account and throws, which would end the request in a server error. Here the + * visit just carries on signed out, so the browser lands on the sign-in page. + */ +class RememberedLogins extends PersistentTokenBasedRememberMeServices { + + private static final Logger logger = LoggerFactory.getLogger(RememberedLogins.class); + + RememberedLogins(String key, UserDetailsService accounts, PersistentTokenRepository tokens) { + super(key, accounts, tokens); + } + + @Override + public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { + try { + return super.autoLogin(request, response); + } + catch (CookieTheftException ex) { + logger.warn("A remembered login was used with an old token; all of the account's remembered logins " + + "were ended"); + return null; + } + } + +} diff --git a/src/main/java/com/mccprogrammer/extpulse/auth/SecurityConfiguration.java b/src/main/java/com/mccprogrammer/extpulse/auth/SecurityConfiguration.java index bc793da..40516c7 100644 --- a/src/main/java/com/mccprogrammer/extpulse/auth/SecurityConfiguration.java +++ b/src/main/java/com/mccprogrammer/extpulse/auth/SecurityConfiguration.java @@ -2,9 +2,12 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.time.Duration; +import java.util.HexFormat; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; +import javax.sql.DataSource; import com.nimbusds.jose.jwk.source.ImmutableSecret; import org.slf4j.Logger; @@ -27,6 +30,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter; import org.springframework.security.web.header.writers.DelegatingRequestMatcherHeaderWriter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @@ -57,6 +61,13 @@ class SecurityConfiguration { private static final Logger logger = LoggerFactory.getLogger(SecurityConfiguration.class); + // "Keep me signed in": how long a browser stays signed in without the password. + private static final Duration REMEMBER_ME = Duration.ofDays(30); + + // Only ties the remember-me filter to its authentication provider in this process; + // the tokens themselves are random and stored, so a new key at each start is fine. + private static final String REMEMBER_ME_KEY = randomHex(16); + // Only this site's own files: no inline scripts or styles, no other origins, and no // framing. Swagger UI is left out, since it builds its page with inline code. private static final String CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; " @@ -84,7 +95,8 @@ SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { @Bean @Order(2) - SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain webSecurityFilterChain(HttpSecurity http, RememberedLogins rememberMeServices) + throws Exception { RequestMatcher swaggerUi = PathPatternRequestMatcher.withDefaults().matcher("/swagger-ui/**"); http.authorizeHttpRequests((requests) -> requests .requestMatchers("/", "/login", "/register", "/p/**", "/css/**", "/favicon.svg", "/error") @@ -105,6 +117,12 @@ SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { .anyRequest() .authenticated()) .formLogin((form) -> form.loginPage("/login").usernameParameter("email").defaultSuccessUrl("/app")) + // The box on the sign-in form ("remember-me"). Persistent tokens rather than + // a + // cookie signed with the password hash: each use replaces the token, a stolen + // one used after the real one ends every remembered login of the account, and + // signing out deletes them. The cookie is Secure over HTTPS. + .rememberMe((rememberMe) -> rememberMe.rememberMeServices(rememberMeServices).key(REMEMBER_ME_KEY)) .logout((logout) -> logout.logoutSuccessUrl("/login?signedOut")) .headers((headers) -> headers .addHeaderWriter(new DelegatingRequestMatcherHeaderWriter(new NegatedRequestMatcher(swaggerUi), @@ -112,6 +130,22 @@ SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { return http.build(); } + private static String randomHex(int bytes) { + byte[] random = new byte[bytes]; + RANDOM.nextBytes(random); + return HexFormat.of().formatHex(random); + } + + /** The remembered logins, in persistent_logins (V7). */ + @Bean + RememberedLogins rememberMeServices(DataSource dataSource, AccountDetailsService accounts) { + JdbcTokenRepositoryImpl tokens = new JdbcTokenRepositoryImpl(); + tokens.setDataSource(dataSource); + RememberedLogins services = new RememberedLogins(REMEMBER_ME_KEY, accounts, tokens); + services.setTokenValiditySeconds((int) REMEMBER_ME.toSeconds()); + return services; + } + /** BCrypt today; the "{bcrypt}" prefix lets a future algorithm read old hashes. */ @Bean PasswordEncoder passwordEncoder() { diff --git a/src/main/resources/db/migration/V7__create_persistent_logins.sql b/src/main/resources/db/migration/V7__create_persistent_logins.sql new file mode 100644 index 0000000..332f0f7 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_persistent_logins.sql @@ -0,0 +1,13 @@ +-- "Keep me signed in" on the website: Spring Security's persistent remember-me tokens. +-- The columns are the ones its JdbcTokenRepositoryImpl reads and writes. A login is a +-- series (one per browser that ticked the box) whose token changes every time it is +-- used; a stolen cookie used after the real one stops working, and signing out +-- deletes the account's series. +CREATE TABLE persistent_logins ( + series text PRIMARY KEY, + username text NOT NULL, + token text NOT NULL, + last_used timestamptz NOT NULL +); + +CREATE INDEX persistent_logins_username_idx ON persistent_logins (username); diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 6ddc767..4f194dc 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -311,6 +311,14 @@ label { color: var(--text-2); } +label.check { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--text); +} + input, select { font: inherit; diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index 8c8316a..3f6da1c 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -19,6 +19,9 @@

Sign in

+
diff --git a/src/test/java/com/mccprogrammer/extpulse/auth/WebsiteSecurityTests.java b/src/test/java/com/mccprogrammer/extpulse/auth/WebsiteSecurityTests.java index 7972fd5..76186c4 100644 --- a/src/test/java/com/mccprogrammer/extpulse/auth/WebsiteSecurityTests.java +++ b/src/test/java/com/mccprogrammer/extpulse/auth/WebsiteSecurityTests.java @@ -3,6 +3,7 @@ import com.mccprogrammer.extpulse.TestAccounts; import com.mccprogrammer.extpulse.TestcontainersConfiguration; import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,8 +15,10 @@ import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import static org.hamcrest.Matchers.containsString; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.not; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -44,6 +47,82 @@ class WebsiteSecurityTests { @BeforeEach void deleteAccounts() { this.jdbc.sql("DELETE FROM users").update(); + this.jdbc.sql("DELETE FROM persistent_logins").update(); + } + + @Test + void keepMeSignedInRemembersTheBrowserUntilSigningOut() throws Exception { + Cookie remembered = signInRemembered("miguel@example.com"); + assertThat(remembered.getMaxAge()).isEqualTo(30 * 24 * 3600); + assertThat(remembered.isHttpOnly()).isTrue(); + + // A new visit with no session, only the cookie: signed in, and given a new token. + MvcResult visit = this.mvc.perform(get("/app").cookie(remembered)).andExpect(status().isOk()).andReturn(); + Cookie renewed = visit.getResponse().getCookie("remember-me"); + assertThat(renewed.getValue()).isNotEqualTo(remembered.getValue()); + MockHttpSession session = (MockHttpSession) visit.getRequest().getSession(false); + + MvcResult signOut = this.mvc.perform(post("/logout").session(session).cookie(renewed).with(csrf())) + .andExpect(redirectedUrl("/login?signedOut")) + .andReturn(); + assertThat(signOut.getResponse().getCookie("remember-me").getMaxAge()).isZero(); + assertThat(rememberedLogins()).isZero(); + this.mvc.perform(get("/app").cookie(renewed)).andExpect(status().isFound()).andExpect(redirectedUrl("/login")); + } + + @Test + void aRememberedCookieUsedAgainAfterItsRenewalEndsTheRememberedLogin() throws Exception { + Cookie stolen = signInRemembered("miguel@example.com"); + Cookie renewed = this.mvc.perform(get("/app").cookie(stolen)) + .andReturn() + .getResponse() + .getCookie("remember-me"); + + // The old token again: someone else has a copy, so neither works any more. + this.mvc.perform(get("/app").cookie(stolen)).andExpect(status().isFound()); + this.mvc.perform(get("/app").cookie(renewed)).andExpect(status().isFound()); + assertThat(rememberedLogins()).isZero(); + } + + @Test + void withoutTheBoxTheBrowserIsNotRemembered() throws Exception { + TestAccounts.tokenFor(this.mvc, "miguel@example.com"); + MvcResult signIn = this.mvc + .perform(post("/login").param("email", "miguel@example.com") + .param("password", TestAccounts.PASSWORD) + .with(csrf())) + .andExpect(redirectedUrl("/app")) + .andReturn(); + + assertThat(signIn.getResponse().getCookie("remember-me")).isNull(); + assertThat(rememberedLogins()).isZero(); + } + + @Test + void theSignInPageOffersToKeepTheBrowserSignedIn() throws Exception { + this.mvc.perform(get("/login")) + .andExpect(content().string(containsString("name=\"remember-me\""))) + .andExpect(content().string(containsString("Keep me signed in"))); + } + + private Cookie signInRemembered(String email) throws Exception { + TestAccounts.tokenFor(this.mvc, email); + Cookie cookie = this.mvc + .perform(post("/login").param("email", email) + .param("password", TestAccounts.PASSWORD) + .param("remember-me", "on") + .with(csrf())) + .andExpect(redirectedUrl("/app")) + .andReturn() + .getResponse() + .getCookie("remember-me"); + assertThat(cookie).isNotNull(); + assertThat(rememberedLogins()).isEqualTo(1); + return cookie; + } + + private long rememberedLogins() { + return this.jdbc.sql("SELECT count(*) FROM persistent_logins").query(Long.class).single(); } @Test