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
10 changes: 8 additions & 2 deletions docs/decisions/0015-website.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 22 in src/main/java/com/mccprogrammer/extpulse/auth/RememberedLogins.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"logger" is the name of a field in "AbstractRememberMeServices".

See more on https://sonarcloud.io/project/issues?id=MCCProgrammer_extpulse&issues=AaGDzJyaGKqpDXImzjl-&open=AaGDzJyaGKqpDXImzjl-&pullRequest=34

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;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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:; "
Expand Down Expand Up @@ -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")
Expand All @@ -105,13 +117,35 @@ 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),
new ContentSecurityPolicyHeaderWriter(CONTENT_SECURITY_POLICY))));
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() {
Expand Down
13 changes: 13 additions & 0 deletions src/main/resources/db/migration/V7__create_persistent_logins.sql
Original file line number Diff line number Diff line change
@@ -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);
8 changes: 8 additions & 0 deletions src/main/resources/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/templates/auth/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ <h1>Sign in</h1>
<label>Password
<input type="password" name="password" autocomplete="current-password" required>
</label>
<label class="check">
<input type="checkbox" name="remember-me"> Keep me signed in for 30 days
</label>
<div class="actions">
<button type="submit">Sign in</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading