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.session spring-session-core + + + org.springframework.session + spring-session-jdbc + org.springframework.boot spring-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 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 sessionRepository; private final UserService userService; public SessionService( - SessionRegistry sessionRegistry, + FindByIndexNameSessionRepository 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 principals = sessionRegistry.getAllPrincipals(); - boolean foundUser = false; - - for (Object principal : principals) { - if (principal instanceof UserDetails userDetails) { // <- Spring Security UserDetails - if (userDetails.getUsername().equals(username)) { - foundUser = true; - // Get all active sessions for this principal - List sessions = sessionRegistry.getAllSessions(principal, false); - // Expire each session to effectively force logout - for (SessionInformation sessionInfo : sessions) { - sessionInfo.expireNow(); // mark it as expired - } - } - } - } + // Indexed lookup against the SPRING_SESSION.PRINCIPAL_NAME column. + Map sessions = sessionRepository.findByPrincipalName(username); - if (!foundUser) { + if (sessions.isEmpty()) { return "No active sessions found for username: " + username; } + // Deleting the session row immediately invalidates the client cookie. + for (String sessionId : sessions.keySet()) { + sessionRepository.deleteById(sessionId); + } + return "User '" + username + "' has been logged out of all active sessions."; } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties index eb3fb770..44ce69d5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties @@ -3,7 +3,7 @@ spring.application.name=ClipCascade # --------------------------------------- # Database Configuration # --------------------------------------- -spring.datasource.url=${CC_SERVER_DB_URL:jdbc:h2:file:./database/clipcascade;CIPHER=AES;MODE=PostgreSQL} +spring.datasource.url=${CC_SERVER_DB_URL:jdbc:h2:file:./database/clipcascade;CIPHER=AES;MODE=PostgreSQL;WRITE_DELAY=0} spring.datasource.driverClassName=${CC_SERVER_DB_DRIVER:org.h2.Driver} spring.datasource.username=${CC_SERVER_DB_USERNAME:clipcascade} spring.datasource.password=${CC_SERVER_DB_PASSWORD:QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky} @@ -23,6 +23,19 @@ spring.jpa.properties.hibernate.dialect=${CC_SERVER_DB_HIBERNATE_DIALECT:org.hib server.port=${CC_PORT:8080} server.servlet.session.timeout=${CC_SESSION_TIMEOUT:525960m} +# --------------------------------------- +# Spring Session (JDBC store) +# Persists HttpSession rows in the SPRING_SESSION table so existing +# client cookies remain valid across server/container restarts. +# The cookie name is pinned to JSESSIONID for backward compatibility +# with desktop and mobile clients. +# --------------------------------------- +spring.session.store-type=jdbc +spring.session.jdbc.initialize-schema=always +spring.session.jdbc.table-name=SPRING_SESSION +server.servlet.session.cookie.name=JSESSIONID +spring.session.timeout=${CC_SESSION_TIMEOUT:525960m} + # --------------------------------------- # Application Properties