From efc8d0fc5ed44c3998cffde4ac9cddfd88ffbe60 Mon Sep 17 00:00:00 2001
From: zhubaoduo <1848638596@qq.com>
Date: Sun, 3 May 2026 19:07:46 +0800
Subject: [PATCH] feat(server): persist HttpSession across server restarts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the in-memory SessionRegistry with a JDBC-backed Spring Session
store so that a container or process restart no longer evicts every
active session. Without this fix every desktop and mobile client is
forced back to the login screen on each redeploy (see #17 — maintainer
acknowledged: "A container restart will log out devices because the
session keys are removed").
- pom.xml: add spring-session-jdbc (spring-session-core was already on
the classpath but had no store backend wired in).
- application.properties: enable the JDBC store with automatic schema
init; pin server.servlet.session.cookie.name=JSESSIONID so existing
clients stay compatible; add WRITE_DELAY=0 to the H2 URL so session
writes are durable across an abrupt container stop.
- SecurityConfiguration: swap SessionRegistryImpl for
SpringSessionBackedSessionRegistry so the admin "active devices" view
also reads from the persistent store.
- UserPrincipal / Users: implement Serializable.
BruteForceProtectionService is marked transient (it is a Spring bean,
not session state); isAccountNonLocked() is null-guarded for the
rehydrated path.
- SessionService: SpringSessionBackedSessionRegistry.getAllPrincipals()
deliberately throws UnsupportedOperationException, which would 500
every "log this user out" path (admin force-logoff, Log off from all
devices, username/password change, user delete). Replaced with
FindByIndexNameSessionRepository.findByPrincipalName() + deleteById()
— same semantics, indexed lookup.
Verified locally: login → docker restart → original cookie still
authenticates and the dashboard renders without re-login.
---
.../ClipCascade_Backend/pom.xml | 7 +++
.../config/SecurityConfiguration.java | 19 ++++---
.../acme/clipcascade/model/UserPrincipal.java | 24 ++++++++-
.../com/acme/clipcascade/model/Users.java | 9 +++-
.../clipcascade/service/SessionService.java | 51 ++++++++++---------
.../src/main/resources/application.properties | 15 +++++-
6 files changed, 90 insertions(+), 35 deletions(-)
diff --git a/ClipCascade_Server/ClipCascade_Backend/pom.xml b/ClipCascade_Server/ClipCascade_Backend/pom.xml
index f0bd8cba..d447af49 100644
--- a/ClipCascade_Server/ClipCascade_Backend/pom.xml
+++ b/ClipCascade_Server/ClipCascade_Backend/pom.xml
@@ -54,6 +54,13 @@
org.springframework.sessionspring-session-core
+
+
+ org.springframework.session
+ spring-session-jdbc
+ org.springframework.bootspring-boot-starter-thymeleaf
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java
index cfac3026..5e8f8148 100644
--- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java
+++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java
@@ -8,11 +8,13 @@
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
-import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.session.HttpSessionEventPublisher;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.Session;
+import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import com.acme.clipcascade.service.BruteForceProtectionService;
import com.acme.clipcascade.service.FacadeUserService;
@@ -37,10 +39,13 @@ public class SecurityConfiguration {
this.facadeUserService = facadeUserService;
}
- // SessionRegistry bean to store session information
+ // Replaces the in-memory SessionRegistryImpl with a registry backed by
+ // the Spring Session repository, so server-side session state survives a
+ // restart and existing client cookies remain valid.
@Bean
- public SessionRegistry sessionRegistry() {
- return new SessionRegistryImpl();
+ public SessionRegistry sessionRegistry(
+ FindByIndexNameSessionRepository extends Session> sessionRepository) {
+ return new SpringSessionBackedSessionRegistry<>(sessionRepository);
}
// Ensures the SessionRegistry is notified of session lifecycle events
@@ -50,7 +55,9 @@ public HttpSessionEventPublisher httpSessionEventPublisher() {
}
@Bean
- public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http,
+ SessionRegistry sessionRegistry) throws Exception {
return http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(
@@ -78,7 +85,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS) // Always create a new session
.maximumSessions(-1) // Allow unlimited sessions
- .sessionRegistry(sessionRegistry()) // Use the session registry
+ .sessionRegistry(sessionRegistry)
.expiredSessionStrategy(new CustomExpiredSession())) // Custom expired session strategy
.build();
}
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java
index b3ba55f0..6b2d0220 100644
--- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java
+++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java
@@ -1,5 +1,6 @@
package com.acme.clipcascade.model;
+import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
@@ -10,11 +11,20 @@
import com.acme.clipcascade.constants.RoleConstants;
import com.acme.clipcascade.service.BruteForceProtectionService;
-public class UserPrincipal implements UserDetails {
+public class UserPrincipal implements UserDetails, Serializable {
+
+ // Spring Session (JDBC store) serializes the SecurityContext, which holds
+ // an Authentication whose principal is this UserPrincipal, so the class
+ // must be Serializable.
+ private static final long serialVersionUID = 1L;
private Users user;
- private final BruteForceProtectionService bruteForceProtectionService;
+ // Spring-managed service bean: not Serializable and must not be persisted
+ // alongside the user. Marked transient so it is skipped by the JDK
+ // serializer; after deserialization the field is null and the null-guard
+ // in isAccountNonLocked() takes over.
+ private final transient BruteForceProtectionService bruteForceProtectionService;
public UserPrincipal(
Users user,
@@ -27,6 +37,16 @@ public UserPrincipal(
@Override
public boolean isAccountNonLocked() {
+ // When this principal was rehydrated from a persisted session the
+ // transient service is null. Brute-force protection only needs to
+ // gate the login flow itself; an already-authenticated session being
+ // restored has cleared that gate previously, so skipping the check
+ // here is safe. A fresh login request goes through MyUserDetailsService
+ // and constructs a UserPrincipal that does have the service wired in.
+ if (bruteForceProtectionService == null) {
+ return true;
+ }
+
// validate attempt using brute force protection
return bruteForceProtectionService.recordAndValidateAttempt(user.getUsername());
}
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/Users.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/Users.java
index 04e050a4..21662bf8 100644
--- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/Users.java
+++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/Users.java
@@ -1,5 +1,7 @@
package com.acme.clipcascade.model;
+import java.io.Serializable;
+
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@@ -8,7 +10,12 @@
@Entity
@Table(name = "users")
-public class Users {
+public class Users implements Serializable {
+
+ // Held by UserPrincipal, which is serialized as part of the
+ // SecurityContext when sessions are persisted via Spring Session, so
+ // this entity must also be Serializable.
+ private static final long serialVersionUID = 1L;
@Id
@NotNull(message = "Username is required") // Validation constraint at application level
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java
index d53da74e..895e9746 100644
--- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java
+++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java
@@ -1,26 +1,37 @@
package com.acme.clipcascade.service;
-import java.util.List;
+import java.util.Map;
-import org.springframework.security.core.session.SessionInformation;
-import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.Session;
import org.springframework.stereotype.Service;
-import org.springframework.security.core.userdetails.UserDetails;
import com.acme.clipcascade.utils.UserValidator;
import jakarta.persistence.EntityNotFoundException;
@Service
public class SessionService {
- private final SessionRegistry sessionRegistry;
+
+ // SpringSessionBackedSessionRegistry.getAllPrincipals() throws
+ // UnsupportedOperationException because Spring Session does not expose a
+ // way to enumerate every principal across the store. The previous
+ // implementation, which scanned that list and filtered by username,
+ // therefore broke every "log out user X" code path (admin force-logoff,
+ // self "Logoff from All Devices", username/password change, user delete).
+ //
+ // Looking sessions up by indexed principal name and deleting them
+ // directly preserves the original semantics — the client cookie is
+ // invalidated — while also being O(n) only in the user's own sessions.
+
+ private final FindByIndexNameSessionRepository extends Session> sessionRepository;
private final UserService userService;
public SessionService(
- SessionRegistry sessionRegistry,
+ FindByIndexNameSessionRepository extends Session> sessionRepository,
UserService userService) {
- this.sessionRegistry = sessionRegistry;
+ this.sessionRepository = sessionRepository;
this.userService = userService;
}
@@ -35,28 +46,18 @@ public String logoutAllSessions(String username) {
throw new EntityNotFoundException("User not found");
}
- // Iterate over all principals in the SessionRegistry
- List