Merge fa8df329dfcd0299d533fd6dd4e92a52975a6564 into 6906344178f102e5aa5273a10fe6a92db1389761

This commit is contained in:
Ludy 2025-04-16 07:02:30 +02:00 committed by GitHub
commit 1e1a78cded
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/SessionEntity.java"
exclude "stirling/software/SPDF/model/User.java" exclude "stirling/software/SPDF/model/User.java"
exclude "stirling/software/SPDF/repository/**" exclude "stirling/software/SPDF/repository/**"
} else {
exclude "stirling/software/SPDF/config/anonymus/**"
} }
if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") { if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {

View File

@ -1,28 +1,225 @@
package stirling.software.SPDF.config; 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.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsInterface;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Component @Component
@Slf4j @Slf4j
public class EndpointInterceptor implements HandlerInterceptor { public class EndpointInterceptor implements HandlerInterceptor {
private final EndpointConfiguration endpointConfiguration; private final EndpointConfiguration endpointConfiguration;
private final SessionsInterface sessionsInterface;
public EndpointInterceptor(EndpointConfiguration endpointConfiguration) { public EndpointInterceptor(
EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) {
this.endpointConfiguration = endpointConfiguration; this.endpointConfiguration = endpointConfiguration;
this.sessionsInterface = sessionsInterface;
} }
@Override @Override
public boolean preHandle( public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler) HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception { throws Exception {
HttpSession session = request.getSession(false);
if (session == null) {
session = request.getSession(true);
}
String requestURI = request.getRequestURI(); 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; boolean isEnabled;
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages) // 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()) { if (authentication == null || !authentication.isAuthenticated()) {
return !showUpdateOnlyAdmin; return !showUpdateOnlyAdmin;
} }
if (authentication.getName().equalsIgnoreCase("anonymousUser")) { if ("anonymousUser".equalsIgnoreCase(authentication.getName())) {
return !showUpdateOnlyAdmin; return !showUpdateOnlyAdmin;
} }
Optional<User> user = userRepository.findByUsername(authentication.getName()); 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.CustomSaml2AuthenticationFailureHandler;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.SPDF.config.security.saml2.CustomSaml2ResponseAuthenticationConverter; 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.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.User;
@ -149,7 +150,7 @@ public class SecurityConfiguration {
sessionManagement -> sessionManagement ->
sessionManagement sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10) .maximumSessions(sessionRegistry.getMaxUserSessions())
.maxSessionsPreventsLogin(false) .maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry) .sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true")); .expiredUrl("/login?logout=true"));
@ -158,6 +159,8 @@ public class SecurityConfiguration {
http.logout( http.logout(
logout -> logout ->
logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.addLogoutHandler(
new PreLogoutDataCaptureHandler(sessionRegistry))
.logoutSuccessHandler( .logoutSuccessHandler(
new CustomLogoutSuccessHandler(applicationProperties)) new CustomLogoutSuccessHandler(applicationProperties))
.clearAuthentication(true) .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.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; 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.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@ -397,23 +396,10 @@ public class UserService implements UserServiceInterface {
} }
public void invalidateUserSessions(String username) { public void invalidateUserSessions(String username) {
String usernameP = "";
for (Object principal : sessionRegistry.getAllPrincipals()) { for (Object principal : sessionRegistry.getAllPrincipals()) {
for (SessionInformation sessionsInformation : String usernameP = UserUtils.getUsernameFromPrincipal(principal);
sessionRegistry.getAllSessions(principal, false)) { if (usernameP.equalsIgnoreCase(username)) {
if (principal instanceof UserDetails detailsUser) { sessionRegistry.expireAllSessionsByPrincipalName(usernameP);
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());
}
} }
} }
} }

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.authentication.LockedException;
import org.springframework.security.core.Authentication; 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.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest; 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.LoginAttemptService;
import stirling.software.SPDF.config.security.UserService; 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;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.AuthenticationType;
@ -45,13 +45,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
throws ServletException, IOException { throws ServletException, IOException {
Object principal = authentication.getPrincipal(); Object principal = authentication.getPrincipal();
String username = ""; String username = UserUtils.getUsernameFromPrincipal(principal);
if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
} else if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
}
// Get the saved request // Get the saved request
HttpSession session = request.getSession(false); HttpSession session = request.getSession(false);

View File

@ -1,30 +1,276 @@
package stirling.software.SPDF.config.security.session; 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 org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener; import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j; 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 @Component
@Slf4j @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 @Value("${server.servlet.session.timeout:30m}")
public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { private Duration defaultMaxInactiveInterval;
public CustomHttpSessionListener(
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabled,
@Qualifier("runningEE") boolean runningEE,
ApplicationProperties applicationProperties) {
super(); super();
this.sessionPersistentRegistry = sessionPersistentRegistry; this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabled = loginEnabled;
this.runningEE = runningEE;
this.applicationProperties = applicationProperties;
} }
@Override @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 @Override
public void sessionDestroyed(HttpSessionEvent se) { 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; package stirling.software.SPDF.config.security.session;
import java.time.Duration; 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.beans.factory.annotation.Value;
import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry; 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 org.springframework.stereotype.Component;
import jakarta.transaction.Transactional; 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; import stirling.software.SPDF.model.SessionEntity;
@Component @Component
@Slf4j
public class SessionPersistentRegistry implements SessionRegistry { public class SessionPersistentRegistry implements SessionRegistry {
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final boolean runningEE;
private final boolean loginEnabled;
@Value("${server.servlet.session.timeout:30m}") @Value("${server.servlet.session.timeout:30m}")
private Duration defaultMaxInactiveInterval; 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.sessionRepository = sessionRepository;
this.loginEnabled = loginEnabled;
} }
@Override @Override
@ -41,17 +56,7 @@ public class SessionPersistentRegistry implements SessionRegistry {
public List<SessionInformation> getAllSessions( public List<SessionInformation> getAllSessions(
Object principal, boolean includeExpiredSessions) { Object principal, boolean includeExpiredSessions) {
List<SessionInformation> sessionInformations = new ArrayList<>(); List<SessionInformation> sessionInformations = new ArrayList<>();
String principalName = null; String principalName = UserUtils.getUsernameFromPrincipal(principal);
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;
}
if (principalName != null) { if (principalName != null) {
List<SessionEntity> sessionEntities = List<SessionEntity> sessionEntities =
@ -72,33 +77,28 @@ public class SessionPersistentRegistry implements SessionRegistry {
@Override @Override
@Transactional @Transactional
public void registerNewSession(String sessionId, Object principal) { public void registerNewSession(String sessionId, Object principal) {
String principalName = null; String principalName = UserUtils.getUsernameFromPrincipal(principal);
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;
}
if (principalName != null) { 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(); int sessionUserCount = getAllSessions(principalName, false).size();
sessionEntity.setSessionId(sessionId);
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.setPrincipalName(principalName);
sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date 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.save(sessionEntity);
sessionRepository.flush();
} }
} }
@ -106,16 +106,57 @@ public class SessionPersistentRegistry implements SessionRegistry {
@Transactional @Transactional
public void removeSessionInformation(String sessionId) { public void removeSessionInformation(String sessionId) {
sessionRepository.deleteById(sessionId); sessionRepository.deleteById(sessionId);
sessionRepository.flush();
}
@Transactional
public void removeSessionInformationByPrincipalName(String principalName) {
sessionRepository.deleteByPrincipalName(principalName);
sessionRepository.flush();
} }
@Override @Override
@Transactional @Transactional
public void refreshLastRequest(String sessionId) { public void refreshLastRequest(String sessionId) {
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId); SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId);
if (sessionEntityOpt.isPresent()) { if (sessionEntity != null) {
SessionEntity sessionEntity = sessionEntityOpt.get();
sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date
sessionRepository.save(sessionEntity); 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 sessionEntity = sessionEntityOpt.get();
sessionEntity.setExpired(true); // Set expired to true sessionEntity.setExpired(true); // Set expired to true
sessionRepository.save(sessionEntity); 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 // Update session details by principal name
public void updateSessionByPrincipalName( // public void updateSessionByPrincipalName(
String principalName, boolean expired, Date lastRequest) { // String principalName, boolean expired, Date lastRequest) {
sessionRepository.saveByPrincipalName(expired, lastRequest, principalName); // 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 // Find the latest session for a given principal name
public Optional<SessionEntity> findLatestSession(String principalName) { public Optional<SessionEntity> findLatestSession(String principalName) {
@ -178,15 +283,29 @@ public class SessionPersistentRegistry implements SessionRegistry {
// Sort sessions by lastRequest in descending order // Sort sessions by lastRequest in descending order
Collections.sort( Collections.sort(
allSessions, allSessions,
new Comparator<SessionEntity>() { (SessionEntity s1, SessionEntity s2) ->
@Override s2.getLastRequest().compareTo(s1.getLastRequest()));
public int compare(SessionEntity s1, SessionEntity s2) {
// Sort by lastRequest in descending order
return s2.getLastRequest().compareTo(s1.getLastRequest());
}
});
// The first session in the list is the latest session for the given principal name // The first session in the list is the latest session for the given principal name
return Optional.of(allSessions.get(0)); 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; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.session.SessionRegistryImpl;
@ -14,7 +15,9 @@ public class SessionRegistryConfig {
@Bean @Bean
public SessionPersistentRegistry sessionPersistentRegistry( public SessionPersistentRegistry sessionPersistentRegistry(
SessionRepository sessionRepository) { SessionRepository sessionRepository,
return new SessionPersistentRegistry(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); SessionEntity findBySessionId(String sessionId);
void deleteByPrincipalName(String principalName);
@Modifying @Modifying
@Transactional @Transactional
@Query( @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( void saveByPrincipalName(
@Param("expired") boolean expired, @Param("expired") boolean expired,
@Param("lastRequest") Date lastRequest, @Param("lastRequest") Date lastRequest,

View File

@ -5,23 +5,43 @@ import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled; 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.security.core.session.SessionInformation;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Component @Component
@Slf4j
public class SessionScheduled { public class SessionScheduled {
private final SessionPersistentRegistry sessionPersistentRegistry; private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
public SessionScheduled(SessionPersistentRegistry sessionPersistentRegistry) { public SessionScheduled(
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
this.sessionPersistentRegistry = sessionPersistentRegistry; this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
} }
@Scheduled(cron = "0 0/5 * * * ?") @Scheduled(cron = "0 0/5 * * * ?")
public void expireSessions() { public void expireSessions() {
Instant now = Instant.now(); Instant now = Instant.now();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
for (Object principal : sessionPersistentRegistry.getAllPrincipals()) { 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 = List<SessionInformation> sessionInformations =
sessionPersistentRegistry.getAllSessions(principal, false); sessionPersistentRegistry.getAllSessions(principal, false);
for (SessionInformation sessionInformation : sessionInformations) { for (SessionInformation sessionInformation : sessionInformations) {
@ -31,6 +51,19 @@ public class SessionScheduled {
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) { if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); 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.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.SessionInformation; 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.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@ -30,7 +28,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.UserService; 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.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.AuthenticationType; import stirling.software.SPDF.model.AuthenticationType;
@ -304,19 +302,11 @@ public class UserController {
if (!enabled) { if (!enabled) {
// Invalidate all sessions if the user is being disabled // Invalidate all sessions if the user is being disabled
List<Object> principals = sessionRegistry.getAllPrincipals(); List<Object> principals = sessionRegistry.getAllPrincipals();
String userNameP = "";
for (Object principal : principals) { for (Object principal : principals) {
List<SessionInformation> sessionsInformation = List<SessionInformation> sessionsInformation =
sessionRegistry.getAllSessions(principal, false); sessionRegistry.getAllSessions(principal, false);
if (principal instanceof UserDetails detailsUser) { String userNameP = UserUtils.getUsernameFromPrincipal(principal);
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)) { if (userNameP.equalsIgnoreCase(username)) {
for (SessionInformation sessionInfo : sessionsInformation) { for (SessionInformation sessionInfo : sessionsInformation) {
sessionRegistry.expireSession(sessionInfo.getSessionId()); sessionRegistry.expireSession(sessionInfo.getSessionId());

View File

@ -29,8 +29,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; 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.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;
import stirling.software.SPDF.model.ApplicationProperties.Security; import stirling.software.SPDF.model.ApplicationProperties.Security;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
@ -53,26 +54,29 @@ public class AccountWebController {
public static final String OAUTH_2_AUTHORIZATION = "/oauth2/authorization/"; public static final String OAUTH_2_AUTHORIZATION = "/oauth2/authorization/";
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private final SessionPersistentRegistry sessionPersistentRegistry; private final CustomHttpSessionListener customHttpSessionListener;
// Assuming you have a repository for user operations // Assuming you have a repository for user operations
private final UserRepository userRepository; private final UserRepository userRepository;
private final boolean loginEnabledValue;
private final boolean runningEE; private final boolean runningEE;
public AccountWebController( public AccountWebController(
ApplicationProperties applicationProperties, ApplicationProperties applicationProperties,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository, UserRepository userRepository,
@Qualifier("runningEE") boolean runningEE) { @Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningEE") boolean runningEE,
CustomHttpSessionListener customHttpSessionListener) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository; this.userRepository = userRepository;
this.loginEnabledValue = loginEnabledValue;
this.runningEE = runningEE; this.runningEE = runningEE;
this.customHttpSessionListener = customHttpSessionListener;
} }
@GetMapping("/login") @GetMapping("/login")
public String login(HttpServletRequest request, Model model, Authentication authentication) { public String login(HttpServletRequest request, Model model, Authentication authentication) {
// If the user is already authenticated, redirect them to the home page. // 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:/"; return "redirect:/";
} }
@ -150,6 +154,7 @@ public class AccountWebController {
case "badCredentials" -> error = "login.invalid"; case "badCredentials" -> error = "login.invalid";
case "locked" -> error = "login.locked"; case "locked" -> error = "login.locked";
case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage"; case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage";
case "expiredSession" -> error = "expiredSessionMessage";
} }
model.addAttribute("error", error); model.addAttribute("error", error);
@ -210,14 +215,20 @@ public class AccountWebController {
@GetMapping("/adminSettings") @GetMapping("/adminSettings")
public String showAddUserForm( public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) { HttpServletRequest request, Model model, Authentication authentication) {
String currentSessionId = request.getSession().getId();
List<User> allUsers = userRepository.findAll(); List<User> allUsers = userRepository.findAll();
Iterator<User> iterator = allUsers.iterator(); Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails(); Map<String, String> roleDetails = Role.getAllRoleDetails();
// Map to store session information and user activity status // Map to store session information and user activity status
Map<String, Boolean> userSessions = new HashMap<>(); Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>(); Map<String, Date> userLastRequest = new HashMap<>();
Map<String, List<SessionsModelInterface>> userActiveSessions = new HashMap<>();
int activeUsers = 0; int activeUsers = 0;
int disabledUsers = 0; int disabledUsers = 0;
int maxSessions = customHttpSessionListener.getMaxApplicationSessions();
int maxUserSessions = customHttpSessionListener.getMaxUserSessions();
int sessionCount = customHttpSessionListener.getAllNonExpiredSessions().size();
while (iterator.hasNext()) { while (iterator.hasNext()) {
User user = iterator.next(); User user = iterator.next();
if (user != null) { if (user != null) {
@ -230,13 +241,13 @@ public class AccountWebController {
} }
} }
// Determine the user's session status and last request time // Determine the user's session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); int maxInactiveInterval = customHttpSessionListener.getMaxInactiveInterval();
boolean hasActiveSession = false; boolean hasActiveSession = false;
Date lastRequest = null; Date lastRequest;
Optional<SessionEntity> latestSession = Optional<SessionsModelInterface> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername()); customHttpSessionListener.findLatestSession(user.getUsername());
if (latestSession.isPresent()) { if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get(); SessionEntity sessionEntity = (SessionEntity) latestSession.get();
Date lastAccessedTime = sessionEntity.getLastRequest(); Date lastAccessedTime = sessionEntity.getLastRequest();
Instant now = Instant.now(); Instant now = Instant.now();
// Calculate session expiration and update session status accordingly // Calculate session expiration and update session status accordingly
@ -245,7 +256,7 @@ public class AccountWebController {
.toInstant() .toInstant()
.plus(maxInactiveInterval, ChronoUnit.SECONDS); .plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) { if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); customHttpSessionListener.expireSession(sessionEntity.getSessionId());
} else { } else {
hasActiveSession = !sessionEntity.isExpired(); hasActiveSession = !sessionEntity.isExpired();
} }
@ -262,6 +273,9 @@ public class AccountWebController {
if (!user.isEnabled()) { if (!user.isEnabled()) {
disabledUsers++; disabledUsers++;
} }
List<SessionsModelInterface> sessionInformations =
customHttpSessionListener.getAllSessions(user.getUsername(), false);
userActiveSessions.put(user.getUsername(), sessionInformations);
} }
} }
// Sort users by active status and last request date // Sort users by active status and last request date
@ -323,15 +337,21 @@ public class AccountWebController {
} }
model.addAttribute("users", sortedUsers); 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("roleDetails", roleDetails);
model.addAttribute("userSessions", userSessions); model.addAttribute("userSessions", userSessions);
model.addAttribute("userLastRequest", userLastRequest); model.addAttribute("userLastRequest", userLastRequest);
model.addAttribute("userActiveSessions", userActiveSessions);
model.addAttribute("totalUsers", allUsers.size()); model.addAttribute("totalUsers", allUsers.size());
model.addAttribute("activeUsers", activeUsers); model.addAttribute("activeUsers", activeUsers);
model.addAttribute("disabledUsers", disabledUsers); model.addAttribute("disabledUsers", disabledUsers);
model.addAttribute("maxSessions", maxSessions);
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers()); model.addAttribute("maxUserSessions", maxUserSessions);
model.addAttribute("sessionCount", sessionCount);
model.addAttribute("maxPaidUsers", customHttpSessionListener.getMaxUsers());
return "adminSettings"; return "adminSettings";
} }

View File

@ -9,15 +9,36 @@ import jakarta.persistence.Table;
import lombok.Data; import lombok.Data;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Entity @Entity
@Data @Data
@Table(name = "sessions") @Table(name = "sessions")
public class SessionEntity implements Serializable { public class SessionEntity implements Serializable, SessionsModelInterface {
@Id private String sessionId; @Id private String sessionId;
private String principalName; private String principalName;
private Date lastRequest; private Date lastRequest;
private boolean expired; 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 # Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs springdoc.swagger-ui.url=/v1/api-docs
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq 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.bored.help=Aktiviert das Easter-Egg-Spiel
settings.cacheInputs.name=Formulareingaben speichern settings.cacheInputs.name=Formulareingaben speichern
settings.cacheInputs.help=Aktivieren, um zuvor verwendete Eingaben für zukünftige Durchläufe zu 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.title=Anmeldeinformationen ändern
changeCreds.header=Aktualisieren Sie Ihre Kontodaten changeCreds.header=Aktualisieren Sie Ihre Kontodaten
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Aktive Benutzer:
adminUserSettings.disabledUsers=Deaktivierte Benutzer: adminUserSettings.disabledUsers=Deaktivierte Benutzer:
adminUserSettings.totalUsers=Gesamtzahl der Benutzer: adminUserSettings.totalUsers=Gesamtzahl der Benutzer:
adminUserSettings.lastRequest=Letzte Anfrage adminUserSettings.lastRequest=Letzte Anfrage
adminUserSettings.userSessions=Benutzersitzungen
adminUserSettings.totalSessions=Gesamtzahl der Sitzungen:
adminUserSettings.usage=View Usage adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics 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.expired=Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.
session.refreshPage=Seite aktualisieren 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 # # HOME-PAGE #
############# #############

View File

@ -176,6 +176,7 @@ settings.accountSettings=Account Settings
settings.bored.help=Enables easter egg game settings.bored.help=Enables easter egg game
settings.cacheInputs.name=Save form inputs settings.cacheInputs.name=Save form inputs
settings.cacheInputs.help=Enable to store previously used inputs for future runs settings.cacheInputs.help=Enable to store previously used inputs for future runs
settings.userSessions=User Sessions
changeCreds.title=Change Credentials changeCreds.title=Change Credentials
changeCreds.header=Update Your Account Details changeCreds.header=Update Your Account Details
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Active Users:
adminUserSettings.disabledUsers=Disabled Users: adminUserSettings.disabledUsers=Disabled Users:
adminUserSettings.totalUsers=Total Users: adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request adminUserSettings.lastRequest=Last Request
adminUserSettings.userSessions=User sessions
adminUserSettings.totalSessions=Total Sessions:
adminUserSettings.usage=View Usage adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics 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.expired=Your session has expired. Please refresh the page and try again.
session.refreshPage=Refresh Page 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 # # 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.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.1=Strictly Necessary Cookies
cookieBanner.preferencesModal.necessary.title.2=Always Enabled 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.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. 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> <!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org"> <html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
<head> xmlns:th="https://www.thymeleaf.org">
<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;
}
.text-overflow { <head>
max-width: 100px; <th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}">
white-space: nowrap; </th:block>
overflow: hidden; <style>
text-overflow:ellipsis; .active-user {
} color: green;
</style> text-shadow: 0 0 5px green;
</head> }
<body> .text-overflow {
<th:block th:insert="~{fragments/common :: game}"></th:block> max-width: 100px;
<div id="page-container"> white-space: nowrap;
<div id="content-wrap"> overflow: hidden;
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block> text-overflow: ellipsis;
<br><br> }
<div class="container"> </style>
<div class="row justify-content-center"> </head>
<div class="col-md-9 bg-card">
<div class="tool-header"> <body>
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span> <th:block th:insert="~{fragments/common :: game}"></th:block>
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span> <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> </div>
<!-- User Settings Title --> <div class="my-4">
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;"> <strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<a href="#" <span th:text="${totalUsers}"></span>
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'" <span th:if="${@runningProOrHigher}" th:text="' | ' + ${maxPaidUsers}"></span>
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.activeUsers}">Active Users:</strong>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong> <span th:text="${activeUsers}"></span>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span> <strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong> <th:block>
<span th:text="${activeUsers}"></span> <strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalSessions}">Total Sessions:</strong>
<span th:if="${@runningProOrHigher}" th:text="${sessionCount}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong> <span th:text="' | ' + ${maxSessions}"></span>
<span th:text="${disabledUsers}"></span> </th:block>
</div> </div>
</div> </div>
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;"> <div th:if="${addMessage}" class="p-3"
<div class="alert alert-danger mb-auto"> style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<span th:text="#{${addMessage}}">Default message if not found</span> <div class="alert alert-danger mb-auto">
</div> <span th:text="#{${addMessage}}">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>
<div class="alert alert-danger mb-auto"> <div th:if="${changeMessage}" class="p-3"
<span th:text="#{${changeMessage}}">Default message if not found</span> style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
</div> <div class="alert alert-danger mb-auto">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div> </div>
<div th:if="${deleteMessage}" class="alert alert-danger"> </div>
<span th:text="#{${deleteMessage}}">Default message if not found</span> <div th:if="${deleteMessage}" class="alert alert-danger">
</div> <span th:text="#{${deleteMessage}}">Default message if not found</span>
<div class="bg-card mt-3 mb-3 table-responsive"> </div>
<table class="table table-striped table-hover"> <div class="bg-card mt-3 mb-3 table-responsive">
<thead> <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> <tr>
<th scope="col">#</th> <th scope="row" th:text="${user.id}"></th>
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th> <td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th> <td th:text="#{${user.roleName}}"></td>
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th> <td th:text="${user.authenticationType}"></td>
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th> <td
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}" colspan="2">Actions</th> th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}">
<!-- <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>
</td> </td>
<td style="align-content: center;"> <th:block th:if="${@runningProOrHigher}">
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()"> <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}" /> <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> <span class="material-symbols-rounded">person</span>
</button> </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> <span class="material-symbols-rounded">person_off</span>
</button> </button>
</form> </form>
</td> </td>
</tr> </tr>
</tbody> <tr th:if="${@enableAlphaFunctionality}">
</table> <td colspan="8" class="p-0 border-0">
</div> <div th:id="'sessions__' + ${iterStat.index}" class="collapse">
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p> <table class="table table-striped table-hover table-sm mb-0">
<tbody>
<script th:inline="javascript"> <tr th:each="s : ${userActiveSessions[user.username]}">
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?'; <td scope="row" colspan="4">
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?'; <span th:text="${s.sessionId}"></span>
function confirmDeleteUser() { <span th:if="${s.sessionId == currentSessionId}" class="text-warning ms-2"
return confirm(delete_confirm_text); title="Aktuelle Sitzung">
} ⚠️
function confirmChangeUserStatus() { </span>
return confirm(change_confirm_text); </td>
} <td colspan="2" th:text="${#dates.format(s.lastRequest, 'yyyy-MM-dd HH:mm:ss')}"></td>
</script> <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> </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> </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> </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> </html>

View File

@ -14,6 +14,9 @@
<h1 class="display-2" th:text="#{oops}"></h1> <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: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: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> <br>
<h2 th:text="#{error.needHelp}"></h2> <h2 th:text="#{error.needHelp}"></h2>
<p th:text="#{error.contactTip}"></p> <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://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> <a href="https://discord.gg/HYmhKj45pU" id="discord-button" class="btn btn-primary" target="_blank" th:text="#{joinDiscord}"></a>
</div> </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> </div>
</div> </div>

View File

@ -259,6 +259,8 @@
<div class="modal-footer"> <div class="modal-footer">
<a th:if="${@loginEnabled and @activeSecurity}" class="btn btn-danger" role="button" <a th:if="${@loginEnabled and @activeSecurity}" class="btn btn-danger" role="button"
th:text="#{settings.signOut}" th:href="@{'/logout'}">Sign Out</a> 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> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}"></button>
</div> </div>
</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() { capture_file_list() {
local container_name=$1 local container_name=$1
local output_file=$2 local output_file=$2
echo "Capturing file list from $container_name..." echo "Capturing file list from $container_name..."
# Get all files in one command, output directly from Docker to avoid path issues # Get all files in one command, output directly from Docker to avoid path issues
# Skip proc, sys, dev, and the specified LibreOffice config directory # Skip proc, sys, dev, and the specified LibreOffice config directory
@ -60,12 +60,12 @@ capture_file_list() {
-not -path '*/tmp/lu*' \ -not -path '*/tmp/lu*' \
-not -path '*/tmp/tmp*' \ -not -path '*/tmp/tmp*' \
2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" 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 # Check if the output file has content
if [ ! -s "$output_file" ]; then if [ ! -s "$output_file" ]; then
echo "WARNING: Failed to capture file list or container returned empty list" echo "WARNING: Failed to capture file list or container returned empty list"
echo "Trying alternative approach..." echo "Trying alternative approach..."
# Alternative simpler approach - just get paths as a fallback # Alternative simpler approach - just get paths as a fallback
docker exec $container_name sh -c "find / -type f \ docker exec $container_name sh -c "find / -type f \
-not -path '*/proc/*' \ -not -path '*/proc/*' \
@ -79,14 +79,14 @@ capture_file_list() {
-not -path '*/tmp/lu*' \ -not -path '*/tmp/lu*' \
-not -path '*/tmp/tmp*' \ -not -path '*/tmp/tmp*' \
2>/dev/null | sort" > "$output_file" 2>/dev/null | sort" > "$output_file"
if [ ! -s "$output_file" ]; then if [ ! -s "$output_file" ]; then
echo "ERROR: All attempts to capture file list failed" echo "ERROR: All attempts to capture file list failed"
# Create a dummy entry to prevent diff errors # Create a dummy entry to prevent diff errors
echo "NO_FILES_FOUND 0 0" > "$output_file" echo "NO_FILES_FOUND 0 0" > "$output_file"
fi fi
fi fi
echo "File list captured to $output_file" echo "File list captured to $output_file"
} }
@ -96,24 +96,24 @@ compare_file_lists() {
local after_file=$2 local after_file=$2
local diff_file=$3 local diff_file=$3
local container_name=$4 # Added container_name parameter local container_name=$4 # Added container_name parameter
echo "Comparing file lists..." echo "Comparing file lists..."
# Check if files exist and have content # Check if files exist and have content
if [ ! -s "$before_file" ] || [ ! -s "$after_file" ]; then if [ ! -s "$before_file" ] || [ ! -s "$after_file" ]; then
echo "WARNING: One or both file lists are empty." echo "WARNING: One or both file lists are empty."
if [ ! -s "$before_file" ]; then if [ ! -s "$before_file" ]; then
echo "Before file is empty: $before_file" echo "Before file is empty: $before_file"
fi fi
if [ ! -s "$after_file" ]; then if [ ! -s "$after_file" ]; then
echo "After file is empty: $after_file" echo "After file is empty: $after_file"
fi fi
# Create empty diff file # Create empty diff file
> "$diff_file" > "$diff_file"
# Check if we at least have the after file to look for temp files # Check if we at least have the after file to look for temp files
if [ -s "$after_file" ]; then if [ -s "$after_file" ]; then
echo "Checking for temp files in the after snapshot..." 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." echo "No temporary files found in the after snapshot."
fi fi
fi fi
return 0 return 0
fi fi
# Both files exist and have content, proceed with diff # Both files exist and have content, proceed with diff
diff "$before_file" "$after_file" > "$diff_file" diff "$before_file" "$after_file" > "$diff_file"
if [ -s "$diff_file" ]; then if [ -s "$diff_file" ]; then
echo "Detected changes in files:" echo "Detected changes in files:"
cat "$diff_file" cat "$diff_file"
# Extract only added files (lines starting with ">") # Extract only added files (lines starting with ">")
grep "^>" "$diff_file" > "${diff_file}.added" || true grep "^>" "$diff_file" > "${diff_file}.added" || true
if [ -s "${diff_file}.added" ]; then if [ -s "${diff_file}.added" ]; then
echo "New files created during test:" echo "New files created during test:"
cat "${diff_file}.added" | sed 's/^> //' cat "${diff_file}.added" | sed 's/^> //'
# Check for tmp files # Check for tmp files
grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true
if [ -s "${diff_file}.tmp" ]; then if [ -s "${diff_file}.tmp" ]; then
@ -155,7 +155,7 @@ compare_file_lists() {
return 1 return 1
fi fi
fi fi
# Extract only removed files (lines starting with "<") # Extract only removed files (lines starting with "<")
grep "^<" "$diff_file" > "${diff_file}.removed" || true grep "^<" "$diff_file" > "${diff_file}.removed" || true
if [ -s "${diff_file}.removed" ]; then if [ -s "${diff_file}.removed" ]; then
@ -165,7 +165,7 @@ compare_file_lists() {
else else
echo "No file changes detected during test." echo "No file changes detected during test."
fi fi
return 0 return 0
} }
@ -212,8 +212,8 @@ main() {
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
export DOCKER_CLI_EXPERIMENTAL=enabled export DOCKER_CLI_EXPERIMENTAL=enabled
export COMPOSE_DOCKER_CLI_BUILD=0 export COMPOSE_DOCKER_CLI_BUILD=0
export DOCKER_ENABLE_SECURITY=false export DOCKER_ENABLE_SECURITY=false
# Run the gradlew build command and check if it fails # Run the gradlew build command and check if it fails
if ! ./gradlew clean build; then if ! ./gradlew clean build; then
@ -282,27 +282,27 @@ main() {
# Create directory for file snapshots if it doesn't exist # Create directory for file snapshots if it doesn't exist
SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots" SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots"
mkdir -p "$SNAPSHOT_DIR" mkdir -p "$SNAPSHOT_DIR"
# Capture file list before running behave tests # Capture file list before running behave tests
BEFORE_FILE="$SNAPSHOT_DIR/files_before_behave.txt" BEFORE_FILE="$SNAPSHOT_DIR/files_before_behave.txt"
AFTER_FILE="$SNAPSHOT_DIR/files_after_behave.txt" AFTER_FILE="$SNAPSHOT_DIR/files_after_behave.txt"
DIFF_FILE="$SNAPSHOT_DIR/files_diff.txt" DIFF_FILE="$SNAPSHOT_DIR/files_diff.txt"
# Define container name variable for consistency # Define container name variable for consistency
CONTAINER_NAME="Stirling-PDF-Security-Fat-with-login" CONTAINER_NAME="Stirling-PDF-Security-Fat-with-login"
capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE" capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE"
cd "testing/cucumber" cd "testing/cucumber"
if python -m behave; then if python -m behave; then
# Wait 10 seconds before capturing the file list after tests # Wait 10 seconds before capturing the file list after tests
echo "Waiting 5 seconds for any file operations to complete..." echo "Waiting 5 seconds for any file operations to complete..."
sleep 5 sleep 5
# Capture file list after running behave tests # Capture file list after running behave tests
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE"
# Compare file lists # Compare file lists
if compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"; then if compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"; then
echo "No unexpected temporary files found." echo "No unexpected temporary files found."
@ -311,19 +311,19 @@ main() {
echo "WARNING: Unexpected temporary files detected after behave tests!" echo "WARNING: Unexpected temporary files detected after behave tests!"
failed_tests+=("Stirling-PDF-Regression-Temp-Files") failed_tests+=("Stirling-PDF-Regression-Temp-Files")
fi fi
passed_tests+=("Stirling-PDF-Regression") passed_tests+=("Stirling-PDF-Regression")
else else
failed_tests+=("Stirling-PDF-Regression") failed_tests+=("Stirling-PDF-Regression")
echo "Printing docker logs of failed regression" echo "Printing docker logs of failed regression"
docker logs "$CONTAINER_NAME" docker logs "$CONTAINER_NAME"
echo "Printed docker logs of failed regression" echo "Printed docker logs of failed regression"
# Still capture file list after failure for analysis # Still capture file list after failure for analysis
# Wait 10 seconds before capturing the file list # Wait 10 seconds before capturing the file list
echo "Waiting 5 seconds before capturing file list..." echo "Waiting 5 seconds before capturing file list..."
sleep 10 sleep 10
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
capture_file_list "$CONTAINER_NAME" "$AFTER_FILE" capture_file_list "$CONTAINER_NAME" "$AFTER_FILE"
compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME" compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"
@ -372,4 +372,4 @@ main() {
fi fi
} }
main main

View File

@ -9,7 +9,7 @@ check_webpage() {
local result_file="$3" local result_file="$3"
# Use curl to fetch the page with timeout # 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 if [ $? -ne 0 ]; then
echo "FAILED - Connection error or timeout $full_url" >> "$result_file" echo "FAILED - Connection error or timeout $full_url" >> "$result_file"
return 1 return 1
@ -75,13 +75,13 @@ test_all_urls() {
((total_count++)) ((total_count++))
((url_index++)) ((url_index++))
# Run the check in background # Run the check in background
test_url "$url" "$base_url" "$tmp_dir" "$url_index" & test_url "$url" "$base_url" "$tmp_dir" "$url_index" &
# Track the job # Track the job
((active_jobs++)) ((active_jobs++))
# If we've reached max_parallel, wait for a job to finish # If we've reached max_parallel, wait for a job to finish
if [ $active_jobs -ge $max_parallel ]; then if [ $active_jobs -ge $max_parallel ]; then
wait -n # Wait for any child process to exit wait -n # Wait for any child process to exit
@ -97,7 +97,7 @@ test_all_urls() {
if [ -f "${tmp_dir}/result_${i}.txt" ]; then if [ -f "${tmp_dir}/result_${i}.txt" ]; then
cat "${tmp_dir}/result_${i}.txt" cat "${tmp_dir}/result_${i}.txt"
fi fi
if [ -f "${tmp_dir}/failed_${i}" ]; then if [ -f "${tmp_dir}/failed_${i}" ]; then
failed_count=$((failed_count + $(cat "${tmp_dir}/failed_${i}"))) failed_count=$((failed_count + $(cat "${tmp_dir}/failed_${i}")))
fi fi
@ -105,6 +105,7 @@ test_all_urls() {
# Clean up # Clean up
rm -rf "$tmp_dir" rm -rf "$tmp_dir"
rm -f cookies.txt
local end_time=$(date +%s) local end_time=$(date +%s)
local duration=$((end_time - start_time)) local duration=$((end_time - start_time))
@ -158,6 +159,8 @@ main() {
exit 1 exit 1
fi fi
curl -s -c cookies.txt -o /dev/null $base_url/
# Run tests using the URL list # Run tests using the URL list
if test_all_urls "$url_file" "$base_url" "$max_parallel"; then if test_all_urls "$url_file" "$base_url" "$max_parallel"; then
echo "All webpage tests passed!" echo "All webpage tests passed!"
@ -171,4 +174,4 @@ main() {
# Run main if script is executed directly # Run main if script is executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@" main "$@"
fi fi