Skip to content
Open
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
7 changes: 7 additions & 0 deletions ClipCascade_Server/ClipCascade_Backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<!-- Persists HttpSession to the relational store so a container restart
does not invalidate every active session and force all clients to
re-authenticate. -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.acme.clipcascade.model;

import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;

Expand All @@ -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,
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -35,28 +46,18 @@ public String logoutAllSessions(String username) {
throw new EntityNotFoundException("User not found");
}

// Iterate over all principals in the SessionRegistry
List<Object> 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<SessionInformation> 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<String, ? extends Session> 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.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down