diff --git a/build.gradle b/build.gradle index b49b03c0..7176fef2 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,8 @@ sourceSets { exclude "stirling/software/SPDF/model/SessionEntity.java" exclude "stirling/software/SPDF/model/User.java" exclude "stirling/software/SPDF/repository/**" + } else { + exclude "stirling/software/SPDF/config/anonymus/**" } if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") { diff --git a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index aa3fce4c..32b81a87 100644 --- a/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -1,28 +1,225 @@ package stirling.software.SPDF.config; +import java.security.Principal; +import java.util.Collection; +import java.util.List; + import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + @Component @Slf4j public class EndpointInterceptor implements HandlerInterceptor { private final EndpointConfiguration endpointConfiguration; + private final SessionsInterface sessionsInterface; - public EndpointInterceptor(EndpointConfiguration endpointConfiguration) { + public EndpointInterceptor( + EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) { this.endpointConfiguration = endpointConfiguration; + this.sessionsInterface = sessionsInterface; } @Override public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + HttpSession session = request.getSession(false); + if (session == null) { + session = request.getSession(true); + } String requestURI = request.getRequestURI(); + + boolean isApiRequest = requestURI.contains("/api/v1"); + + if ("GET".equalsIgnoreCase(request.getMethod()) && !isApiRequest) { + + Principal principal = request.getUserPrincipal(); + + // allowlist for public or static routes + if ("/login".equals(requestURI) + // || "/".equals(requestURI) + // || "/home".equals(requestURI) + // || "/home-legacy".equals(requestURI) + || "/userSession".equals(requestURI) + || requestURI.contains("/userSession/invalidate/") + || requestURI.contains("/js/") + || requestURI.contains("/css/") + || requestURI.contains("/fonts/") + || requestURI.contains("/images/") + || requestURI.contains("/favicon") + || requestURI.contains("/pdfjs-legacy/") + || requestURI.contains("/error") + || requestURI.contains("/session") + || requestURI.endsWith(".js") + || requestURI.endsWith(".png") + || requestURI.endsWith(".webmanifest") + || requestURI.contains("/files/")) { + return true; + } else if (principal != null) { + if (session == null) { + session = request.getSession(true); + } + + final HttpSession finalSession = session; + String sessionId = finalSession.getId(); + + boolean isExpiredByAdmin = + sessionsInterface.getAllSessions().stream() + .filter(s -> s.getSessionId().equals(finalSession.getId())) + .anyMatch(s -> s.isExpired()); + + if (isExpiredByAdmin + && !"/".equals(requestURI) + && !"/home".equals(requestURI) + && !"/home-legacy".equals(requestURI)) { + response.sendRedirect("/logout"); + log.info("Session expired. Logging out user {}", principal.getName()); + return false; + } else if (isExpiredByAdmin + && ("/".equals(requestURI) + || "/home".equals(requestURI) + || "/home-legacy".equals(requestURI))) { + log.info( + "Max sessions reached for this user. To continue on this device, please" + + " close your session in another browser. "); + response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED); + return false; + } + + int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions(); + + Collection allSessions = sessionsInterface.getAllSessions(); + + long totalSessionsNonExpired = + allSessions.stream().filter(s -> !s.isExpired()).count(); + + List activeSessions = + allSessions.stream() + .filter(s -> !s.isExpired()) + .sorted( + (s1, s2) -> + Long.compare( + s2.getLastRequest().getTime(), + s1.getLastRequest().getTime())) + .limit(maxApplicationSessions) + .toList(); + + boolean hasUserActiveSession = + // activeSessions.stream().anyMatch(s -> + // s.getSessionId().equals(sessionId)); + activeSessions.stream() + .anyMatch( + s -> + s.getSessionId().equals(sessionId) + // && !s.isExpired() + && s.getPrincipalName() + .equals(principal.getName())); + + final String currentPrincipal = principal.getName(); + + long userSessions = + sessionsInterface.getAllSessions().stream() + .filter( + s -> + !s.isExpired() + && currentPrincipal.equals( + s.getPrincipalName())) + .count(); + + int maxUserSessions = sessionsInterface.getMaxUserSessions(); + + log.info( + "Active sessions for {}: {} (max: {}) | Total: {} (max: {}) | Active" + + " sessions: {}", + currentPrincipal, + userSessions, + maxUserSessions, + totalSessionsNonExpired, + maxApplicationSessions, + hasUserActiveSession); + + if ((userSessions >= maxUserSessions + || totalSessionsNonExpired >= maxApplicationSessions) + && !hasUserActiveSession) { + log.info( + "Max sessions reached for this user. To continue on this device, please" + + " close your session in another browser."); + response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED); + return false; + } + + // If session is not registered yet, register it; otherwise, update the last request + // timestamp. + if (!hasUserActiveSession) { + log.info("Register session: {}", sessionId); + sessionsInterface.registerSession(finalSession); + } else { + log.info("Update session last request: {}", sessionId); + sessionsInterface.updateSessionLastRequest(sessionId); + } + return true; + } else if (principal == null) { + if (session == null) { + session = request.getSession(true); + } + final HttpSession finalSession = session; + String sessionId = finalSession.getId(); + + int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions(); + + Collection allSessions = sessionsInterface.getAllSessions(); + + long totalSessions = allSessions.stream().filter(s -> !s.isExpired()).count(); + + List activeSessions = + allSessions.stream() + .filter(s -> !s.isExpired()) + .sorted( + (s1, s2) -> + Long.compare( + s2.getLastRequest().getTime(), + s1.getLastRequest().getTime())) + .limit(maxApplicationSessions) + .toList(); + + boolean hasUserActiveSession = + activeSessions.stream().anyMatch(s -> s.getSessionId().equals(sessionId)); + + log.info( + "Active sessions for anonymous: Total: {} (max: {}) | Active sessions: {}", + totalSessions, + maxApplicationSessions, + hasUserActiveSession); + + if (totalSessions >= maxApplicationSessions && !hasUserActiveSession) { + sessionsInterface.removeSession(finalSession); + log.info( + "Max sessions reached for this user. To continue on this device, please" + + " close your session in another browser."); + response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED); + return false; + } + if (!hasUserActiveSession) { + log.debug("Register session: {}", sessionId); + sessionsInterface.registerSession(finalSession); + } else { + log.debug("Update session last request: {}", sessionId); + sessionsInterface.updateSessionLastRequest(sessionId); + } + return true; + } + } + boolean isEnabled; // Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages) diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java new file mode 100644 index 00000000..54beaa46 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionInfo.java @@ -0,0 +1,55 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.Date; + +import jakarta.servlet.http.HttpSession; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + +@Setter +@ToString(exclude = "session") // exclude session from toString to avoid verbose output or sensitive +// data +@AllArgsConstructor +public class AnonymusSessionInfo implements SessionsModelInterface { + private static final String principalName = "anonymousUser"; + private HttpSession session; + + @Setter(AccessLevel.NONE) + private final Date createdAt; + + private Date lastRequest; + private Boolean expired; + + public HttpSession getSession() { + return session; + } + + public Date getCreatedAt() { + return createdAt; + } + + @Override + public Date getLastRequest() { + return lastRequest; + } + + @Override + public boolean isExpired() { + return expired; + } + + @Override + public String getSessionId() { + return session.getId(); + } + + @Override + public String getPrincipalName() { + return principalName; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionListener.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionListener.java new file mode 100644 index 00000000..91e0c6cc --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionListener.java @@ -0,0 +1,217 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + +@Component +@Slf4j +public class AnonymusSessionListener implements HttpSessionListener, SessionsInterface { + + @Value("${server.servlet.session.timeout:30m}") + private Duration defaultMaxInactiveInterval; + + // Map for storing sessions including timestamp + private static final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void sessionCreated(HttpSessionEvent event) { + HttpSession session = event.getSession(); + if (session == null) { + return; + } + + if (sessions.containsKey(session.getId())) { + return; + } + + // Save creation timestamp + Date creationTime = new Date(); + + int allNonExpiredSessions = getAllNonExpiredSessions().size(); + + if (allNonExpiredSessions >= getMaxUserSessions()) { + sessions.put( + session.getId(), + new AnonymusSessionInfo(session, creationTime, creationTime, true)); + } else { + sessions.put( + session.getId(), + new AnonymusSessionInfo(session, creationTime, creationTime, false)); + } + } + + @Override + public void sessionDestroyed(HttpSessionEvent event) { + HttpSession session = event.getSession(); + if (session == null) { + return; + } + AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId()); + if (sessionsInfo == null) { + return; + } + + Date lastRequest = sessionsInfo.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant now = Instant.now(); + Instant expirationTime = + lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionsInfo.setExpired(true); + session.invalidate(); + log.debug("Session {} expired=TRUE", session.getId()); + } + } + + // Mark a single session as expired + public void expireSession(String sessionId) { + if (sessions.containsKey(sessionId)) { + AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId); + sessionInfo.setExpired(true); + try { + sessionInfo.getSession().invalidate(); + } catch (IllegalStateException e) { + log.debug("Session {} already invalidated", sessionInfo.getSession().getId()); + } + } + } + + // Expire first session sorted by last request time aufsteigend + public void expireFirstSession(String sessionId) { + sessions.values().stream() + .filter(info -> !info.isExpired()) + .filter(info -> !info.getSessionId().equals(sessionId)) + .sorted((s1, s2) -> s1.getLastRequest().compareTo(s2.getLastRequest())) + .findFirst() + .ifPresent( + session -> { + AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) session; + sessionInfo.setExpired(true); + try { + log.info( + "Session {} expired by first Session", + sessionInfo.getSession().getId()); + } catch (IllegalStateException e) { + log.info( + "Session {} already invalidated", + sessionInfo.getSession().getId()); + } + }); + } + + // Mark all sessions as expired + public void expireAllSessions() { + sessions.values() + .forEach( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + info.setExpired(true); + HttpSession session = info.getSession(); + try { + session.invalidate(); + } catch (IllegalStateException e) { + log.debug("Session {} already invalidated", session.getId()); + } + }); + } + + // Expire all sessions by username + public void expireAllSessionsByUsername(String username) { + sessions.values().stream() + .filter( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + return info.getPrincipalName().equals(username); + }) + .forEach( + sessionInfo -> { + AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo; + info.setExpired(true); + HttpSession session = info.getSession(); + try { + session.invalidate(); + } catch (IllegalStateException e) { + log.debug("Session {} already invalidated", session.getId()); + } + }); + } + + @Override + public void updateSessionLastRequest(String sessionId) { + if (sessions.containsKey(sessionId)) { + AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId); + sessionInfo.setLastRequest(new Date()); + } + } + + @Override + public Collection getAllSessions() { + return sessions.values().stream().toList(); + } + + @Override + public Collection getAllNonExpiredSessions() { + return sessions.values().stream().filter(info -> !info.isExpired()).toList(); + } + + public Collection getAllIsExpiredSessions() { + return sessions.values().stream().filter(SessionsModelInterface::isExpired).toList(); + } + + public void clear() { + sessions.clear(); + } + + @Override + public void registerSession(HttpSession session) { + if (!sessions.containsKey(session.getId())) { + AnonymusSessionInfo sessionInfo = + new AnonymusSessionInfo(session, new Date(), new Date(), false); + sessions.put(session.getId(), sessionInfo); + log.debug("Session {} registered", session.getId()); + } + } + + @Override + public void removeSession(HttpSession session) { + AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId()); + if (sessionsInfo != null) { + sessionsInfo.setExpired(true); + } + try { + session.invalidate(); + } catch (IllegalStateException e) { + log.debug("Session {} already invalidated", session.getId()); + } + sessions.remove(session.getId()); + } + + @Override + public int getMaxApplicationSessions() { + // return getMaxUserSessions(); + return Integer.MAX_VALUE; + } + + @Override + public int getMaxUsers() { + return 1; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java new file mode 100644 index 00000000..57e7c8f0 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionService.java @@ -0,0 +1,45 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class AnonymusSessionService { + + @Autowired private AnonymusSessionListener sessionRegistry; + + @Value("${server.servlet.session.timeout:30m}") + private Duration defaultMaxInactiveInterval; + + // Runs every minute to expire inactive sessions + @Scheduled(cron = "0 0/5 * * * ?") + public void expireSessions() { + Instant now = Instant.now(); + sessionRegistry.getAllSessions().stream() + .filter(session -> !session.isExpired()) + .forEach( + session -> { + Date lastRequest = session.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant expirationTime = + lastRequest + .toInstant() + .plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + log.debug("Session expiration triggered"); + sessionRegistry.expireSession(session.getSessionId()); + } + }); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java new file mode 100644 index 00000000..2ef42c01 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/anonymus/session/AnonymusSessionStatusController.java @@ -0,0 +1,56 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + +@Controller +@Slf4j +public class AnonymusSessionStatusController { + + @Autowired private AnonymusSessionListener sessionRegistry; + + @GetMapping("/userSession") + public String getUserSessions(HttpServletRequest request, Model model) { + HttpSession session = request.getSession(false); + if (session != null) { + + boolean isSessionValid = + sessionRegistry.getAllNonExpiredSessions().stream() + .allMatch( + sessionEntity -> + sessionEntity.getSessionId().equals(session.getId())); + + // Get all sessions for the user + List sessionList = + sessionRegistry.getAllNonExpiredSessions().stream() + .filter( + sessionEntity -> + !sessionEntity.getSessionId().equals(session.getId())) + .toList(); + + model.addAttribute("sessionList", sessionList); + return "userSession"; + } + return "redirect:/"; + } + + @GetMapping("/userSession/invalidate/{sessionId}") + public String invalidateUserSession( + HttpServletRequest request, @PathVariable String sessionId) { + sessionRegistry.expireSession(sessionId); + sessionRegistry.registerSession(request.getSession(false)); + return "redirect:/userSession"; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java new file mode 100644 index 00000000..7197e7ba --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsInterface.java @@ -0,0 +1,30 @@ +package stirling.software.SPDF.config.interfaces; + +import java.util.Collection; + +import jakarta.servlet.http.HttpSession; + +public interface SessionsInterface { + + void updateSessionLastRequest(String sessionId); + + Collection getAllSessions(); + + Collection getAllNonExpiredSessions(); + + void registerSession(HttpSession session); + + void removeSession(HttpSession session); + + default int getMaxUserSessions() { + return 3; + } + + default int getMaxApplicationSessions() { + return getMaxUserSessions() * 3; + } + + default int getMaxUsers() { + return 10; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java new file mode 100644 index 00000000..dfe84012 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/interfaces/SessionsModelInterface.java @@ -0,0 +1,14 @@ +package stirling.software.SPDF.config.interfaces; + +import java.util.Date; + +public interface SessionsModelInterface { + + String getSessionId(); + + String getPrincipalName(); + + Date getLastRequest(); + + boolean isExpired(); +} diff --git a/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java b/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java index c8a13322..7e44f7e2 100644 --- a/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java +++ b/src/main/java/stirling/software/SPDF/config/security/AppUpdateAuthService.java @@ -35,7 +35,7 @@ class AppUpdateAuthService implements ShowAdminInterface { if (authentication == null || !authentication.isAuthenticated()) { return !showUpdateOnlyAdmin; } - if (authentication.getName().equalsIgnoreCase("anonymousUser")) { + if ("anonymousUser".equalsIgnoreCase(authentication.getName())) { return !showUpdateOnlyAdmin; } Optional user = userRepository.findByUsername(authentication.getName()); diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index a4c10d1a..4f0b8537 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -36,6 +36,7 @@ import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationFailureHandler; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler; import stirling.software.SPDF.config.security.saml2.CustomSaml2ResponseAuthenticationConverter; +import stirling.software.SPDF.config.security.session.PreLogoutDataCaptureHandler; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.User; @@ -149,7 +150,7 @@ public class SecurityConfiguration { sessionManagement -> sessionManagement .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(10) + .maximumSessions(sessionRegistry.getMaxUserSessions()) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) .expiredUrl("/login?logout=true")); @@ -158,6 +159,8 @@ public class SecurityConfiguration { http.logout( logout -> logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .addLogoutHandler( + new PreLogoutDataCaptureHandler(sessionRegistry)) .logoutSuccessHandler( new CustomLogoutSuccessHandler(applicationProperties)) .clearAuthentication(true) diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 46467671..f9b36c72 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -11,7 +11,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -397,23 +396,10 @@ public class UserService implements UserServiceInterface { } public void invalidateUserSessions(String username) { - String usernameP = ""; - for (Object principal : sessionRegistry.getAllPrincipals()) { - for (SessionInformation sessionsInformation : - sessionRegistry.getAllSessions(principal, false)) { - if (principal instanceof UserDetails detailsUser) { - usernameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - usernameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - usernameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - usernameP = stringUser; - } - if (usernameP.equalsIgnoreCase(username)) { - sessionRegistry.expireSession(sessionsInformation.getSessionId()); - } + String usernameP = UserUtils.getUsernameFromPrincipal(principal); + if (usernameP.equalsIgnoreCase(username)) { + sessionRegistry.expireAllSessionsByPrincipalName(usernameP); } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/UserUtils.java b/src/main/java/stirling/software/SPDF/config/security/UserUtils.java new file mode 100644 index 00000000..a2c03ac1 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/UserUtils.java @@ -0,0 +1,22 @@ +package stirling.software.SPDF.config.security; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; + +public class UserUtils { + public static String getUsernameFromPrincipal(Object principal) { + if (principal instanceof UserDetails detailsUser) { + return detailsUser.getUsername(); + } else if (principal instanceof OAuth2User oAuth2User) { + return oAuth2User.getName(); + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { + return saml2User.name(); + } else if (principal instanceof String stringUser) { + return stringUser; + } else { + return null; + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 4ee49aed..1b4d84e5 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -5,7 +5,6 @@ import java.sql.SQLException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; @@ -17,6 +16,7 @@ import jakarta.servlet.http.HttpSession; import stirling.software.SPDF.config.security.LoginAttemptService; import stirling.software.SPDF.config.security.UserService; +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.AuthenticationType; @@ -45,13 +45,7 @@ public class CustomOAuth2AuthenticationSuccessHandler throws ServletException, IOException { Object principal = authentication.getPrincipal(); - String username = ""; - - if (principal instanceof OAuth2User oAuth2User) { - username = oAuth2User.getName(); - } else if (principal instanceof UserDetails detailsUser) { - username = detailsUser.getUsername(); - } + String username = UserUtils.getUsernameFromPrincipal(principal); // Get the saved request HttpSession session = request.getSession(false); diff --git a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java index 3d97181a..93c86160 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/CustomHttpSessionListener.java @@ -1,30 +1,276 @@ package stirling.software.SPDF.config.security.session; -import org.springframework.beans.factory.annotation.Autowired; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; +import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; +import stirling.software.SPDF.config.security.UserUtils; +import stirling.software.SPDF.model.ApplicationProperties; + @Component @Slf4j -public class CustomHttpSessionListener implements HttpSessionListener { +public class CustomHttpSessionListener implements HttpSessionListener, SessionsInterface { - private SessionPersistentRegistry sessionPersistentRegistry; + private final SessionPersistentRegistry sessionPersistentRegistry; + private final ApplicationProperties applicationProperties; + private final boolean loginEnabled; + private final boolean runningEE; - @Autowired - public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { + @Value("${server.servlet.session.timeout:30m}") + private Duration defaultMaxInactiveInterval; + + public CustomHttpSessionListener( + SessionPersistentRegistry sessionPersistentRegistry, + @Qualifier("loginEnabled") boolean loginEnabled, + @Qualifier("runningEE") boolean runningEE, + ApplicationProperties applicationProperties) { super(); this.sessionPersistentRegistry = sessionPersistentRegistry; + this.loginEnabled = loginEnabled; + this.runningEE = runningEE; + this.applicationProperties = applicationProperties; } @Override - public void sessionCreated(HttpSessionEvent se) {} + public Collection getAllNonExpiredSessions() { + return sessionPersistentRegistry.getAllSessionsNotExpired().stream() + .map(session -> (SessionsModelInterface) session) + .toList(); + } + + public List getAllSessions(Object principalName, boolean expired) { + return sessionPersistentRegistry.getAllSessions().stream() + .filter(s -> s.getPrincipalName().equals(principalName)) + .filter(s -> expired == s.isExpired()) + .sorted(Comparator.comparing(SessionsModelInterface::getLastRequest)) + .collect(Collectors.toList()); + } + + @Override + public Collection getAllSessions() { + return new ArrayList<>(sessionPersistentRegistry.getAllSessions()); + } + + @Override + public void updateSessionLastRequest(String sessionId) { + sessionPersistentRegistry.refreshLastRequest(sessionId); + } + + public Optional findLatestSession(String principalName) { + return getAllSessions(principalName, false).stream() + .filter(s -> s.getPrincipalName().equals(principalName)) + .max(Comparator.comparing(SessionsModelInterface::getLastRequest)); + } + + public void expireSession(String sessionId) { + sessionPersistentRegistry.expireSession(sessionId); + } + + public void expireSession(String sessionId, boolean expiredByAdmin) { + sessionPersistentRegistry.expireSession(sessionId, expiredByAdmin); + } + + public int getMaxInactiveInterval() { + return (int) defaultMaxInactiveInterval.getSeconds(); + } + + @Override + public void sessionCreated(HttpSessionEvent se) { + HttpSession session = se.getSession(); + if (session == null) { + return; + } + SecurityContext securityContext = SecurityContextHolder.getContext(); + if (securityContext == null) { + return; + } + Authentication authentication = securityContext.getAuthentication(); + if (authentication == null) { + return; + } + Object principal = authentication.getPrincipal(); + if (principal == null) { + return; + } + String principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName == null) { + return; + } + if ("anonymousUser".equals(principalName) && loginEnabled) { + return; + } + + int allNonExpiredSessions; + + if ("anonymousUser".equals(principalName) && !loginEnabled) { + allNonExpiredSessions = + (int) getAllSessions().stream().filter(s -> !s.isExpired()).count(); + } else { + allNonExpiredSessions = + (int) + getAllSessions().stream() + .filter(s -> !s.isExpired()) + .filter(s -> s.getPrincipalName().equals(principalName)) + .count(); + } + + int all = + getAllSessions().stream() + .filter(s -> !s.isExpired() && s.getPrincipalName().equals(principalName)) + .toList() + .size(); + boolean isAnonymousUserWithLogin = "anonymousUser".equals(principalName) && loginEnabled; + log.info( + "all {} allNonExpiredSessions {} {} isAnonymousUserWithLogin {}", + all, + allNonExpiredSessions, + getMaxUserSessions(), + isAnonymousUserWithLogin); + + if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithLogin) { + log.info("Session {} Expired=TRUE", session.getId()); + sessionPersistentRegistry.expireSession(session.getId()); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + // if (allNonExpiredSessions > getMaxUserSessions()) { + // enforceMaxSessionsForPrincipal(principalName); + // } + } else if (all >= getMaxUserSessions() && !isAnonymousUserWithLogin) { + enforceMaxSessionsForPrincipal(principalName); + log.info("Session {} Expired=TRUE", session.getId()); + } else if (isAnonymousUserWithLogin) { + sessionPersistentRegistry.expireSession(session.getId()); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + } else { + log.info("Session created: {}", session.getId()); + sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName); + } + } + + private void enforceMaxSessionsForPrincipal(String principalName) { + // Alle aktiven Sessions des Benutzers über das gemeinsame Interface abrufen + List userSessions = + getAllSessions().stream() + .filter(s -> !s.isExpired() && principalName.equals(s.getPrincipalName())) + .sorted(Comparator.comparing(SessionsModelInterface::getLastRequest)) + .collect(Collectors.toList()); + + int maxAllowed = getMaxUserSessions(); + if (userSessions.size() > maxAllowed) { + int sessionsToRemove = userSessions.size() - maxAllowed; + log.debug( + "User {} has {} active sessions, removing {} oldest session(s).", + principalName, + userSessions.size(), + sessionsToRemove); + for (int i = 0; i < sessionsToRemove; i++) { + SessionsModelInterface sessionModel = userSessions.get(i); + // Statt auf die HttpSession zuzugreifen, rufen wir die Registry-Methoden auf, + // die die Session anhand der Session-ID invalidieren und entfernen. + sessionPersistentRegistry.expireSession(sessionModel.getSessionId()); + sessionPersistentRegistry.removeSessionInformation(sessionModel.getSessionId()); + log.debug( + "Removed session {} for principal {}", + sessionModel.getSessionId(), + principalName); + } + } + } @Override public void sessionDestroyed(HttpSessionEvent se) { - sessionPersistentRegistry.expireSession(se.getSession().getId()); + HttpSession session = se.getSession(); + if (session == null) { + return; + } + SessionInformation sessionsInfo = + sessionPersistentRegistry.getSessionInformation(session.getId()); + if (sessionsInfo == null) { + return; + } + + Date lastRequest = sessionsInfo.getLastRequest(); + int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds(); + Instant now = Instant.now(); + Instant expirationTime = + lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionPersistentRegistry.expireSession(session.getId()); + session.invalidate(); + sessionPersistentRegistry.removeSessionInformation(se.getSession().getId()); + log.debug("Session {} expired=TRUE", session.getId()); + } + } + + @Override + public void registerSession(HttpSession session) { + sessionCreated(new HttpSessionEvent(session)); + } + + @Override + public void removeSession(HttpSession session) { + sessionPersistentRegistry.expireSession(session.getId()); + session.invalidate(); + sessionPersistentRegistry.removeSessionInformation(session.getId()); + log.debug("Session {} expired=TRUE", session.getId()); + } + + // Get the maximum number of application sessions + @Override + public int getMaxApplicationSessions() { + if (runningEE) { + return getMaxUsers() * getMaxUserSessions(); + } + return Integer.MAX_VALUE; + } + + // Get the maximum number of user sessions + @Override + public int getMaxUserSessions() { + if (loginEnabled) { + if (runningEE) { + return 3; + } + return Integer.MAX_VALUE; // (3) + } + return Integer.MAX_VALUE; // (10) + } + + // Get the maximum number of user sessions + @Override + public int getMaxUsers() { + if (loginEnabled) { + if (runningEE) { + int maxUsers = applicationProperties.getPremium().getMaxUsers(); + if (maxUsers > 0) { + return maxUsers; + } + } + return Integer.MAX_VALUE; // (50) + } + return Integer.MAX_VALUE; // (1) } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java new file mode 100644 index 00000000..7821927d --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/session/PreLogoutDataCaptureHandler.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.config.security.session; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@AllArgsConstructor +public class PreLogoutDataCaptureHandler implements LogoutHandler { + + private final SessionPersistentRegistry sessionPersistentRegistry; + + @Override + public void logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + + String sessionId = session.getId(); + if (sessionId == null) { + return; + } + + String path = request.getServletPath(); + if (path == null) { + return; + } + + // Only handle explicit logout requests + if (!"/logout".equals(path)) { + return; + } + + log.debug("Session ID: {} Principal: {}", sessionId, authentication.getPrincipal()); + + // Mark the session as expired and remove its record + sessionPersistentRegistry.expireSession(sessionId); + sessionPersistentRegistry.removeSessionInformation(sessionId); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java index 18b03716..f7d1dedc 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionPersistentRegistry.java @@ -1,30 +1,45 @@ package stirling.software.SPDF.config.security.session; import java.time.Duration; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import jakarta.transaction.Transactional; -import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.model.SessionEntity; @Component +@Slf4j public class SessionPersistentRegistry implements SessionRegistry { private final SessionRepository sessionRepository; + private final boolean runningEE; + private final boolean loginEnabled; @Value("${server.servlet.session.timeout:30m}") private Duration defaultMaxInactiveInterval; - public SessionPersistentRegistry(SessionRepository sessionRepository) { + public SessionPersistentRegistry( + SessionRepository sessionRepository, + @Qualifier("runningEE") boolean runningEE, + @Qualifier("loginEnabled") boolean loginEnabled) { + this.runningEE = runningEE; this.sessionRepository = sessionRepository; + this.loginEnabled = loginEnabled; } @Override @@ -41,17 +56,7 @@ public class SessionPersistentRegistry implements SessionRegistry { public List getAllSessions( Object principal, boolean includeExpiredSessions) { List sessionInformations = new ArrayList<>(); - String principalName = null; - - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; - } + String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName != null) { List sessionEntities = @@ -72,33 +77,28 @@ public class SessionPersistentRegistry implements SessionRegistry { @Override @Transactional public void registerNewSession(String sessionId, Object principal) { - String principalName = null; - - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; - } + String principalName = UserUtils.getUsernameFromPrincipal(principal); if (principalName != null) { - // Clear old sessions for the principal (unsure if needed) - // List existingSessions = - // sessionRepository.findByPrincipalName(principalName); - // for (SessionEntity session : existingSessions) { - // session.setExpired(true); - // sessionRepository.save(session); - // } - SessionEntity sessionEntity = new SessionEntity(); - sessionEntity.setSessionId(sessionId); + int sessionUserCount = getAllSessions(principalName, false).size(); + + SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId); + if (sessionEntity == null) { + sessionEntity = new SessionEntity(); + sessionEntity.setSessionId(sessionId); + log.info("Registering new session for principal: {}", principalName); + } sessionEntity.setPrincipalName(principalName); sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date - sessionEntity.setExpired(false); + + if (sessionUserCount >= getMaxUserSessions()) { + sessionEntity.setExpired(true); + } else { + sessionEntity.setExpired(false); + } sessionRepository.save(sessionEntity); + sessionRepository.flush(); } } @@ -106,16 +106,57 @@ public class SessionPersistentRegistry implements SessionRegistry { @Transactional public void removeSessionInformation(String sessionId) { sessionRepository.deleteById(sessionId); + sessionRepository.flush(); + } + + @Transactional + public void removeSessionInformationByPrincipalName(String principalName) { + sessionRepository.deleteByPrincipalName(principalName); + sessionRepository.flush(); } @Override @Transactional public void refreshLastRequest(String sessionId) { - Optional sessionEntityOpt = sessionRepository.findById(sessionId); - if (sessionEntityOpt.isPresent()) { - SessionEntity sessionEntity = sessionEntityOpt.get(); + SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId); + if (sessionEntity != null) { sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date sessionRepository.save(sessionEntity); + } else { + log.error("Session not found for session ID: {}", sessionId); + } + } + + @Transactional + public void expireOldestSessionForPrincipal(String principalName) { + // Alle Sessions des principalName abrufen + List sessionsForPrincipal = + sessionRepository.findByPrincipalName(principalName); + + // Nur die nicht abgelaufenen Sessions filtern + List nonExpiredSessions = + sessionsForPrincipal.stream() + .filter(session -> !session.isExpired()) + .collect(Collectors.toList()); + + if (nonExpiredSessions.isEmpty()) { + log.debug("No active sessions found for principal {}", principalName); + return; + } + + // Die Session mit dem ältesten lastRequest ermitteln + Optional oldestSessionOpt = + nonExpiredSessions.stream() + .min(Comparator.comparing(SessionEntity::getLastRequest)); + + if (oldestSessionOpt.isPresent()) { + SessionEntity oldestSession = oldestSessionOpt.get(); + expireSession(oldestSession.getSessionId()); + removeSessionInformation(oldestSession.getSessionId()); + log.debug( + "Oldest session {} for principal {} has been marked as expired", + oldestSession.getSessionId(), + principalName); } } @@ -149,6 +190,61 @@ public class SessionPersistentRegistry implements SessionRegistry { SessionEntity sessionEntity = sessionEntityOpt.get(); sessionEntity.setExpired(true); // Set expired to true sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionId); + } + } + + public void expireSession(String sessionId, boolean expiredByAdmin) { + Optional sessionEntityOpt = sessionRepository.findById(sessionId); + if (sessionEntityOpt.isPresent()) { + SessionEntity sessionEntity = sessionEntityOpt.get(); + sessionEntity.setExpired(true); // Set expired to true + sessionEntity.setAdminExpired(expiredByAdmin); + sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionId); + } + } + + // Mark all sessions as expired + public void expireAllSessions() { + List sessionEntities = sessionRepository.findAll(); + for (SessionEntity sessionEntity : sessionEntities) { + sessionEntity.setExpired(true); // Set expired to true + sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionEntity.getSessionId()); + } + } + + // Mark all sessions as expired by username + public void expireAllSessionsByUsername(String username) { + List sessionEntities = sessionRepository.findByPrincipalName(username); + for (SessionEntity sessionEntity : sessionEntities) { + sessionEntity.setExpired(true); // Set expired to true + sessionRepository.save(sessionEntity); + log.debug("Session expired: {}", sessionEntity.getSessionId()); + } + } + + // Mark all sessions as expired for a given principal name + public void expireAllSessionsByPrincipalName(String principalName) { + List sessionEntities = sessionRepository.findByPrincipalName(principalName); + log.debug("Session entities: {}", sessionEntities.size()); + for (SessionEntity sessionEntity : sessionEntities) { + log.debug( + "Session expired: {} {} {}", + sessionEntity.getPrincipalName(), + sessionEntity.isExpired(), + sessionEntity.getSessionId()); + sessionEntity.setExpired(true); // Set expired to true + removeSessionInformation(sessionEntity.getSessionId()); + } + + sessionEntities = sessionRepository.findByPrincipalName(principalName); + log.debug("Session entities: {}", sessionEntities.size()); + for (SessionEntity sessionEntity : sessionEntities) { + if (sessionEntity.getPrincipalName().equals(principalName)) { + log.debug("Session expired: {}", sessionEntity.getSessionId()); + } } } @@ -163,10 +259,19 @@ public class SessionPersistentRegistry implements SessionRegistry { } // Update session details by principal name - public void updateSessionByPrincipalName( - String principalName, boolean expired, Date lastRequest) { - sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); - } + // public void updateSessionByPrincipalName( + // String principalName, boolean expired, Date lastRequest) { + // sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); + // } + + // Update session details by session ID + // public void updateSessionBySessionId(String sessionId) { + // SessionEntity sessionEntity = getSessionEntity(sessionId); + // if (sessionEntity != null) { + // sessionEntity.setLastRequest(new Date()); + // sessionRepository.save(sessionEntity); + // } + // } // Find the latest session for a given principal name public Optional findLatestSession(String principalName) { @@ -178,15 +283,29 @@ public class SessionPersistentRegistry implements SessionRegistry { // Sort sessions by lastRequest in descending order Collections.sort( allSessions, - new Comparator() { - @Override - public int compare(SessionEntity s1, SessionEntity s2) { - // Sort by lastRequest in descending order - return s2.getLastRequest().compareTo(s1.getLastRequest()); - } - }); + (SessionEntity s1, SessionEntity s2) -> + s2.getLastRequest().compareTo(s1.getLastRequest())); // The first session in the list is the latest session for the given principal name return Optional.of(allSessions.get(0)); } + + // Get the maximum number of sessions + public int getMaxSessions() { + if (runningEE) { + return Integer.MAX_VALUE; + } + return getMaxUserSessions() * 10; + } + + // Get the maximum number of user sessions + public int getMaxUserSessions() { + if (loginEnabled) { + if (runningEE) { + return 3; + } + return Integer.MAX_VALUE; // (3) + } + return Integer.MAX_VALUE; // (10) + } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java index 8fa24e95..f42b3a80 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRegistryConfig.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.config.security.session; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.session.SessionRegistryImpl; @@ -14,7 +15,9 @@ public class SessionRegistryConfig { @Bean public SessionPersistentRegistry sessionPersistentRegistry( - SessionRepository sessionRepository) { - return new SessionPersistentRegistry(sessionRepository); + SessionRepository sessionRepository, + @Qualifier("runningEE") boolean runningEE, + @Qualifier("loginEnabled") boolean loginEnabled) { + return new SessionPersistentRegistry(sessionRepository, runningEE, loginEnabled); } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java index b7f0133f..160a0375 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionRepository.java @@ -21,10 +21,13 @@ public interface SessionRepository extends JpaRepository SessionEntity findBySessionId(String sessionId); + void deleteByPrincipalName(String principalName); + @Modifying @Transactional @Query( - "UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE s.principalName = :principalName") + "UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE" + + " s.principalName = :principalName") void saveByPrincipalName( @Param("expired") boolean expired, @Param("lastRequest") Date lastRequest, diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java index 9710316e..8f4ede8a 100644 --- a/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionScheduled.java @@ -5,23 +5,43 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; + @Component +@Slf4j public class SessionScheduled { private final SessionPersistentRegistry sessionPersistentRegistry; + private final boolean loginEnabledValue; - public SessionScheduled(SessionPersistentRegistry sessionPersistentRegistry) { + public SessionScheduled( + SessionPersistentRegistry sessionPersistentRegistry, + @Qualifier("loginEnabled") boolean loginEnabledValue) { this.sessionPersistentRegistry = sessionPersistentRegistry; + this.loginEnabledValue = loginEnabledValue; } @Scheduled(cron = "0 0/5 * * * ?") public void expireSessions() { Instant now = Instant.now(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); for (Object principal : sessionPersistentRegistry.getAllPrincipals()) { + if (principal == null) { + continue; + } else if (principal instanceof String stringPrincipal) { + // Expire anonymousUser sessions if login is enabled + if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) { + sessionPersistentRegistry.expireAllSessionsByPrincipalName(stringPrincipal); + continue; + } + } List sessionInformations = sessionPersistentRegistry.getAllSessions(principal, false); for (SessionInformation sessionInformation : sessionInformations) { @@ -31,6 +51,19 @@ public class SessionScheduled { lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); if (now.isAfter(expirationTime)) { sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); + sessionInformation.expireNow(); + + // Invalidate current authentication if expired session belongs to current user + if (authentication != null && principal.equals(authentication.getPrincipal())) { + authentication.setAuthenticated(false); + } + + SecurityContextHolder.clearContext(); + + log.debug( + "Session expired for principal: {} SessionID: {}", + principal, + sessionInformation.getSessionId()); } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java new file mode 100644 index 00000000..328fc9c9 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/session/SessionStatusController.java @@ -0,0 +1,136 @@ +package stirling.software.SPDF.config.anonymus.session; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.interfaces.SessionsInterface; +import stirling.software.SPDF.config.security.UserUtils; +import stirling.software.SPDF.config.security.session.CustomHttpSessionListener; +import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; + +@Controller +@Slf4j +public class SessionStatusController { + + @Qualifier("loginEnabled") + private boolean loginEnabled; + + @Autowired private SessionPersistentRegistry sessionPersistentRegistry; + @Autowired private SessionsInterface sessionInterface; + + @Autowired private CustomHttpSessionListener customHttpSessionListener; + + // list all sessions from authentication user, return String redirect userSession.html + @GetMapping("/userSession") + public String getUserSessions( + HttpServletRequest request, Model model, Authentication authentication) { + if ((authentication == null || !authentication.isAuthenticated()) && loginEnabled) { + return "redirect:/login"; + } + HttpSession session = request.getSession(false); + if (session != null) { + String principalName = null; + if (authentication != null && authentication.isAuthenticated()) { + Object principal = authentication.getPrincipal(); + principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName == null) { + return "redirect:/login"; + } + } else { + principalName = "anonymousUser"; + } + + boolean isSessionValid = + sessionPersistentRegistry.getAllSessions(principalName, false).stream() + .allMatch( + sessionEntity -> + sessionEntity.getSessionId().equals(session.getId())); + + if (isSessionValid) { + return "redirect:/"; + } + // Get all sessions for the user + List sessionList = + sessionPersistentRegistry.getAllSessions(principalName, false).stream() + .filter( + sessionEntity -> + !sessionEntity.getSessionId().equals(session.getId())) + .toList(); + + model.addAttribute("sessionList", sessionList); + return "userSession"; + } + return "redirect:/login"; + } + + @GetMapping("/userSession/invalidate/{sessionId}") + public String invalidateUserSession( + HttpServletRequest request, + Authentication authentication, + @PathVariable String sessionId) + throws ServletException { + if (authentication == null || !authentication.isAuthenticated()) { + return "redirect:/login"; + } + Object principal = authentication.getPrincipal(); + String principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName == null) { + return "redirect:/login"; + } + boolean isOwner = + sessionPersistentRegistry.getAllSessions(principalName, false).stream() + .anyMatch(session -> session.getSessionId().equals(sessionId)); + if (isOwner) { + customHttpSessionListener.expireSession(sessionId, false); + sessionPersistentRegistry.registerNewSession( + request.getRequestedSessionId().split(".node0")[0], principal); + // return "redirect:/userSession?messageType=sessionInvalidated" + return "redirect:/userSession"; + } else { + return "redirect:/login"; + } + } + + @GetMapping("/session/invalidate/{sessionId}") + public String invalidateSession( + HttpServletRequest request, + Authentication authentication, + @PathVariable String sessionId) { + // ist ROLE_ADMIN oder session inhaber + if (authentication == null || !authentication.isAuthenticated()) { + return "redirect:/login"; + } + Object principal = authentication.getPrincipal(); + String principalName = UserUtils.getUsernameFromPrincipal(principal); + if (principalName == null) { + return "redirect:/login"; + } + boolean isAdmin = + authentication.getAuthorities().stream() + .anyMatch(role -> "ROLE_ADMIN".equals(role.getAuthority())); + + boolean isOwner = + sessionPersistentRegistry.getAllSessions(principalName, false).stream() + .anyMatch(session -> session.getSessionId().equals(sessionId)); + if (isAdmin || isOwner) { + customHttpSessionListener.expireSession(sessionId, isAdmin); + return "redirect:/adminSettings?messageType=sessionInvalidated"; + } else { + return "redirect:/login"; + } + } +} diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index 06dc7665..34c72653 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -13,8 +13,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.session.SessionInformation; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -30,7 +28,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.UserService; -import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.SPDF.config.security.UserUtils; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.AuthenticationType; @@ -304,19 +302,11 @@ public class UserController { if (!enabled) { // Invalidate all sessions if the user is being disabled List principals = sessionRegistry.getAllPrincipals(); - String userNameP = ""; for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; - } + String userNameP = UserUtils.getUsernameFromPrincipal(principal); + if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { sessionRegistry.expireSession(sessionInfo.getSessionId()); diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 3f478abe..5a2edbca 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -29,8 +29,9 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; -import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; +import stirling.software.SPDF.config.security.session.CustomHttpSessionListener; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; @@ -53,26 +54,29 @@ public class AccountWebController { public static final String OAUTH_2_AUTHORIZATION = "/oauth2/authorization/"; private final ApplicationProperties applicationProperties; - private final SessionPersistentRegistry sessionPersistentRegistry; + private final CustomHttpSessionListener customHttpSessionListener; // Assuming you have a repository for user operations private final UserRepository userRepository; + private final boolean loginEnabledValue; private final boolean runningEE; public AccountWebController( ApplicationProperties applicationProperties, - SessionPersistentRegistry sessionPersistentRegistry, UserRepository userRepository, - @Qualifier("runningEE") boolean runningEE) { + @Qualifier("loginEnabled") boolean loginEnabledValue, + @Qualifier("runningEE") boolean runningEE, + CustomHttpSessionListener customHttpSessionListener) { this.applicationProperties = applicationProperties; - this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; + this.loginEnabledValue = loginEnabledValue; this.runningEE = runningEE; + this.customHttpSessionListener = customHttpSessionListener; } @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { // If the user is already authenticated, redirect them to the home page. - if (authentication != null && authentication.isAuthenticated()) { + if ((authentication != null && authentication.isAuthenticated()) || !loginEnabledValue) { return "redirect:/"; } @@ -150,6 +154,7 @@ public class AccountWebController { case "badCredentials" -> error = "login.invalid"; case "locked" -> error = "login.locked"; case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage"; + case "expiredSession" -> error = "expiredSessionMessage"; } model.addAttribute("error", error); @@ -210,14 +215,20 @@ public class AccountWebController { @GetMapping("/adminSettings") public String showAddUserForm( HttpServletRequest request, Model model, Authentication authentication) { + String currentSessionId = request.getSession().getId(); + List allUsers = userRepository.findAll(); Iterator iterator = allUsers.iterator(); Map roleDetails = Role.getAllRoleDetails(); // Map to store session information and user activity status Map userSessions = new HashMap<>(); Map userLastRequest = new HashMap<>(); + Map> userActiveSessions = new HashMap<>(); int activeUsers = 0; int disabledUsers = 0; + int maxSessions = customHttpSessionListener.getMaxApplicationSessions(); + int maxUserSessions = customHttpSessionListener.getMaxUserSessions(); + int sessionCount = customHttpSessionListener.getAllNonExpiredSessions().size(); while (iterator.hasNext()) { User user = iterator.next(); if (user != null) { @@ -230,13 +241,13 @@ public class AccountWebController { } } // Determine the user's session status and last request time - int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); + int maxInactiveInterval = customHttpSessionListener.getMaxInactiveInterval(); boolean hasActiveSession = false; - Date lastRequest = null; - Optional latestSession = - sessionPersistentRegistry.findLatestSession(user.getUsername()); + Date lastRequest; + Optional latestSession = + customHttpSessionListener.findLatestSession(user.getUsername()); if (latestSession.isPresent()) { - SessionEntity sessionEntity = latestSession.get(); + SessionEntity sessionEntity = (SessionEntity) latestSession.get(); Date lastAccessedTime = sessionEntity.getLastRequest(); Instant now = Instant.now(); // Calculate session expiration and update session status accordingly @@ -245,7 +256,7 @@ public class AccountWebController { .toInstant() .plus(maxInactiveInterval, ChronoUnit.SECONDS); if (now.isAfter(expirationTime)) { - sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); + customHttpSessionListener.expireSession(sessionEntity.getSessionId()); } else { hasActiveSession = !sessionEntity.isExpired(); } @@ -262,6 +273,9 @@ public class AccountWebController { if (!user.isEnabled()) { disabledUsers++; } + List sessionInformations = + customHttpSessionListener.getAllSessions(user.getUsername(), false); + userActiveSessions.put(user.getUsername(), sessionInformations); } } // Sort users by active status and last request date @@ -323,15 +337,21 @@ public class AccountWebController { } model.addAttribute("users", sortedUsers); - model.addAttribute("currentUsername", authentication.getName()); + model.addAttribute("currentSessionId", currentSessionId); + if (authentication != null) { + model.addAttribute("currentUsername", authentication.getName()); + } model.addAttribute("roleDetails", roleDetails); model.addAttribute("userSessions", userSessions); model.addAttribute("userLastRequest", userLastRequest); + model.addAttribute("userActiveSessions", userActiveSessions); model.addAttribute("totalUsers", allUsers.size()); model.addAttribute("activeUsers", activeUsers); model.addAttribute("disabledUsers", disabledUsers); - - model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers()); + model.addAttribute("maxSessions", maxSessions); + model.addAttribute("maxUserSessions", maxUserSessions); + model.addAttribute("sessionCount", sessionCount); + model.addAttribute("maxPaidUsers", customHttpSessionListener.getMaxUsers()); return "adminSettings"; } diff --git a/src/main/java/stirling/software/SPDF/model/SessionEntity.java b/src/main/java/stirling/software/SPDF/model/SessionEntity.java index bba7b33d..09f967c5 100644 --- a/src/main/java/stirling/software/SPDF/model/SessionEntity.java +++ b/src/main/java/stirling/software/SPDF/model/SessionEntity.java @@ -9,15 +9,36 @@ import jakarta.persistence.Table; import lombok.Data; +import stirling.software.SPDF.config.interfaces.SessionsModelInterface; + @Entity @Data @Table(name = "sessions") -public class SessionEntity implements Serializable { +public class SessionEntity implements Serializable, SessionsModelInterface { @Id private String sessionId; - private String principalName; - private Date lastRequest; - private boolean expired; + + private Boolean adminExpired = false; + + @Override + public String getSessionId() { + return sessionId; + } + + @Override + public String getPrincipalName() { + return principalName; + } + + @Override + public Date getLastRequest() { + return lastRequest; + } + + @Override + public boolean isExpired() { + return expired; + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9f58a93c..cccfd3b0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -40,4 +40,4 @@ springdoc.api-docs.path=/v1/api-docs # Set the URL of the OpenAPI JSON for the Swagger UI springdoc.swagger-ui.url=/v1/api-docs posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq -posthog.host=https://eu.i.posthog.com \ No newline at end of file +posthog.host=https://eu.i.posthog.com diff --git a/src/main/resources/messages_de_DE.properties b/src/main/resources/messages_de_DE.properties index dfe973fb..31eec223 100644 --- a/src/main/resources/messages_de_DE.properties +++ b/src/main/resources/messages_de_DE.properties @@ -176,6 +176,7 @@ settings.accountSettings=Kontoeinstellungen settings.bored.help=Aktiviert das Easter-Egg-Spiel settings.cacheInputs.name=Formulareingaben speichern settings.cacheInputs.help=Aktivieren, um zuvor verwendete Eingaben für zukünftige Durchläufe zu speichern +settings.userSessions=Benutzersitzungen changeCreds.title=Anmeldeinformationen ändern changeCreds.header=Aktualisieren Sie Ihre Kontodaten @@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Aktive Benutzer: adminUserSettings.disabledUsers=Deaktivierte Benutzer: adminUserSettings.totalUsers=Gesamtzahl der Benutzer: adminUserSettings.lastRequest=Letzte Anfrage +adminUserSettings.userSessions=Benutzersitzungen +adminUserSettings.totalSessions=Gesamtzahl der Sitzungen: adminUserSettings.usage=View Usage endpointStatistics.title=Endpoint Statistics @@ -285,6 +288,14 @@ database.notSupported=Diese Funktion ist für deine Datenbankverbindung nicht ve session.expired=Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut. session.refreshPage=Seite aktualisieren +################# +# USER SESSION # +################# +userSession.title=Benutzersitzungen +userSession.header=Benutzersitzungen +userSession.maxUserSession=Wenn die maximale Anzahl Sitzungen für diesen Benutzer erreicht ist, können Sie hier andere Anmeldungen beenden, um auf diesem Gerät fortzufahren. +userSession.lastRequest=Letzte Aufrufe + ############# # HOME-PAGE # ############# diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 5b7efbab..247eda73 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -176,6 +176,7 @@ settings.accountSettings=Account Settings settings.bored.help=Enables easter egg game settings.cacheInputs.name=Save form inputs settings.cacheInputs.help=Enable to store previously used inputs for future runs +settings.userSessions=User Sessions changeCreds.title=Change Credentials changeCreds.header=Update Your Account Details @@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Active Users: adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.totalUsers=Total Users: adminUserSettings.lastRequest=Last Request +adminUserSettings.userSessions=User sessions +adminUserSettings.totalSessions=Total Sessions: adminUserSettings.usage=View Usage endpointStatistics.title=Endpoint Statistics @@ -285,6 +288,14 @@ database.notSupported=This function is not available for your database connectio session.expired=Your session has expired. Please refresh the page and try again. session.refreshPage=Refresh Page +################# +# USER SESSION # +################# +userSession.title=User Sessions +userSession.header=User Sessions +userSession.maxUserSession=If the maximum number of sessions for this user is reached, you can end other logins here to continue on this device. +userSession.lastRequest=last Request + ############# # HOME-PAGE # ############# @@ -1426,7 +1437,7 @@ cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do. cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies cookieBanner.preferencesModal.necessary.title.2=Always Enabled -cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off. +cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off. cookieBanner.preferencesModal.analytics.title=Analytics cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with. diff --git a/src/main/resources/templates/adminSettings.html b/src/main/resources/templates/adminSettings.html index da4d16c1..8a6b4996 100644 --- a/src/main/resources/templates/adminSettings.html +++ b/src/main/resources/templates/adminSettings.html @@ -1,319 +1,409 @@ - - - - - + + + + + + + + +
+
+ +

+
+
+
+
+ manage_accounts + Admin User Control Settings +
+ + +
+ + person_add + Add New User + + + + edit + Change User's Role + + + + analytics + Usage Statistics + + +
+ runningEE + Non-Paid
- -
- - person_add - Add New User - - - - edit - Change User's Role - - - - analytics - Usage Statistics - +
+ Total Users: + + -
- Total Users: - - - - Active Users: - - - Disabled Users: - -
-
+ Active Users: + + + Disabled Users: + + + Total Sessions: + + + +
+
-
-
- Default message if not found -
+
+
+ Default message if not found
-
-
- Default message if not found -
+
+
+
+ Default message if not found
-
- Default message if not found -
-
- - + +
+ Default message if not found +
+
+
+ + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + - + + + + + + - -
#UsernameRolesAuthenticatedLast RequestUser SessionsActions
#UsernameRolesAuthenticatedLast RequestActions
-
- -
- edit +
-
+ +
+ + | + + 0 + + + + + edit + + +
- -
-
-

- - + + +
+ + + + + + + + + + + +
+ + + ⚠️ + + +
+ + +
+
Keine aktiven Sessions
+
+ + + + +
+

+ +
- - - - - - - - - - -
- + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index 00f22c27..daf9029a 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -14,6 +14,9 @@

+

+ Max sessions reached for this user. +


@@ -21,7 +24,7 @@
- +
diff --git a/src/main/resources/templates/fragments/navbar.html b/src/main/resources/templates/fragments/navbar.html index 593d8c21..e169b64a 100644 --- a/src/main/resources/templates/fragments/navbar.html +++ b/src/main/resources/templates/fragments/navbar.html @@ -259,6 +259,8 @@ diff --git a/src/main/resources/templates/userSession.html b/src/main/resources/templates/userSession.html new file mode 100644 index 00000000..fa02014c --- /dev/null +++ b/src/main/resources/templates/userSession.html @@ -0,0 +1,52 @@ + + + + + + + + +
+
+ +

+
+
+
+
+ key + User Session +
+
+ Max sessions reached for this user. + + + + + + + + + + + + + + + +
last Request
+ logout + +
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/testing/test.sh b/testing/test.sh index 5c4455dc..d9073766 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -43,7 +43,7 @@ check_health() { capture_file_list() { local container_name=$1 local output_file=$2 - + echo "Capturing file list from $container_name..." # Get all files in one command, output directly from Docker to avoid path issues # Skip proc, sys, dev, and the specified LibreOffice config directory @@ -60,12 +60,12 @@ capture_file_list() { -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ 2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" - + # Check if the output file has content if [ ! -s "$output_file" ]; then echo "WARNING: Failed to capture file list or container returned empty list" echo "Trying alternative approach..." - + # Alternative simpler approach - just get paths as a fallback docker exec $container_name sh -c "find / -type f \ -not -path '*/proc/*' \ @@ -79,14 +79,14 @@ capture_file_list() { -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ 2>/dev/null | sort" > "$output_file" - + if [ ! -s "$output_file" ]; then echo "ERROR: All attempts to capture file list failed" # Create a dummy entry to prevent diff errors echo "NO_FILES_FOUND 0 0" > "$output_file" fi fi - + echo "File list captured to $output_file" } @@ -96,24 +96,24 @@ compare_file_lists() { local after_file=$2 local diff_file=$3 local container_name=$4 # Added container_name parameter - + echo "Comparing file lists..." - + # Check if files exist and have content if [ ! -s "$before_file" ] || [ ! -s "$after_file" ]; then echo "WARNING: One or both file lists are empty." - + if [ ! -s "$before_file" ]; then echo "Before file is empty: $before_file" fi - + if [ ! -s "$after_file" ]; then echo "After file is empty: $after_file" fi - + # Create empty diff file > "$diff_file" - + # Check if we at least have the after file to look for temp files if [ -s "$after_file" ]; then echo "Checking for temp files in the after snapshot..." @@ -128,23 +128,23 @@ compare_file_lists() { echo "No temporary files found in the after snapshot." fi fi - + return 0 fi - + # Both files exist and have content, proceed with diff diff "$before_file" "$after_file" > "$diff_file" - + if [ -s "$diff_file" ]; then echo "Detected changes in files:" cat "$diff_file" - + # Extract only added files (lines starting with ">") grep "^>" "$diff_file" > "${diff_file}.added" || true if [ -s "${diff_file}.added" ]; then echo "New files created during test:" cat "${diff_file}.added" | sed 's/^> //' - + # Check for tmp files grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true if [ -s "${diff_file}.tmp" ]; then @@ -155,7 +155,7 @@ compare_file_lists() { return 1 fi fi - + # Extract only removed files (lines starting with "<") grep "^<" "$diff_file" > "${diff_file}.removed" || true if [ -s "${diff_file}.removed" ]; then @@ -165,7 +165,7 @@ compare_file_lists() { else echo "No file changes detected during test." fi - + return 0 } @@ -212,8 +212,8 @@ main() { cd "$PROJECT_ROOT" - export DOCKER_CLI_EXPERIMENTAL=enabled - export COMPOSE_DOCKER_CLI_BUILD=0 + export DOCKER_CLI_EXPERIMENTAL=enabled + export COMPOSE_DOCKER_CLI_BUILD=0 export DOCKER_ENABLE_SECURITY=false # Run the gradlew build command and check if it fails if ! ./gradlew clean build; then @@ -282,27 +282,27 @@ main() { # Create directory for file snapshots if it doesn't exist SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots" mkdir -p "$SNAPSHOT_DIR" - + # Capture file list before running behave tests BEFORE_FILE="$SNAPSHOT_DIR/files_before_behave.txt" AFTER_FILE="$SNAPSHOT_DIR/files_after_behave.txt" DIFF_FILE="$SNAPSHOT_DIR/files_diff.txt" - + # Define container name variable for consistency CONTAINER_NAME="Stirling-PDF-Security-Fat-with-login" - + capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE" - + cd "testing/cucumber" if python -m behave; then # Wait 10 seconds before capturing the file list after tests echo "Waiting 5 seconds for any file operations to complete..." sleep 5 - + # Capture file list after running behave tests cd "$PROJECT_ROOT" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" - + # Compare file lists if compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"; then echo "No unexpected temporary files found." @@ -311,19 +311,19 @@ main() { echo "WARNING: Unexpected temporary files detected after behave tests!" failed_tests+=("Stirling-PDF-Regression-Temp-Files") fi - + passed_tests+=("Stirling-PDF-Regression") else failed_tests+=("Stirling-PDF-Regression") echo "Printing docker logs of failed regression" docker logs "$CONTAINER_NAME" echo "Printed docker logs of failed regression" - + # Still capture file list after failure for analysis # Wait 10 seconds before capturing the file list echo "Waiting 5 seconds before capturing file list..." sleep 10 - + cd "$PROJECT_ROOT" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME" @@ -372,4 +372,4 @@ main() { fi } -main \ No newline at end of file +main diff --git a/testing/test_webpages.sh b/testing/test_webpages.sh index 2091995a..99834c2e 100644 --- a/testing/test_webpages.sh +++ b/testing/test_webpages.sh @@ -9,7 +9,7 @@ check_webpage() { local result_file="$3" # Use curl to fetch the page with timeout - response=$(curl -s -w "\n%{http_code}" --max-time $timeout "$full_url") + response=$(curl -b cookies.txt -s -w "\n%{http_code}" --max-time $timeout "$full_url") if [ $? -ne 0 ]; then echo "FAILED - Connection error or timeout $full_url" >> "$result_file" return 1 @@ -75,13 +75,13 @@ test_all_urls() { ((total_count++)) ((url_index++)) - + # Run the check in background test_url "$url" "$base_url" "$tmp_dir" "$url_index" & - + # Track the job ((active_jobs++)) - + # If we've reached max_parallel, wait for a job to finish if [ $active_jobs -ge $max_parallel ]; then wait -n # Wait for any child process to exit @@ -97,7 +97,7 @@ test_all_urls() { if [ -f "${tmp_dir}/result_${i}.txt" ]; then cat "${tmp_dir}/result_${i}.txt" fi - + if [ -f "${tmp_dir}/failed_${i}" ]; then failed_count=$((failed_count + $(cat "${tmp_dir}/failed_${i}"))) fi @@ -105,6 +105,7 @@ test_all_urls() { # Clean up rm -rf "$tmp_dir" + rm -f cookies.txt local end_time=$(date +%s) local duration=$((end_time - start_time)) @@ -158,6 +159,8 @@ main() { exit 1 fi + curl -s -c cookies.txt -o /dev/null $base_url/ + # Run tests using the URL list if test_all_urls "$url_file" "$base_url" "$max_parallel"; then echo "All webpage tests passed!" @@ -171,4 +174,4 @@ main() { # Run main if script is executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" -fi \ No newline at end of file +fi