Merge fa8df329dfcd0299d533fd6dd4e92a52975a6564 into 3fda82e39d6a6922143394c2b4a9d758496aff4d

This commit is contained in:
Ludy 2025-04-16 18:46:49 +01:00 committed by GitHub
commit 1d68ec287a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1869 additions and 454 deletions

View File

@ -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") {

View File

@ -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<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
long totalSessionsNonExpired =
allSessions.stream().filter(s -> !s.isExpired()).count();
List<SessionsModelInterface> 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<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
long totalSessions = allSessions.stream().filter(s -> !s.isExpired()).count();
List<SessionsModelInterface> 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)

View File

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

View File

@ -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<String, SessionsModelInterface> 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<SessionsModelInterface> getAllSessions() {
return sessions.values().stream().toList();
}
@Override
public Collection<SessionsModelInterface> getAllNonExpiredSessions() {
return sessions.values().stream().filter(info -> !info.isExpired()).toList();
}
public Collection<SessionsModelInterface> 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;
}
}

View File

@ -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());
}
});
}
}

View File

@ -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<SessionsModelInterface> 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";
}
}

View File

@ -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<SessionsModelInterface> getAllSessions();
Collection<SessionsModelInterface> 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;
}
}

View File

@ -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();
}

View File

@ -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> user = userRepository.findByUsername(authentication.getName());

View File

@ -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)

View File

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

View File

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

View File

@ -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);

View File

@ -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<SessionsModelInterface> getAllNonExpiredSessions() {
return sessionPersistentRegistry.getAllSessionsNotExpired().stream()
.map(session -> (SessionsModelInterface) session)
.toList();
}
public List<SessionsModelInterface> 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<SessionsModelInterface> getAllSessions() {
return new ArrayList<>(sessionPersistentRegistry.getAllSessions());
}
@Override
public void updateSessionLastRequest(String sessionId) {
sessionPersistentRegistry.refreshLastRequest(sessionId);
}
public Optional<SessionsModelInterface> 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<SessionsModelInterface> 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)
}
}

View File

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

View File

@ -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<SessionInformation> getAllSessions(
Object principal, boolean includeExpiredSessions) {
List<SessionInformation> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> sessionsForPrincipal =
sessionRepository.findByPrincipalName(principalName);
// Nur die nicht abgelaufenen Sessions filtern
List<SessionEntity> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> 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<SessionEntity> findLatestSession(String principalName) {
@ -178,15 +283,29 @@ public class SessionPersistentRegistry implements SessionRegistry {
// Sort sessions by lastRequest in descending order
Collections.sort(
allSessions,
new Comparator<SessionEntity>() {
@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)
}
}

View File

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

View File

@ -21,10 +21,13 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
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,

View File

@ -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<SessionInformation> 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());
}
}
}

View File

@ -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<SessionInformation> 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";
}
}
}

View File

@ -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<Object> principals = sessionRegistry.getAllPrincipals();
String userNameP = "";
for (Object principal : principals) {
List<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;
}
String userNameP = UserUtils.getUsernameFromPrincipal(principal);
if (userNameP.equalsIgnoreCase(username)) {
for (SessionInformation sessionInfo : sessionsInformation) {
sessionRegistry.expireSession(sessionInfo.getSessionId());

View File

@ -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<User> allUsers = userRepository.findAll();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Map to store session information and user activity status
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
Map<String, List<SessionsModelInterface>> 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<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername());
Date lastRequest;
Optional<SessionsModelInterface> 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<SessionsModelInterface> 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";
}

View File

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

View File

@ -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
posthog.host=https://eu.i.posthog.com

View File

@ -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 #
#############

View File

@ -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 cant 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 cant 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.

View File

@ -1,319 +1,409 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
<style>
.active-user {
color: green;
text-shadow: 0 0 5px green;
}
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow:ellipsis;
}
</style>
</head>
<head>
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}">
</th:block>
<style>
.active-user {
color: green;
text-shadow: 0 0 5px green;
}
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-12 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
</div>
<!-- User Settings Title -->
<div
style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#" th:data-bs-toggle="${totalUsers >= maxPaidUsers} ? null : 'modal'"
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
th:class="${totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
th:title="${totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
</a>
<a href="#" data-bs-toggle="modal" data-bs-target="#changeUserRoleModal" class="btn btn-outline-success"
th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<a href="/usage" th:if="${@runningEE}" class="btn btn-outline-success" th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a>
<div class="my-4">
<strong th:if="${@runningEE}" style="margin-left: 20px;"
text="#{adminUserSettings.totalUsers}">runningEE</strong>
<strong th:if="${!@runningEE}" style="margin-left: 20px;"
text="#{adminUserSettings.totalUsers}">Non-Paid</strong>
</div>
<!-- User Settings Title -->
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#"
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
</a>
<a href="#"
data-bs-toggle="modal"
data-bs-target="#changeUserRoleModal"
class="btn btn-outline-success"
th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<a href="/usage" th:if="${@runningEE}"
class="btn btn-outline-success"
th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a>
<div class="my-4">
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningProOrHigher}" th:text="' | ' + ${maxPaidUsers}"></span>
<div class="my-4">
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
<span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
</div>
</div>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
<span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
<th:block>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalSessions}">Total Sessions:</strong>
<span th:if="${@runningProOrHigher}" th:text="${sessionCount}"></span>
<span th:text="' | ' + ${maxSessions}"></span>
</th:block>
</div>
</div>
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto">
<span th:text="#{${addMessage}}">Default message if not found</span>
</div>
<div th:if="${addMessage}" class="p-3"
style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto">
<span th:text="#{${addMessage}}">Default message if not found</span>
</div>
<div th:if="${changeMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div>
</div>
<div th:if="${changeMessage}" class="p-3"
style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div>
<div th:if="${deleteMessage}" class="alert alert-danger">
<span th:text="#{${deleteMessage}}">Default message if not found</span>
</div>
<div class="bg-card mt-3 mb-3 table-responsive">
<table class="table table-striped table-hover">
<thead>
</div>
<div th:if="${deleteMessage}" class="alert alert-danger">
<span th:text="#{${deleteMessage}}">Default message if not found</span>
</div>
<div class="bg-card mt-3 mb-3 table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow"
th:text="#{adminUserSettings.authenticated}">Authenticated</th>
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow"
th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th scope="col" class="text-center" th:title="#{adminUserSettings.userSessions}"
th:text="#{adminUserSettings.userSessions}">User Sessions</th>
<th scope="col" class="text-center" th:title="#{adminUserSettings.actions}"
th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
<!-- <th scope="col"></th> -->
</tr>
</thead>
<tbody>
<th:block th:each="user, iterStat : ${users}">
<tr>
<th scope="col">#</th>
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th>
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
<!-- <th scope="col"></th> -->
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<th scope="row" style="align-content: center;" th:text="${user.id}"></th>
<td style="align-content: center;" th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<td style="align-content: center;" th:text="#{${user.roleName}}"></td>
<td style="align-content: center;" th:text="${user.authenticationType}"></td>
<td style="align-content: center;" th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
<td style="align-content: center;">
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()">
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span class="material-symbols-rounded">person_remove</span></button>
</form>
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="btn btn-outline-success btn-sm"><span class="material-symbols-rounded">edit</span></a>
<th scope="row" th:text="${user.id}"></th>
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<td th:text="#{${user.roleName}}"></td>
<td th:text="${user.authenticationType}"></td>
<td
th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}">
</td>
<td style="align-content: center;">
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()">
<th:block th:if="${@runningProOrHigher}">
<td class="text-center">
<button th:if="${@enableAlphaFunctionality}" type="button"
th:text="${#lists.size(userActiveSessions[user.username])}" th:data-bs-toggle="'collapse'"
th:data-bs-target="'#sessions__' + ${iterStat.index}"
th:aria-controls="'sessions__' + ${iterStat.index}"
th:class="${#lists.isEmpty(userActiveSessions[user.username])} ? 'btn btn-sm btn-outline-secondary disabled' : 'btn btn-sm btn-outline-secondary'"
th:aria-disabled="${#lists.isEmpty(userActiveSessions[user.username])} ? 'true' : 'false'">
0
</button>
<span th:if="${!@enableAlphaFunctionality}"
th:text="${#lists.size(userActiveSessions[user.username])}"></span> |
<span th:text="${maxUserSessions}"></span>
</td>
</th:block>
<th:block th:if="${!@runningProOrHigher}">
<td class="text-center" th:text="${#lists.size(userActiveSessions[user.username])}">0</td>
</th:block>
<td class="text-center">
<form th:if="${user.username != currentUsername}"
th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post"
onsubmit="return confirmDeleteUser()">
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm">
<span class="material-symbols-rounded">person_remove</span>
</button>
</form>
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}"
th:href="@{'/account'}" class="btn btn-outline-success btn-sm">
<span class="material-symbols-rounded">edit</span>
</a>
</td>
<td>
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post"
onsubmit="return confirmChangeUserStatus()">
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="btn btn-success btn-sm">
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}"
class="btn btn-success btn-sm">
<span class="material-symbols-rounded">person</span>
</button>
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="btn btn-danger btn-sm">
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}"
class="btn btn-danger btn-sm">
<span class="material-symbols-rounded">person_off</span>
</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
<script th:inline="javascript">
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
function confirmDeleteUser() {
return confirm(delete_confirm_text);
}
function confirmChangeUserStatus() {
return confirm(change_confirm_text);
}
</script>
<tr th:if="${@enableAlphaFunctionality}">
<td colspan="8" class="p-0 border-0">
<div th:id="'sessions__' + ${iterStat.index}" class="collapse">
<table class="table table-striped table-hover table-sm mb-0">
<tbody>
<tr th:each="s : ${userActiveSessions[user.username]}">
<td scope="row" colspan="4">
<span th:text="${s.sessionId}"></span>
<span th:if="${s.sessionId == currentSessionId}" class="text-warning ms-2"
title="Aktuelle Sitzung">
⚠️
</span>
</td>
<td colspan="2" th:text="${#dates.format(s.lastRequest, 'yyyy-MM-dd HH:mm:ss')}"></td>
<td colspan="2">
<form th:action="@{'/session/invalidate/' + ${s.sessionId}}" method="get"
onsubmit="return confirm('Session wirklich beenden?')">
<input type="hidden" name="_method" value="DELETE" />
<button class="btn btn-danger btn-sm"><span
class="material-symbols-rounded">remove_circle_outline</span></button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(userActiveSessions[user.username])}">
<td colspan="3" class="text-center text-muted">Keine aktiven Sessions</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</th:block>
</tbody>
</table>
</div>
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
<script th:inline="javascript">
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
function confirmDeleteUser() {
return confirm(delete_confirm_text);
}
function confirmChangeUserStatus() {
return confirm(change_confirm_text);
}
</script>
</div>
</div>
</div>
<!-- change User role Modal start -->
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<select name="username" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
</select>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
</select>
</div>
<!-- Add other fields as required -->
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<!-- change User role Modal end -->
<!-- Add User Modal start -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<input type="text" class="form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
</div>
<div class="mb-3" id="passwordContainer">
<label for="password" th:text="#{password}">Password</label>
<input type="password" class="form-control" name="password" id="password" required>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" id="role" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
</select>
</div>
<div class="mb-3">
<label for="authType">Authentication Type</label>
<select id="authType" name="authType" class="form-control" required>
<option value="web" selected>WEB</option>
<option value="sso">SSO</option>
</select>
</div>
<div class="form-check mb-3" id="checkboxContainer">
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
</div>
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<!-- Add User Modal end -->
<script th:inline="javascript">
jQuery.validator.addMethod("usernamePattern", function(value, element) {
// Regular expression for user name: Min. 3 characters, max. 50 characters
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
// Check if the field is optional or meets the requirements
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
$(document).ready(function() {
$('[data-toggle="tooltip"]').tooltip();
$('#formsaveuser').validate({
rules: {
username: {
required: true,
usernamePattern: true
},
password: {
required: true
},
role: {
required: true
},
authType: {
required: true
}
},
messages: {
username: {
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
},
},
errorPlacement: function(error, element) {
if (element.attr("name") === "username") {
$("#usernameError").text(error.text()).show();
} else if (element.attr("name") !== "role" && element.attr("name") !== "authType") {
error.insertAfter(element);
}
},
success: function(label, element) {
if ($(element).attr("name") === "username") {
$("#usernameError").hide();
}
}
});
$('#username').on('input', function() {
var usernameInput = $(this);
var isValid = usernameInput[0].checkValidity();
var errorSpan = $('#usernameError');
if (isValid) {
usernameInput.removeClass('invalid').addClass('valid');
errorSpan.hide();
} else {
usernameInput.removeClass('valid').addClass('invalid');
errorSpan.show();
}
});
$('#authType').on('change', function() {
var authType = $(this).val();
var passwordField = $('#password');
var passwordFieldContainer = $('#passwordContainer');
var checkboxContainer = $('#checkboxContainer');
if (authType === 'sso') {
passwordField.removeAttr('required');
passwordField.prop('disabled', true).val('');
passwordFieldContainer.slideUp('fast');
checkboxContainer.slideUp('fast');
} else {
passwordField.prop('disabled', false);
passwordField.attr('required', 'required');
passwordFieldContainer.slideDown('fast');
checkboxContainer.slideDown('fast');
}
});
});
</script>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
<!-- change User role Modal start -->
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto"
th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<select name="username" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}"
th:text="${user.username}">Username</option>
</select>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">
Role</option>
</select>
</div>
<!-- Add other fields as required -->
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<!-- change User role Modal end -->
<!-- Add User Modal start -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto"
th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
<div class="mb-3">
<label for="username" th:text="#{username}">Username</label>
<input type="text" class="form-control" name="username" id="username"
th:title="#{adminUserSettings.usernameInfo}" required>
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid
username!</span>
</div>
<div class="mb-3" id="passwordContainer">
<label for="password" th:text="#{password}">Password</label>
<input type="password" class="form-control" name="password" id="password" required>
</div>
<div class="mb-3">
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
<select name="role" class="form-control" id="role" required>
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">
Role</option>
</select>
</div>
<div class="mb-3">
<label for="authType">Authentication Type</label>
<select id="authType" name="authType" class="form-control" required>
<option value="web" selected>WEB</option>
<option value="sso">SSO</option>
</select>
</div>
<div class="form-check mb-3" id="checkboxContainer">
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user
to change username/password on login</label>
</div>
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
</form>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<!-- Add User Modal end -->
<script th:inline="javascript">
jQuery.validator.addMethod("usernamePattern", function (value, element) {
// Regular expression for user name: Min. 3 characters, max. 50 characters
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
// Check if the field is optional or meets the requirements
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
$(document).ready(function () {
$('[data-toggle="tooltip"]').tooltip();
$('#formsaveuser').validate({
rules: {
username: {
required: true,
usernamePattern: true
},
password: {
required: true
},
role: {
required: true
},
authType: {
required: true
}
},
messages: {
username: {
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
},
},
errorPlacement: function (error, element) {
if (element.attr("name") === "username") {
$("#usernameError").text(error.text()).show();
} else if (element.attr("name") !== "role" && element.attr("name") !== "authType") {
error.insertAfter(element);
}
},
success: function (label, element) {
if ($(element).attr("name") === "username") {
$("#usernameError").hide();
}
}
});
$('#username').on('input', function () {
var usernameInput = $(this);
var isValid = usernameInput[0].checkValidity();
var errorSpan = $('#usernameError');
if (isValid) {
usernameInput.removeClass('invalid').addClass('valid');
errorSpan.hide();
} else {
usernameInput.removeClass('valid').addClass('invalid');
errorSpan.show();
}
});
$('#authType').on('change', function () {
var authType = $(this).val();
var passwordField = $('#password');
var passwordFieldContainer = $('#passwordContainer');
var checkboxContainer = $('#checkboxContainer');
if (authType === 'sso') {
passwordField.removeAttr('required');
passwordField.prop('disabled', true).val('');
passwordFieldContainer.slideUp('fast');
checkboxContainer.slideUp('fast');
} else {
passwordField.prop('disabled', false);
passwordField.attr('required', 'required');
passwordFieldContainer.slideDown('fast');
checkboxContainer.slideDown('fast');
}
});
});
</script>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@ -14,6 +14,9 @@
<h1 class="display-2" th:text="#{oops}"></h1>
<p class="lead" th:if="${param.status == '404'}" th:text="#{error.404.1}"></p>
<p class="lead" th:unless="${param.status == '404'}" th:text="#{error.404.2}"></p>
<p class="lead" th:if="${status == 417}">
<a th:href="@{'/userSession'}" th:text="#{userSession.maxUserSession}">Max sessions reached for this user.</a>
</p>
<br>
<h2 th:text="#{error.needHelp}"></h2>
<p th:text="#{error.contactTip}"></p>
@ -21,7 +24,7 @@
<a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" id="github-button" class="btn btn-primary" target="_blank" th:text="#{error.github}"></a>
<a href="https://discord.gg/HYmhKj45pU" id="discord-button" class="btn btn-primary" target="_blank" th:text="#{joinDiscord}"></a>
</div>
<a th:href="@{'/'}" id="home-button" class="home-button btn btn-primary" th:text="#{goHomepage}"></a>
<a th:href="@{'/'}" id="home-button" class="home-button btn btn-primary" th:text="#{goHomepage}"></a>
</div>
</div>
</div>

View File

@ -259,6 +259,8 @@
<div class="modal-footer">
<a th:if="${@loginEnabled and @activeSecurity}" class="btn btn-danger" role="button"
th:text="#{settings.signOut}" th:href="@{'/logout'}">Sign Out</a>
<a class="btn btn-danger" role="button"
th:text="#{settings.userSessions}" th:href="@{'/userSession'}">Sign Out</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}"></button>
</div>
</div>

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{userSession.title}, header=#{userSession.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">key</span>
<span class="tool-header-text" th:text="#{userSession.title}">User Session</span>
</div>
<div class="bg-card mt-3 mb-3">
<span th:text="#{userSession.maxUserSession}">Max sessions reached for this user.</span>
<table th:unless="${#lists.isEmpty(sessionList)}" class="table table-striped table-hover mb-0">
<thead>
<tr>
<!-- <th scope="col" th:text="#{session.sessionId}">Session ID</th> -->
<th scope="col" th:text="#{userSession.lastRequest}">last Request</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr th:each="userSession : ${sessionList}">
<!-- <td th:text="${userSession.getSessionId}"></td> -->
<td th:text="${userSession.getLastRequest}"></td>
<td><a th:href="@{/userSession/invalidate/{id}(id=${userSession.getSessionId})}"
class="btn btn-sm btn-danger">
<span class="material-symbols-rounded">logout</span>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@ -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
main

View File

@ -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
fi