mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-04-18 18:51:19 +00:00
Merge fa8df329dfcd0299d533fd6dd4e92a52975a6564 into 3fda82e39d6a6922143394c2b4a9d758496aff4d
This commit is contained in:
commit
1d68ec287a
@ -65,6 +65,8 @@ sourceSets {
|
||||
exclude "stirling/software/SPDF/model/SessionEntity.java"
|
||||
exclude "stirling/software/SPDF/model/User.java"
|
||||
exclude "stirling/software/SPDF/repository/**"
|
||||
} else {
|
||||
exclude "stirling/software/SPDF/config/anonymus/**"
|
||||
}
|
||||
|
||||
if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {
|
||||
|
@ -1,28 +1,225 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.interfaces.SessionsInterface;
|
||||
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class EndpointInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final SessionsInterface sessionsInterface;
|
||||
|
||||
public EndpointInterceptor(EndpointConfiguration endpointConfiguration) {
|
||||
public EndpointInterceptor(
|
||||
EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) {
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.sessionsInterface = sessionsInterface;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
session = request.getSession(true);
|
||||
}
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
boolean isApiRequest = requestURI.contains("/api/v1");
|
||||
|
||||
if ("GET".equalsIgnoreCase(request.getMethod()) && !isApiRequest) {
|
||||
|
||||
Principal principal = request.getUserPrincipal();
|
||||
|
||||
// allowlist for public or static routes
|
||||
if ("/login".equals(requestURI)
|
||||
// || "/".equals(requestURI)
|
||||
// || "/home".equals(requestURI)
|
||||
// || "/home-legacy".equals(requestURI)
|
||||
|| "/userSession".equals(requestURI)
|
||||
|| requestURI.contains("/userSession/invalidate/")
|
||||
|| requestURI.contains("/js/")
|
||||
|| requestURI.contains("/css/")
|
||||
|| requestURI.contains("/fonts/")
|
||||
|| requestURI.contains("/images/")
|
||||
|| requestURI.contains("/favicon")
|
||||
|| requestURI.contains("/pdfjs-legacy/")
|
||||
|| requestURI.contains("/error")
|
||||
|| requestURI.contains("/session")
|
||||
|| requestURI.endsWith(".js")
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".webmanifest")
|
||||
|| requestURI.contains("/files/")) {
|
||||
return true;
|
||||
} else if (principal != null) {
|
||||
if (session == null) {
|
||||
session = request.getSession(true);
|
||||
}
|
||||
|
||||
final HttpSession finalSession = session;
|
||||
String sessionId = finalSession.getId();
|
||||
|
||||
boolean isExpiredByAdmin =
|
||||
sessionsInterface.getAllSessions().stream()
|
||||
.filter(s -> s.getSessionId().equals(finalSession.getId()))
|
||||
.anyMatch(s -> s.isExpired());
|
||||
|
||||
if (isExpiredByAdmin
|
||||
&& !"/".equals(requestURI)
|
||||
&& !"/home".equals(requestURI)
|
||||
&& !"/home-legacy".equals(requestURI)) {
|
||||
response.sendRedirect("/logout");
|
||||
log.info("Session expired. Logging out user {}", principal.getName());
|
||||
return false;
|
||||
} else if (isExpiredByAdmin
|
||||
&& ("/".equals(requestURI)
|
||||
|| "/home".equals(requestURI)
|
||||
|| "/home-legacy".equals(requestURI))) {
|
||||
log.info(
|
||||
"Max sessions reached for this user. To continue on this device, please"
|
||||
+ " close your session in another browser. ");
|
||||
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions();
|
||||
|
||||
Collection<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
|
||||
|
||||
long totalSessionsNonExpired =
|
||||
allSessions.stream().filter(s -> !s.isExpired()).count();
|
||||
|
||||
List<SessionsModelInterface> activeSessions =
|
||||
allSessions.stream()
|
||||
.filter(s -> !s.isExpired())
|
||||
.sorted(
|
||||
(s1, s2) ->
|
||||
Long.compare(
|
||||
s2.getLastRequest().getTime(),
|
||||
s1.getLastRequest().getTime()))
|
||||
.limit(maxApplicationSessions)
|
||||
.toList();
|
||||
|
||||
boolean hasUserActiveSession =
|
||||
// activeSessions.stream().anyMatch(s ->
|
||||
// s.getSessionId().equals(sessionId));
|
||||
activeSessions.stream()
|
||||
.anyMatch(
|
||||
s ->
|
||||
s.getSessionId().equals(sessionId)
|
||||
// && !s.isExpired()
|
||||
&& s.getPrincipalName()
|
||||
.equals(principal.getName()));
|
||||
|
||||
final String currentPrincipal = principal.getName();
|
||||
|
||||
long userSessions =
|
||||
sessionsInterface.getAllSessions().stream()
|
||||
.filter(
|
||||
s ->
|
||||
!s.isExpired()
|
||||
&& currentPrincipal.equals(
|
||||
s.getPrincipalName()))
|
||||
.count();
|
||||
|
||||
int maxUserSessions = sessionsInterface.getMaxUserSessions();
|
||||
|
||||
log.info(
|
||||
"Active sessions for {}: {} (max: {}) | Total: {} (max: {}) | Active"
|
||||
+ " sessions: {}",
|
||||
currentPrincipal,
|
||||
userSessions,
|
||||
maxUserSessions,
|
||||
totalSessionsNonExpired,
|
||||
maxApplicationSessions,
|
||||
hasUserActiveSession);
|
||||
|
||||
if ((userSessions >= maxUserSessions
|
||||
|| totalSessionsNonExpired >= maxApplicationSessions)
|
||||
&& !hasUserActiveSession) {
|
||||
log.info(
|
||||
"Max sessions reached for this user. To continue on this device, please"
|
||||
+ " close your session in another browser.");
|
||||
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If session is not registered yet, register it; otherwise, update the last request
|
||||
// timestamp.
|
||||
if (!hasUserActiveSession) {
|
||||
log.info("Register session: {}", sessionId);
|
||||
sessionsInterface.registerSession(finalSession);
|
||||
} else {
|
||||
log.info("Update session last request: {}", sessionId);
|
||||
sessionsInterface.updateSessionLastRequest(sessionId);
|
||||
}
|
||||
return true;
|
||||
} else if (principal == null) {
|
||||
if (session == null) {
|
||||
session = request.getSession(true);
|
||||
}
|
||||
final HttpSession finalSession = session;
|
||||
String sessionId = finalSession.getId();
|
||||
|
||||
int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions();
|
||||
|
||||
Collection<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
|
||||
|
||||
long totalSessions = allSessions.stream().filter(s -> !s.isExpired()).count();
|
||||
|
||||
List<SessionsModelInterface> activeSessions =
|
||||
allSessions.stream()
|
||||
.filter(s -> !s.isExpired())
|
||||
.sorted(
|
||||
(s1, s2) ->
|
||||
Long.compare(
|
||||
s2.getLastRequest().getTime(),
|
||||
s1.getLastRequest().getTime()))
|
||||
.limit(maxApplicationSessions)
|
||||
.toList();
|
||||
|
||||
boolean hasUserActiveSession =
|
||||
activeSessions.stream().anyMatch(s -> s.getSessionId().equals(sessionId));
|
||||
|
||||
log.info(
|
||||
"Active sessions for anonymous: Total: {} (max: {}) | Active sessions: {}",
|
||||
totalSessions,
|
||||
maxApplicationSessions,
|
||||
hasUserActiveSession);
|
||||
|
||||
if (totalSessions >= maxApplicationSessions && !hasUserActiveSession) {
|
||||
sessionsInterface.removeSession(finalSession);
|
||||
log.info(
|
||||
"Max sessions reached for this user. To continue on this device, please"
|
||||
+ " close your session in another browser.");
|
||||
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
|
||||
return false;
|
||||
}
|
||||
if (!hasUserActiveSession) {
|
||||
log.debug("Register session: {}", sessionId);
|
||||
sessionsInterface.registerSession(finalSession);
|
||||
} else {
|
||||
log.debug("Update session last request: {}", sessionId);
|
||||
sessionsInterface.updateSessionLastRequest(sessionId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEnabled;
|
||||
|
||||
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
@ -35,7 +35,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return !showUpdateOnlyAdmin;
|
||||
}
|
||||
if (authentication.getName().equalsIgnoreCase("anonymousUser")) {
|
||||
if ("anonymousUser".equalsIgnoreCase(authentication.getName())) {
|
||||
return !showUpdateOnlyAdmin;
|
||||
}
|
||||
Optional<User> user = userRepository.findByUsername(authentication.getName());
|
||||
|
@ -36,6 +36,7 @@ import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService;
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
import stirling.software.SPDF.config.security.session.PreLogoutDataCaptureHandler;
|
||||
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
import stirling.software.SPDF.model.User;
|
||||
@ -149,7 +150,7 @@ public class SecurityConfiguration {
|
||||
sessionManagement ->
|
||||
sessionManagement
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(10)
|
||||
.maximumSessions(sessionRegistry.getMaxUserSessions())
|
||||
.maxSessionsPreventsLogin(false)
|
||||
.sessionRegistry(sessionRegistry)
|
||||
.expiredUrl("/login?logout=true"));
|
||||
@ -158,6 +159,8 @@ public class SecurityConfiguration {
|
||||
http.logout(
|
||||
logout ->
|
||||
logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
|
||||
.addLogoutHandler(
|
||||
new PreLogoutDataCaptureHandler(sessionRegistry))
|
||||
.logoutSuccessHandler(
|
||||
new CustomLogoutSuccessHandler(applicationProperties))
|
||||
.clearAuthentication(true)
|
||||
|
@ -11,7 +11,6 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@ -397,23 +396,10 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public void invalidateUserSessions(String username) {
|
||||
String usernameP = "";
|
||||
|
||||
for (Object principal : sessionRegistry.getAllPrincipals()) {
|
||||
for (SessionInformation sessionsInformation :
|
||||
sessionRegistry.getAllSessions(principal, false)) {
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
usernameP = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
usernameP = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
usernameP = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
usernameP = stringUser;
|
||||
}
|
||||
if (usernameP.equalsIgnoreCase(username)) {
|
||||
sessionRegistry.expireSession(sessionsInformation.getSessionId());
|
||||
}
|
||||
String usernameP = UserUtils.getUsernameFromPrincipal(principal);
|
||||
if (usernameP.equalsIgnoreCase(username)) {
|
||||
sessionRegistry.expireAllSessionsByPrincipalName(usernameP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,6 @@ import java.sql.SQLException;
|
||||
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@ -17,6 +16,7 @@ import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import stirling.software.SPDF.config.security.LoginAttemptService;
|
||||
import stirling.software.SPDF.config.security.UserService;
|
||||
import stirling.software.SPDF.config.security.UserUtils;
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.SPDF.model.AuthenticationType;
|
||||
@ -45,13 +45,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
throws ServletException, IOException {
|
||||
|
||||
Object principal = authentication.getPrincipal();
|
||||
String username = "";
|
||||
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
username = oAuth2User.getName();
|
||||
} else if (principal instanceof UserDetails detailsUser) {
|
||||
username = detailsUser.getUsername();
|
||||
}
|
||||
String username = UserUtils.getUsernameFromPrincipal(principal);
|
||||
|
||||
// Get the saved request
|
||||
HttpSession session = request.getSession(false);
|
||||
|
@ -1,30 +1,276 @@
|
||||
package stirling.software.SPDF.config.security.session;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.servlet.http.HttpSessionEvent;
|
||||
import jakarta.servlet.http.HttpSessionListener;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.interfaces.SessionsInterface;
|
||||
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
|
||||
import stirling.software.SPDF.config.security.UserUtils;
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CustomHttpSessionListener implements HttpSessionListener {
|
||||
public class CustomHttpSessionListener implements HttpSessionListener, SessionsInterface {
|
||||
|
||||
private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final boolean loginEnabled;
|
||||
private final boolean runningEE;
|
||||
|
||||
@Autowired
|
||||
public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) {
|
||||
@Value("${server.servlet.session.timeout:30m}")
|
||||
private Duration defaultMaxInactiveInterval;
|
||||
|
||||
public CustomHttpSessionListener(
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabled,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
ApplicationProperties applicationProperties) {
|
||||
super();
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabled = loginEnabled;
|
||||
this.runningEE = runningEE;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sessionCreated(HttpSessionEvent se) {}
|
||||
public Collection<SessionsModelInterface> getAllNonExpiredSessions() {
|
||||
return sessionPersistentRegistry.getAllSessionsNotExpired().stream()
|
||||
.map(session -> (SessionsModelInterface) session)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<SessionsModelInterface> getAllSessions(Object principalName, boolean expired) {
|
||||
return sessionPersistentRegistry.getAllSessions().stream()
|
||||
.filter(s -> s.getPrincipalName().equals(principalName))
|
||||
.filter(s -> expired == s.isExpired())
|
||||
.sorted(Comparator.comparing(SessionsModelInterface::getLastRequest))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<SessionsModelInterface> getAllSessions() {
|
||||
return new ArrayList<>(sessionPersistentRegistry.getAllSessions());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSessionLastRequest(String sessionId) {
|
||||
sessionPersistentRegistry.refreshLastRequest(sessionId);
|
||||
}
|
||||
|
||||
public Optional<SessionsModelInterface> findLatestSession(String principalName) {
|
||||
return getAllSessions(principalName, false).stream()
|
||||
.filter(s -> s.getPrincipalName().equals(principalName))
|
||||
.max(Comparator.comparing(SessionsModelInterface::getLastRequest));
|
||||
}
|
||||
|
||||
public void expireSession(String sessionId) {
|
||||
sessionPersistentRegistry.expireSession(sessionId);
|
||||
}
|
||||
|
||||
public void expireSession(String sessionId, boolean expiredByAdmin) {
|
||||
sessionPersistentRegistry.expireSession(sessionId, expiredByAdmin);
|
||||
}
|
||||
|
||||
public int getMaxInactiveInterval() {
|
||||
return (int) defaultMaxInactiveInterval.getSeconds();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sessionCreated(HttpSessionEvent se) {
|
||||
HttpSession session = se.getSession();
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
SecurityContext securityContext = SecurityContextHolder.getContext();
|
||||
if (securityContext == null) {
|
||||
return;
|
||||
}
|
||||
Authentication authentication = securityContext.getAuthentication();
|
||||
if (authentication == null) {
|
||||
return;
|
||||
}
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal == null) {
|
||||
return;
|
||||
}
|
||||
String principalName = UserUtils.getUsernameFromPrincipal(principal);
|
||||
if (principalName == null) {
|
||||
return;
|
||||
}
|
||||
if ("anonymousUser".equals(principalName) && loginEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
int allNonExpiredSessions;
|
||||
|
||||
if ("anonymousUser".equals(principalName) && !loginEnabled) {
|
||||
allNonExpiredSessions =
|
||||
(int) getAllSessions().stream().filter(s -> !s.isExpired()).count();
|
||||
} else {
|
||||
allNonExpiredSessions =
|
||||
(int)
|
||||
getAllSessions().stream()
|
||||
.filter(s -> !s.isExpired())
|
||||
.filter(s -> s.getPrincipalName().equals(principalName))
|
||||
.count();
|
||||
}
|
||||
|
||||
int all =
|
||||
getAllSessions().stream()
|
||||
.filter(s -> !s.isExpired() && s.getPrincipalName().equals(principalName))
|
||||
.toList()
|
||||
.size();
|
||||
boolean isAnonymousUserWithLogin = "anonymousUser".equals(principalName) && loginEnabled;
|
||||
log.info(
|
||||
"all {} allNonExpiredSessions {} {} isAnonymousUserWithLogin {}",
|
||||
all,
|
||||
allNonExpiredSessions,
|
||||
getMaxUserSessions(),
|
||||
isAnonymousUserWithLogin);
|
||||
|
||||
if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithLogin) {
|
||||
log.info("Session {} Expired=TRUE", session.getId());
|
||||
sessionPersistentRegistry.expireSession(session.getId());
|
||||
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
|
||||
// if (allNonExpiredSessions > getMaxUserSessions()) {
|
||||
// enforceMaxSessionsForPrincipal(principalName);
|
||||
// }
|
||||
} else if (all >= getMaxUserSessions() && !isAnonymousUserWithLogin) {
|
||||
enforceMaxSessionsForPrincipal(principalName);
|
||||
log.info("Session {} Expired=TRUE", session.getId());
|
||||
} else if (isAnonymousUserWithLogin) {
|
||||
sessionPersistentRegistry.expireSession(session.getId());
|
||||
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
|
||||
} else {
|
||||
log.info("Session created: {}", session.getId());
|
||||
sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName);
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceMaxSessionsForPrincipal(String principalName) {
|
||||
// Alle aktiven Sessions des Benutzers über das gemeinsame Interface abrufen
|
||||
List<SessionsModelInterface> userSessions =
|
||||
getAllSessions().stream()
|
||||
.filter(s -> !s.isExpired() && principalName.equals(s.getPrincipalName()))
|
||||
.sorted(Comparator.comparing(SessionsModelInterface::getLastRequest))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int maxAllowed = getMaxUserSessions();
|
||||
if (userSessions.size() > maxAllowed) {
|
||||
int sessionsToRemove = userSessions.size() - maxAllowed;
|
||||
log.debug(
|
||||
"User {} has {} active sessions, removing {} oldest session(s).",
|
||||
principalName,
|
||||
userSessions.size(),
|
||||
sessionsToRemove);
|
||||
for (int i = 0; i < sessionsToRemove; i++) {
|
||||
SessionsModelInterface sessionModel = userSessions.get(i);
|
||||
// Statt auf die HttpSession zuzugreifen, rufen wir die Registry-Methoden auf,
|
||||
// die die Session anhand der Session-ID invalidieren und entfernen.
|
||||
sessionPersistentRegistry.expireSession(sessionModel.getSessionId());
|
||||
sessionPersistentRegistry.removeSessionInformation(sessionModel.getSessionId());
|
||||
log.debug(
|
||||
"Removed session {} for principal {}",
|
||||
sessionModel.getSessionId(),
|
||||
principalName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sessionDestroyed(HttpSessionEvent se) {
|
||||
sessionPersistentRegistry.expireSession(se.getSession().getId());
|
||||
HttpSession session = se.getSession();
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
SessionInformation sessionsInfo =
|
||||
sessionPersistentRegistry.getSessionInformation(session.getId());
|
||||
if (sessionsInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Date lastRequest = sessionsInfo.getLastRequest();
|
||||
int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds();
|
||||
Instant now = Instant.now();
|
||||
Instant expirationTime =
|
||||
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
|
||||
|
||||
if (now.isAfter(expirationTime)) {
|
||||
sessionPersistentRegistry.expireSession(session.getId());
|
||||
session.invalidate();
|
||||
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
|
||||
log.debug("Session {} expired=TRUE", session.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerSession(HttpSession session) {
|
||||
sessionCreated(new HttpSessionEvent(session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSession(HttpSession session) {
|
||||
sessionPersistentRegistry.expireSession(session.getId());
|
||||
session.invalidate();
|
||||
sessionPersistentRegistry.removeSessionInformation(session.getId());
|
||||
log.debug("Session {} expired=TRUE", session.getId());
|
||||
}
|
||||
|
||||
// Get the maximum number of application sessions
|
||||
@Override
|
||||
public int getMaxApplicationSessions() {
|
||||
if (runningEE) {
|
||||
return getMaxUsers() * getMaxUserSessions();
|
||||
}
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
// Get the maximum number of user sessions
|
||||
@Override
|
||||
public int getMaxUserSessions() {
|
||||
if (loginEnabled) {
|
||||
if (runningEE) {
|
||||
return 3;
|
||||
}
|
||||
return Integer.MAX_VALUE; // (3)
|
||||
}
|
||||
return Integer.MAX_VALUE; // (10)
|
||||
}
|
||||
|
||||
// Get the maximum number of user sessions
|
||||
@Override
|
||||
public int getMaxUsers() {
|
||||
if (loginEnabled) {
|
||||
if (runningEE) {
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
if (maxUsers > 0) {
|
||||
return maxUsers;
|
||||
}
|
||||
}
|
||||
return Integer.MAX_VALUE; // (50)
|
||||
}
|
||||
return Integer.MAX_VALUE; // (1)
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,30 +1,45 @@
|
||||
package stirling.software.SPDF.config.security.session;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.session.SessionRegistry;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.security.UserUtils;
|
||||
import stirling.software.SPDF.model.SessionEntity;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SessionPersistentRegistry implements SessionRegistry {
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final boolean runningEE;
|
||||
private final boolean loginEnabled;
|
||||
|
||||
@Value("${server.servlet.session.timeout:30m}")
|
||||
private Duration defaultMaxInactiveInterval;
|
||||
|
||||
public SessionPersistentRegistry(SessionRepository sessionRepository) {
|
||||
public SessionPersistentRegistry(
|
||||
SessionRepository sessionRepository,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
@Qualifier("loginEnabled") boolean loginEnabled) {
|
||||
this.runningEE = runningEE;
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.loginEnabled = loginEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -41,17 +56,7 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
public List<SessionInformation> getAllSessions(
|
||||
Object principal, boolean includeExpiredSessions) {
|
||||
List<SessionInformation> sessionInformations = new ArrayList<>();
|
||||
String principalName = null;
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
principalName = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
principalName = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
principalName = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
principalName = stringUser;
|
||||
}
|
||||
String principalName = UserUtils.getUsernameFromPrincipal(principal);
|
||||
|
||||
if (principalName != null) {
|
||||
List<SessionEntity> sessionEntities =
|
||||
@ -72,33 +77,28 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
@Override
|
||||
@Transactional
|
||||
public void registerNewSession(String sessionId, Object principal) {
|
||||
String principalName = null;
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
principalName = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
principalName = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
principalName = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
principalName = stringUser;
|
||||
}
|
||||
String principalName = UserUtils.getUsernameFromPrincipal(principal);
|
||||
|
||||
if (principalName != null) {
|
||||
// Clear old sessions for the principal (unsure if needed)
|
||||
// List<SessionEntity> existingSessions =
|
||||
// sessionRepository.findByPrincipalName(principalName);
|
||||
// for (SessionEntity session : existingSessions) {
|
||||
// session.setExpired(true);
|
||||
// sessionRepository.save(session);
|
||||
// }
|
||||
|
||||
SessionEntity sessionEntity = new SessionEntity();
|
||||
sessionEntity.setSessionId(sessionId);
|
||||
int sessionUserCount = getAllSessions(principalName, false).size();
|
||||
|
||||
SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId);
|
||||
if (sessionEntity == null) {
|
||||
sessionEntity = new SessionEntity();
|
||||
sessionEntity.setSessionId(sessionId);
|
||||
log.info("Registering new session for principal: {}", principalName);
|
||||
}
|
||||
sessionEntity.setPrincipalName(principalName);
|
||||
sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date
|
||||
sessionEntity.setExpired(false);
|
||||
|
||||
if (sessionUserCount >= getMaxUserSessions()) {
|
||||
sessionEntity.setExpired(true);
|
||||
} else {
|
||||
sessionEntity.setExpired(false);
|
||||
}
|
||||
sessionRepository.save(sessionEntity);
|
||||
sessionRepository.flush();
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,16 +106,57 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
@Transactional
|
||||
public void removeSessionInformation(String sessionId) {
|
||||
sessionRepository.deleteById(sessionId);
|
||||
sessionRepository.flush();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeSessionInformationByPrincipalName(String principalName) {
|
||||
sessionRepository.deleteByPrincipalName(principalName);
|
||||
sessionRepository.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void refreshLastRequest(String sessionId) {
|
||||
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
|
||||
if (sessionEntityOpt.isPresent()) {
|
||||
SessionEntity sessionEntity = sessionEntityOpt.get();
|
||||
SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId);
|
||||
if (sessionEntity != null) {
|
||||
sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date
|
||||
sessionRepository.save(sessionEntity);
|
||||
} else {
|
||||
log.error("Session not found for session ID: {}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void expireOldestSessionForPrincipal(String principalName) {
|
||||
// Alle Sessions des principalName abrufen
|
||||
List<SessionEntity> sessionsForPrincipal =
|
||||
sessionRepository.findByPrincipalName(principalName);
|
||||
|
||||
// Nur die nicht abgelaufenen Sessions filtern
|
||||
List<SessionEntity> nonExpiredSessions =
|
||||
sessionsForPrincipal.stream()
|
||||
.filter(session -> !session.isExpired())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (nonExpiredSessions.isEmpty()) {
|
||||
log.debug("No active sessions found for principal {}", principalName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Die Session mit dem ältesten lastRequest ermitteln
|
||||
Optional<SessionEntity> oldestSessionOpt =
|
||||
nonExpiredSessions.stream()
|
||||
.min(Comparator.comparing(SessionEntity::getLastRequest));
|
||||
|
||||
if (oldestSessionOpt.isPresent()) {
|
||||
SessionEntity oldestSession = oldestSessionOpt.get();
|
||||
expireSession(oldestSession.getSessionId());
|
||||
removeSessionInformation(oldestSession.getSessionId());
|
||||
log.debug(
|
||||
"Oldest session {} for principal {} has been marked as expired",
|
||||
oldestSession.getSessionId(),
|
||||
principalName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +190,61 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
SessionEntity sessionEntity = sessionEntityOpt.get();
|
||||
sessionEntity.setExpired(true); // Set expired to true
|
||||
sessionRepository.save(sessionEntity);
|
||||
log.debug("Session expired: {}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public void expireSession(String sessionId, boolean expiredByAdmin) {
|
||||
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
|
||||
if (sessionEntityOpt.isPresent()) {
|
||||
SessionEntity sessionEntity = sessionEntityOpt.get();
|
||||
sessionEntity.setExpired(true); // Set expired to true
|
||||
sessionEntity.setAdminExpired(expiredByAdmin);
|
||||
sessionRepository.save(sessionEntity);
|
||||
log.debug("Session expired: {}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all sessions as expired
|
||||
public void expireAllSessions() {
|
||||
List<SessionEntity> sessionEntities = sessionRepository.findAll();
|
||||
for (SessionEntity sessionEntity : sessionEntities) {
|
||||
sessionEntity.setExpired(true); // Set expired to true
|
||||
sessionRepository.save(sessionEntity);
|
||||
log.debug("Session expired: {}", sessionEntity.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all sessions as expired by username
|
||||
public void expireAllSessionsByUsername(String username) {
|
||||
List<SessionEntity> sessionEntities = sessionRepository.findByPrincipalName(username);
|
||||
for (SessionEntity sessionEntity : sessionEntities) {
|
||||
sessionEntity.setExpired(true); // Set expired to true
|
||||
sessionRepository.save(sessionEntity);
|
||||
log.debug("Session expired: {}", sessionEntity.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all sessions as expired for a given principal name
|
||||
public void expireAllSessionsByPrincipalName(String principalName) {
|
||||
List<SessionEntity> sessionEntities = sessionRepository.findByPrincipalName(principalName);
|
||||
log.debug("Session entities: {}", sessionEntities.size());
|
||||
for (SessionEntity sessionEntity : sessionEntities) {
|
||||
log.debug(
|
||||
"Session expired: {} {} {}",
|
||||
sessionEntity.getPrincipalName(),
|
||||
sessionEntity.isExpired(),
|
||||
sessionEntity.getSessionId());
|
||||
sessionEntity.setExpired(true); // Set expired to true
|
||||
removeSessionInformation(sessionEntity.getSessionId());
|
||||
}
|
||||
|
||||
sessionEntities = sessionRepository.findByPrincipalName(principalName);
|
||||
log.debug("Session entities: {}", sessionEntities.size());
|
||||
for (SessionEntity sessionEntity : sessionEntities) {
|
||||
if (sessionEntity.getPrincipalName().equals(principalName)) {
|
||||
log.debug("Session expired: {}", sessionEntity.getSessionId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,10 +259,19 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
}
|
||||
|
||||
// Update session details by principal name
|
||||
public void updateSessionByPrincipalName(
|
||||
String principalName, boolean expired, Date lastRequest) {
|
||||
sessionRepository.saveByPrincipalName(expired, lastRequest, principalName);
|
||||
}
|
||||
// public void updateSessionByPrincipalName(
|
||||
// String principalName, boolean expired, Date lastRequest) {
|
||||
// sessionRepository.saveByPrincipalName(expired, lastRequest, principalName);
|
||||
// }
|
||||
|
||||
// Update session details by session ID
|
||||
// public void updateSessionBySessionId(String sessionId) {
|
||||
// SessionEntity sessionEntity = getSessionEntity(sessionId);
|
||||
// if (sessionEntity != null) {
|
||||
// sessionEntity.setLastRequest(new Date());
|
||||
// sessionRepository.save(sessionEntity);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Find the latest session for a given principal name
|
||||
public Optional<SessionEntity> findLatestSession(String principalName) {
|
||||
@ -178,15 +283,29 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
// Sort sessions by lastRequest in descending order
|
||||
Collections.sort(
|
||||
allSessions,
|
||||
new Comparator<SessionEntity>() {
|
||||
@Override
|
||||
public int compare(SessionEntity s1, SessionEntity s2) {
|
||||
// Sort by lastRequest in descending order
|
||||
return s2.getLastRequest().compareTo(s1.getLastRequest());
|
||||
}
|
||||
});
|
||||
(SessionEntity s1, SessionEntity s2) ->
|
||||
s2.getLastRequest().compareTo(s1.getLastRequest()));
|
||||
|
||||
// The first session in the list is the latest session for the given principal name
|
||||
return Optional.of(allSessions.get(0));
|
||||
}
|
||||
|
||||
// Get the maximum number of sessions
|
||||
public int getMaxSessions() {
|
||||
if (runningEE) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
return getMaxUserSessions() * 10;
|
||||
}
|
||||
|
||||
// Get the maximum number of user sessions
|
||||
public int getMaxUserSessions() {
|
||||
if (loginEnabled) {
|
||||
if (runningEE) {
|
||||
return 3;
|
||||
}
|
||||
return Integer.MAX_VALUE; // (3)
|
||||
}
|
||||
return Integer.MAX_VALUE; // (10)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.config.security.session;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.core.session.SessionRegistryImpl;
|
||||
@ -14,7 +15,9 @@ public class SessionRegistryConfig {
|
||||
|
||||
@Bean
|
||||
public SessionPersistentRegistry sessionPersistentRegistry(
|
||||
SessionRepository sessionRepository) {
|
||||
return new SessionPersistentRegistry(sessionRepository);
|
||||
SessionRepository sessionRepository,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
@Qualifier("loginEnabled") boolean loginEnabled) {
|
||||
return new SessionPersistentRegistry(sessionRepository, runningEE, loginEnabled);
|
||||
}
|
||||
}
|
||||
|
@ -21,10 +21,13 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
|
||||
|
||||
SessionEntity findBySessionId(String sessionId);
|
||||
|
||||
void deleteByPrincipalName(String principalName);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE s.principalName = :principalName")
|
||||
"UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE"
|
||||
+ " s.principalName = :principalName")
|
||||
void saveByPrincipalName(
|
||||
@Param("expired") boolean expired,
|
||||
@Param("lastRequest") Date lastRequest,
|
||||
|
@ -5,23 +5,43 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SessionScheduled {
|
||||
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
|
||||
public SessionScheduled(SessionPersistentRegistry sessionPersistentRegistry) {
|
||||
public SessionScheduled(
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0/5 * * * ?")
|
||||
public void expireSessions() {
|
||||
Instant now = Instant.now();
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
for (Object principal : sessionPersistentRegistry.getAllPrincipals()) {
|
||||
if (principal == null) {
|
||||
continue;
|
||||
} else if (principal instanceof String stringPrincipal) {
|
||||
// Expire anonymousUser sessions if login is enabled
|
||||
if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) {
|
||||
sessionPersistentRegistry.expireAllSessionsByPrincipalName(stringPrincipal);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
List<SessionInformation> sessionInformations =
|
||||
sessionPersistentRegistry.getAllSessions(principal, false);
|
||||
for (SessionInformation sessionInformation : sessionInformations) {
|
||||
@ -31,6 +51,19 @@ public class SessionScheduled {
|
||||
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
|
||||
if (now.isAfter(expirationTime)) {
|
||||
sessionPersistentRegistry.expireSession(sessionInformation.getSessionId());
|
||||
sessionInformation.expireNow();
|
||||
|
||||
// Invalidate current authentication if expired session belongs to current user
|
||||
if (authentication != null && principal.equals(authentication.getPrincipal())) {
|
||||
authentication.setAuthenticated(false);
|
||||
}
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
log.debug(
|
||||
"Session expired for principal: {} SessionID: {}",
|
||||
principal,
|
||||
sessionInformation.getSessionId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
@ -13,8 +13,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@ -30,7 +28,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.security.UserService;
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.SPDF.config.security.UserUtils;
|
||||
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
import stirling.software.SPDF.model.AuthenticationType;
|
||||
@ -304,19 +302,11 @@ public class UserController {
|
||||
if (!enabled) {
|
||||
// Invalidate all sessions if the user is being disabled
|
||||
List<Object> principals = sessionRegistry.getAllPrincipals();
|
||||
String userNameP = "";
|
||||
for (Object principal : principals) {
|
||||
List<SessionInformation> sessionsInformation =
|
||||
sessionRegistry.getAllSessions(principal, false);
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
userNameP = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
userNameP = oAuth2User.getName();
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
userNameP = saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
userNameP = stringUser;
|
||||
}
|
||||
String userNameP = UserUtils.getUsernameFromPrincipal(principal);
|
||||
|
||||
if (userNameP.equalsIgnoreCase(username)) {
|
||||
for (SessionInformation sessionInfo : sessionsInformation) {
|
||||
sessionRegistry.expireSession(sessionInfo.getSessionId());
|
||||
|
@ -29,8 +29,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
|
||||
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.SPDF.config.security.session.CustomHttpSessionListener;
|
||||
import stirling.software.SPDF.model.ApplicationProperties;
|
||||
import stirling.software.SPDF.model.ApplicationProperties.Security;
|
||||
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
|
||||
@ -53,26 +54,29 @@ public class AccountWebController {
|
||||
public static final String OAUTH_2_AUTHORIZATION = "/oauth2/authorization/";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final CustomHttpSessionListener customHttpSessionListener;
|
||||
// Assuming you have a repository for user operations
|
||||
private final UserRepository userRepository;
|
||||
private final boolean loginEnabledValue;
|
||||
private final boolean runningEE;
|
||||
|
||||
public AccountWebController(
|
||||
ApplicationProperties applicationProperties,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
UserRepository userRepository,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
CustomHttpSessionListener customHttpSessionListener) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.userRepository = userRepository;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.runningEE = runningEE;
|
||||
this.customHttpSessionListener = customHttpSessionListener;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
public String login(HttpServletRequest request, Model model, Authentication authentication) {
|
||||
// If the user is already authenticated, redirect them to the home page.
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
if ((authentication != null && authentication.isAuthenticated()) || !loginEnabledValue) {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
@ -150,6 +154,7 @@ public class AccountWebController {
|
||||
case "badCredentials" -> error = "login.invalid";
|
||||
case "locked" -> error = "login.locked";
|
||||
case "oauth2AuthenticationError" -> error = "userAlreadyExistsOAuthMessage";
|
||||
case "expiredSession" -> error = "expiredSessionMessage";
|
||||
}
|
||||
|
||||
model.addAttribute("error", error);
|
||||
@ -210,14 +215,20 @@ public class AccountWebController {
|
||||
@GetMapping("/adminSettings")
|
||||
public String showAddUserForm(
|
||||
HttpServletRequest request, Model model, Authentication authentication) {
|
||||
String currentSessionId = request.getSession().getId();
|
||||
|
||||
List<User> allUsers = userRepository.findAll();
|
||||
Iterator<User> iterator = allUsers.iterator();
|
||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||
// Map to store session information and user activity status
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, List<SessionsModelInterface>> userActiveSessions = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
int maxSessions = customHttpSessionListener.getMaxApplicationSessions();
|
||||
int maxUserSessions = customHttpSessionListener.getMaxUserSessions();
|
||||
int sessionCount = customHttpSessionListener.getAllNonExpiredSessions().size();
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
@ -230,13 +241,13 @@ public class AccountWebController {
|
||||
}
|
||||
}
|
||||
// Determine the user's session status and last request time
|
||||
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
|
||||
int maxInactiveInterval = customHttpSessionListener.getMaxInactiveInterval();
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(user.getUsername());
|
||||
Date lastRequest;
|
||||
Optional<SessionsModelInterface> latestSession =
|
||||
customHttpSessionListener.findLatestSession(user.getUsername());
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
SessionEntity sessionEntity = (SessionEntity) latestSession.get();
|
||||
Date lastAccessedTime = sessionEntity.getLastRequest();
|
||||
Instant now = Instant.now();
|
||||
// Calculate session expiration and update session status accordingly
|
||||
@ -245,7 +256,7 @@ public class AccountWebController {
|
||||
.toInstant()
|
||||
.plus(maxInactiveInterval, ChronoUnit.SECONDS);
|
||||
if (now.isAfter(expirationTime)) {
|
||||
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
|
||||
customHttpSessionListener.expireSession(sessionEntity.getSessionId());
|
||||
} else {
|
||||
hasActiveSession = !sessionEntity.isExpired();
|
||||
}
|
||||
@ -262,6 +273,9 @@ public class AccountWebController {
|
||||
if (!user.isEnabled()) {
|
||||
disabledUsers++;
|
||||
}
|
||||
List<SessionsModelInterface> sessionInformations =
|
||||
customHttpSessionListener.getAllSessions(user.getUsername(), false);
|
||||
userActiveSessions.put(user.getUsername(), sessionInformations);
|
||||
}
|
||||
}
|
||||
// Sort users by active status and last request date
|
||||
@ -323,15 +337,21 @@ public class AccountWebController {
|
||||
}
|
||||
|
||||
model.addAttribute("users", sortedUsers);
|
||||
model.addAttribute("currentUsername", authentication.getName());
|
||||
model.addAttribute("currentSessionId", currentSessionId);
|
||||
if (authentication != null) {
|
||||
model.addAttribute("currentUsername", authentication.getName());
|
||||
}
|
||||
model.addAttribute("roleDetails", roleDetails);
|
||||
model.addAttribute("userSessions", userSessions);
|
||||
model.addAttribute("userLastRequest", userLastRequest);
|
||||
model.addAttribute("userActiveSessions", userActiveSessions);
|
||||
model.addAttribute("totalUsers", allUsers.size());
|
||||
model.addAttribute("activeUsers", activeUsers);
|
||||
model.addAttribute("disabledUsers", disabledUsers);
|
||||
|
||||
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
model.addAttribute("maxSessions", maxSessions);
|
||||
model.addAttribute("maxUserSessions", maxUserSessions);
|
||||
model.addAttribute("sessionCount", sessionCount);
|
||||
model.addAttribute("maxPaidUsers", customHttpSessionListener.getMaxUsers());
|
||||
return "adminSettings";
|
||||
}
|
||||
|
||||
|
@ -9,15 +9,36 @@ import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "sessions")
|
||||
public class SessionEntity implements Serializable {
|
||||
public class SessionEntity implements Serializable, SessionsModelInterface {
|
||||
@Id private String sessionId;
|
||||
|
||||
private String principalName;
|
||||
|
||||
private Date lastRequest;
|
||||
|
||||
private boolean expired;
|
||||
|
||||
private Boolean adminExpired = false;
|
||||
|
||||
@Override
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPrincipalName() {
|
||||
return principalName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Date getLastRequest() {
|
||||
return lastRequest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExpired() {
|
||||
return expired;
|
||||
}
|
||||
}
|
||||
|
@ -40,4 +40,4 @@ springdoc.api-docs.path=/v1/api-docs
|
||||
# Set the URL of the OpenAPI JSON for the Swagger UI
|
||||
springdoc.swagger-ui.url=/v1/api-docs
|
||||
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
|
||||
posthog.host=https://eu.i.posthog.com
|
||||
posthog.host=https://eu.i.posthog.com
|
||||
|
@ -176,6 +176,7 @@ settings.accountSettings=Kontoeinstellungen
|
||||
settings.bored.help=Aktiviert das Easter-Egg-Spiel
|
||||
settings.cacheInputs.name=Formulareingaben speichern
|
||||
settings.cacheInputs.help=Aktivieren, um zuvor verwendete Eingaben für zukünftige Durchläufe zu speichern
|
||||
settings.userSessions=Benutzersitzungen
|
||||
|
||||
changeCreds.title=Anmeldeinformationen ändern
|
||||
changeCreds.header=Aktualisieren Sie Ihre Kontodaten
|
||||
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Aktive Benutzer:
|
||||
adminUserSettings.disabledUsers=Deaktivierte Benutzer:
|
||||
adminUserSettings.totalUsers=Gesamtzahl der Benutzer:
|
||||
adminUserSettings.lastRequest=Letzte Anfrage
|
||||
adminUserSettings.userSessions=Benutzersitzungen
|
||||
adminUserSettings.totalSessions=Gesamtzahl der Sitzungen:
|
||||
adminUserSettings.usage=View Usage
|
||||
|
||||
endpointStatistics.title=Endpoint Statistics
|
||||
@ -285,6 +288,14 @@ database.notSupported=Diese Funktion ist für deine Datenbankverbindung nicht ve
|
||||
session.expired=Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.
|
||||
session.refreshPage=Seite aktualisieren
|
||||
|
||||
#################
|
||||
# USER SESSION #
|
||||
#################
|
||||
userSession.title=Benutzersitzungen
|
||||
userSession.header=Benutzersitzungen
|
||||
userSession.maxUserSession=Wenn die maximale Anzahl Sitzungen für diesen Benutzer erreicht ist, können Sie hier andere Anmeldungen beenden, um auf diesem Gerät fortzufahren.
|
||||
userSession.lastRequest=Letzte Aufrufe
|
||||
|
||||
#############
|
||||
# HOME-PAGE #
|
||||
#############
|
||||
|
@ -176,6 +176,7 @@ settings.accountSettings=Account Settings
|
||||
settings.bored.help=Enables easter egg game
|
||||
settings.cacheInputs.name=Save form inputs
|
||||
settings.cacheInputs.help=Enable to store previously used inputs for future runs
|
||||
settings.userSessions=User Sessions
|
||||
|
||||
changeCreds.title=Change Credentials
|
||||
changeCreds.header=Update Your Account Details
|
||||
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Active Users:
|
||||
adminUserSettings.disabledUsers=Disabled Users:
|
||||
adminUserSettings.totalUsers=Total Users:
|
||||
adminUserSettings.lastRequest=Last Request
|
||||
adminUserSettings.userSessions=User sessions
|
||||
adminUserSettings.totalSessions=Total Sessions:
|
||||
adminUserSettings.usage=View Usage
|
||||
|
||||
endpointStatistics.title=Endpoint Statistics
|
||||
@ -285,6 +288,14 @@ database.notSupported=This function is not available for your database connectio
|
||||
session.expired=Your session has expired. Please refresh the page and try again.
|
||||
session.refreshPage=Refresh Page
|
||||
|
||||
#################
|
||||
# USER SESSION #
|
||||
#################
|
||||
userSession.title=User Sessions
|
||||
userSession.header=User Sessions
|
||||
userSession.maxUserSession=If the maximum number of sessions for this user is reached, you can end other logins here to continue on this device.
|
||||
userSession.lastRequest=last Request
|
||||
|
||||
#############
|
||||
# HOME-PAGE #
|
||||
#############
|
||||
@ -1426,7 +1437,7 @@ cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never
|
||||
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
|
||||
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
|
||||
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
|
||||
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off.
|
||||
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off.
|
||||
cookieBanner.preferencesModal.analytics.title=Analytics
|
||||
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
|
||||
|
||||
|
@ -1,319 +1,409 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}"></th:block>
|
||||
<style>
|
||||
.active-user {
|
||||
color: green;
|
||||
text-shadow: 0 0 5px green;
|
||||
}
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
.text-overflow {
|
||||
max-width: 100px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{adminUserSettings.title}, header=#{adminUserSettings.header})}">
|
||||
</th:block>
|
||||
<style>
|
||||
.active-user {
|
||||
color: green;
|
||||
text-shadow: 0 0 5px green;
|
||||
}
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-9 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
|
||||
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
||||
.text-overflow {
|
||||
max-width: 100px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-12 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon organize">manage_accounts</span>
|
||||
<span class="tool-header-text" th:text="#{adminUserSettings.header}">Admin User Control Settings</span>
|
||||
</div>
|
||||
|
||||
<!-- User Settings Title -->
|
||||
<div
|
||||
style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
|
||||
<a href="#" th:data-bs-toggle="${totalUsers >= maxPaidUsers} ? null : 'modal'"
|
||||
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
||||
th:class="${totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
|
||||
th:title="${totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||
</a>
|
||||
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#changeUserRoleModal" class="btn btn-outline-success"
|
||||
th:title="#{adminUserSettings.changeUserRole}">
|
||||
<span class="material-symbols-rounded">edit</span>
|
||||
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||
</a>
|
||||
|
||||
<a href="/usage" th:if="${@runningEE}" class="btn btn-outline-success" th:title="#{adminUserSettings.usage}">
|
||||
<span class="material-symbols-rounded">analytics</span>
|
||||
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
||||
</a>
|
||||
|
||||
<div class="my-4">
|
||||
<strong th:if="${@runningEE}" style="margin-left: 20px;"
|
||||
text="#{adminUserSettings.totalUsers}">runningEE</strong>
|
||||
<strong th:if="${!@runningEE}" style="margin-left: 20px;"
|
||||
text="#{adminUserSettings.totalUsers}">Non-Paid</strong>
|
||||
</div>
|
||||
|
||||
<!-- User Settings Title -->
|
||||
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
|
||||
<a href="#"
|
||||
th:data-bs-toggle="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : 'modal'"
|
||||
th:data-bs-target="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? null : '#addUserModal'"
|
||||
th:class="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
|
||||
th:title="${@runningProOrHigher && totalUsers >= maxPaidUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
|
||||
</a>
|
||||
|
||||
<a href="#"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#changeUserRoleModal"
|
||||
class="btn btn-outline-success"
|
||||
th:title="#{adminUserSettings.changeUserRole}">
|
||||
<span class="material-symbols-rounded">edit</span>
|
||||
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
|
||||
</a>
|
||||
|
||||
<a href="/usage" th:if="${@runningEE}"
|
||||
class="btn btn-outline-success"
|
||||
th:title="#{adminUserSettings.usage}">
|
||||
<span class="material-symbols-rounded">analytics</span>
|
||||
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
||||
</a>
|
||||
<div class="my-4">
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
|
||||
<span th:text="${totalUsers}"></span>
|
||||
<span th:if="${@runningProOrHigher}" th:text="' | ' + ${maxPaidUsers}"></span>
|
||||
|
||||
<div class="my-4">
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
|
||||
<span th:text="${totalUsers}"></span>
|
||||
<span th:if="${@runningProOrHigher}" th:text="'/'+${maxPaidUsers}"></span>
|
||||
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
|
||||
<span th:text="${activeUsers}"></span>
|
||||
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
|
||||
<span th:text="${disabledUsers}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
|
||||
<span th:text="${activeUsers}"></span>
|
||||
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
|
||||
<span th:text="${disabledUsers}"></span>
|
||||
<th:block>
|
||||
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalSessions}">Total Sessions:</strong>
|
||||
<span th:if="${@runningProOrHigher}" th:text="${sessionCount}"></span>
|
||||
<span th:text="' | ' + ${maxSessions}"></span>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
||||
<div class="alert alert-danger mb-auto">
|
||||
<span th:text="#{${addMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
<div th:if="${addMessage}" class="p-3"
|
||||
style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
||||
<div class="alert alert-danger mb-auto">
|
||||
<span th:text="#{${addMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
<div th:if="${changeMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
||||
<div class="alert alert-danger mb-auto">
|
||||
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${changeMessage}" class="p-3"
|
||||
style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
|
||||
<div class="alert alert-danger mb-auto">
|
||||
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
<div th:if="${deleteMessage}" class="alert alert-danger">
|
||||
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
<div class="bg-card mt-3 mb-3 table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
</div>
|
||||
<div th:if="${deleteMessage}" class="alert alert-danger">
|
||||
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
<div class="bg-card mt-3 mb-3 table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow"
|
||||
th:text="#{adminUserSettings.authenticated}">Authenticated</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow"
|
||||
th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th scope="col" class="text-center" th:title="#{adminUserSettings.userSessions}"
|
||||
th:text="#{adminUserSettings.userSessions}">User Sessions</th>
|
||||
<th scope="col" class="text-center" th:title="#{adminUserSettings.actions}"
|
||||
th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
|
||||
<!-- <th scope="col"></th> -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<th:block th:each="user, iterStat : ${users}">
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col" th:title="#{username}" th:text="#{username}">Username</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.roles}" th:text="#{adminUserSettings.roles}">Roles</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.authenticated}" class="text-overflow" th:text="#{adminUserSettings.authenticated}">Authenticated</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.lastRequest}" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}" colspan="2">Actions</th>
|
||||
<!-- <th scope="col"></th> -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="user : ${users}">
|
||||
<th scope="row" style="align-content: center;" th:text="${user.id}"></th>
|
||||
<td style="align-content: center;" th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
||||
<td style="align-content: center;" th:text="#{${user.roleName}}"></td>
|
||||
<td style="align-content: center;" th:text="${user.authenticationType}"></td>
|
||||
<td style="align-content: center;" th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}"></td>
|
||||
<td style="align-content: center;">
|
||||
<form th:if="${user.username != currentUsername}" th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post" onsubmit="return confirmDeleteUser()">
|
||||
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span class="material-symbols-rounded">person_remove</span></button>
|
||||
</form>
|
||||
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}" th:href="@{'/account'}" class="btn btn-outline-success btn-sm"><span class="material-symbols-rounded">edit</span></a>
|
||||
<th scope="row" th:text="${user.id}"></th>
|
||||
<td th:text="${user.username}" th:classappend="${userSessions[user.username] ? 'active-user' : ''}"></td>
|
||||
<td th:text="#{${user.roleName}}"></td>
|
||||
<td th:text="${user.authenticationType}"></td>
|
||||
<td
|
||||
th:text="${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}">
|
||||
</td>
|
||||
<td style="align-content: center;">
|
||||
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post" onsubmit="return confirmChangeUserStatus()">
|
||||
<th:block th:if="${@runningProOrHigher}">
|
||||
<td class="text-center">
|
||||
<button th:if="${@enableAlphaFunctionality}" type="button"
|
||||
th:text="${#lists.size(userActiveSessions[user.username])}" th:data-bs-toggle="'collapse'"
|
||||
th:data-bs-target="'#sessions__' + ${iterStat.index}"
|
||||
th:aria-controls="'sessions__' + ${iterStat.index}"
|
||||
th:class="${#lists.isEmpty(userActiveSessions[user.username])} ? 'btn btn-sm btn-outline-secondary disabled' : 'btn btn-sm btn-outline-secondary'"
|
||||
th:aria-disabled="${#lists.isEmpty(userActiveSessions[user.username])} ? 'true' : 'false'">
|
||||
0
|
||||
</button>
|
||||
<span th:if="${!@enableAlphaFunctionality}"
|
||||
th:text="${#lists.size(userActiveSessions[user.username])}"></span> |
|
||||
<span th:text="${maxUserSessions}"></span>
|
||||
</td>
|
||||
</th:block>
|
||||
<th:block th:if="${!@runningProOrHigher}">
|
||||
<td class="text-center" th:text="${#lists.size(userActiveSessions[user.username])}">0</td>
|
||||
</th:block>
|
||||
<td class="text-center">
|
||||
<form th:if="${user.username != currentUsername}"
|
||||
th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post"
|
||||
onsubmit="return confirmDeleteUser()">
|
||||
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm">
|
||||
<span class="material-symbols-rounded">person_remove</span>
|
||||
</button>
|
||||
</form>
|
||||
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}"
|
||||
th:href="@{'/account'}" class="btn btn-outline-success btn-sm">
|
||||
<span class="material-symbols-rounded">edit</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<form th:action="@{'/api/v1/user/admin/changeUserEnabled/' + ${user.username}}" method="post"
|
||||
onsubmit="return confirmChangeUserStatus()">
|
||||
<input type="hidden" name="enabled" th:value="!${user.enabled}" />
|
||||
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}" class="btn btn-success btn-sm">
|
||||
<button type="submit" th:if="${user.enabled}" th:title="#{adminUserSettings.enabledUser}"
|
||||
class="btn btn-success btn-sm">
|
||||
<span class="material-symbols-rounded">person</span>
|
||||
</button>
|
||||
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}" class="btn btn-danger btn-sm">
|
||||
<button type="submit" th:unless="${user.enabled}" th:title="#{adminUserSettings.disabledUser}"
|
||||
class="btn btn-danger btn-sm">
|
||||
<span class="material-symbols-rounded">person_off</span>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
||||
|
||||
<script th:inline="javascript">
|
||||
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
||||
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
||||
function confirmDeleteUser() {
|
||||
return confirm(delete_confirm_text);
|
||||
}
|
||||
function confirmChangeUserStatus() {
|
||||
return confirm(change_confirm_text);
|
||||
}
|
||||
</script>
|
||||
<tr th:if="${@enableAlphaFunctionality}">
|
||||
<td colspan="8" class="p-0 border-0">
|
||||
<div th:id="'sessions__' + ${iterStat.index}" class="collapse">
|
||||
<table class="table table-striped table-hover table-sm mb-0">
|
||||
<tbody>
|
||||
<tr th:each="s : ${userActiveSessions[user.username]}">
|
||||
<td scope="row" colspan="4">
|
||||
<span th:text="${s.sessionId}"></span>
|
||||
<span th:if="${s.sessionId == currentSessionId}" class="text-warning ms-2"
|
||||
title="Aktuelle Sitzung">
|
||||
⚠️
|
||||
</span>
|
||||
</td>
|
||||
<td colspan="2" th:text="${#dates.format(s.lastRequest, 'yyyy-MM-dd HH:mm:ss')}"></td>
|
||||
<td colspan="2">
|
||||
<form th:action="@{'/session/invalidate/' + ${s.sessionId}}" method="get"
|
||||
onsubmit="return confirm('Session wirklich beenden?')">
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<button class="btn btn-danger btn-sm"><span
|
||||
class="material-symbols-rounded">remove_circle_outline</span></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(userActiveSessions[user.username])}">
|
||||
<td colspan="3" class="text-center text-muted">Keine aktiven Sessions</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p th:if="${!@runningProOrHigher}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
|
||||
|
||||
<script th:inline="javascript">
|
||||
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
|
||||
const change_confirm_text = /*[[#{adminUserSettings.confirmChangeUserStatus}]]*/ 'Should the user be disabled/enabled?';
|
||||
function confirmDeleteUser() {
|
||||
return confirm(delete_confirm_text);
|
||||
}
|
||||
function confirmChangeUserStatus() {
|
||||
return confirm(change_confirm_text);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- change User role Modal start -->
|
||||
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
|
||||
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" th:text="#{username}">Username</label>
|
||||
<select name="username" class="form-control" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}" th:text="${user.username}">Username</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
||||
<select name="role" class="form-control" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Add other fields as required -->
|
||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- change User role Modal end -->
|
||||
|
||||
<!-- Add User Modal start -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto" th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
|
||||
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" th:text="#{username}">Username</label>
|
||||
<input type="text" class="form-control" name="username" id="username" th:title="#{adminUserSettings.usernameInfo}" required>
|
||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid username!</span>
|
||||
</div>
|
||||
<div class="mb-3" id="passwordContainer">
|
||||
<label for="password" th:text="#{password}">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
||||
<select name="role" class="form-control" id="role" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">Role</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="authType">Authentication Type</label>
|
||||
<select id="authType" name="authType" class="form-control" required>
|
||||
<option value="web" selected>WEB</option>
|
||||
<option value="sso">SSO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3" id="checkboxContainer">
|
||||
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
||||
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user to change username/password on login</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add User Modal end -->
|
||||
|
||||
<script th:inline="javascript">
|
||||
jQuery.validator.addMethod("usernamePattern", function(value, element) {
|
||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||
|
||||
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
||||
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
||||
|
||||
// Check if the field is optional or meets the requirements
|
||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||
$(document).ready(function() {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
$('#formsaveuser').validate({
|
||||
rules: {
|
||||
username: {
|
||||
required: true,
|
||||
usernamePattern: true
|
||||
},
|
||||
password: {
|
||||
required: true
|
||||
},
|
||||
role: {
|
||||
required: true
|
||||
},
|
||||
authType: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
username: {
|
||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||
},
|
||||
},
|
||||
errorPlacement: function(error, element) {
|
||||
if (element.attr("name") === "username") {
|
||||
$("#usernameError").text(error.text()).show();
|
||||
} else if (element.attr("name") !== "role" && element.attr("name") !== "authType") {
|
||||
error.insertAfter(element);
|
||||
}
|
||||
},
|
||||
success: function(label, element) {
|
||||
if ($(element).attr("name") === "username") {
|
||||
$("#usernameError").hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#username').on('input', function() {
|
||||
var usernameInput = $(this);
|
||||
var isValid = usernameInput[0].checkValidity();
|
||||
var errorSpan = $('#usernameError');
|
||||
|
||||
if (isValid) {
|
||||
usernameInput.removeClass('invalid').addClass('valid');
|
||||
errorSpan.hide();
|
||||
} else {
|
||||
usernameInput.removeClass('valid').addClass('invalid');
|
||||
errorSpan.show();
|
||||
}
|
||||
});
|
||||
|
||||
$('#authType').on('change', function() {
|
||||
var authType = $(this).val();
|
||||
var passwordField = $('#password');
|
||||
var passwordFieldContainer = $('#passwordContainer');
|
||||
var checkboxContainer = $('#checkboxContainer');
|
||||
|
||||
if (authType === 'sso') {
|
||||
passwordField.removeAttr('required');
|
||||
passwordField.prop('disabled', true).val('');
|
||||
passwordFieldContainer.slideUp('fast');
|
||||
checkboxContainer.slideUp('fast');
|
||||
} else {
|
||||
passwordField.prop('disabled', false);
|
||||
passwordField.attr('required', 'required');
|
||||
passwordFieldContainer.slideDown('fast');
|
||||
checkboxContainer.slideDown('fast');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<!-- change User role Modal start -->
|
||||
<div class="modal fade" id="changeUserRoleModal" tabindex="-1" aria-labelledby="changeUserRoleModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 th:text="#{adminUserSettings.changeUserRole}">Change User's Role</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto"
|
||||
th:title="#{downgradeCurrentUserLongMessage}" th:text="#{help}">Help</button>
|
||||
<form th:action="@{'/api/v1/user/admin/changeRole'}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" th:text="#{username}">Username</label>
|
||||
<select name="username" class="form-control" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="user : ${users}" th:if="${user.username != currentUsername}" th:value="${user.username}"
|
||||
th:text="${user.username}">Username</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
||||
<select name="role" class="form-control" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">
|
||||
Role</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Add other fields as required -->
|
||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- change User role Modal end -->
|
||||
|
||||
<!-- Add User Modal start -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addUserModalLabel" th:text="#{adminUserSettings.addUser}">Add New User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline-info" data-toggle="tooltip" data-placement="auto"
|
||||
th:title="#{adminUserSettings.usernameInfo}" th:text="#{help}">Help</button>
|
||||
<form id="formsaveuser" th:action="@{'/api/v1/user/admin/saveUser'}" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" th:text="#{username}">Username</label>
|
||||
<input type="text" class="form-control" name="username" id="username"
|
||||
th:title="#{adminUserSettings.usernameInfo}" required>
|
||||
<span id="usernameError" style="display: none;" th:text="#{invalidUsernameMessage}">Invalid
|
||||
username!</span>
|
||||
</div>
|
||||
<div class="mb-3" id="passwordContainer">
|
||||
<label for="password" th:text="#{password}">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" th:text="#{adminUserSettings.role}">Role</label>
|
||||
<select name="role" class="form-control" id="role" required>
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select --</option>
|
||||
<option th:each="roleDetail : ${roleDetails}" th:value="${roleDetail.key}" th:text="#{${roleDetail.value}}">
|
||||
Role</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="authType">Authentication Type</label>
|
||||
<select id="authType" name="authType" class="form-control" required>
|
||||
<option value="web" selected>WEB</option>
|
||||
<option value="sso">SSO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3" id="checkboxContainer">
|
||||
<input type="checkbox" class="form-check-input" id="forceChange" name="forceChange">
|
||||
<label class="form-check-label" for="forceChange" th:text="#{adminUserSettings.forceChange}">Force user
|
||||
to change username/password on login</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{adminUserSettings.submit}">Save User</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add User Modal end -->
|
||||
|
||||
<script th:inline="javascript">
|
||||
jQuery.validator.addMethod("usernamePattern", function (value, element) {
|
||||
// Regular expression for user name: Min. 3 characters, max. 50 characters
|
||||
const regexUsername = /^[a-zA-Z0-9](?!.*[-@._+]{2,})([a-zA-Z0-9@._+-]{1,48})[a-zA-Z0-9]$/;
|
||||
|
||||
// Regular expression for email addresses: Max. 320 characters, with RFC-like validation
|
||||
const regexEmail = /^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.[A-Za-z]{2,})$/;
|
||||
|
||||
// Check if the field is optional or meets the requirements
|
||||
return this.optional(element) || regexUsername.test(value) || regexEmail.test(value);
|
||||
}, /*[[#{invalidUsernameMessage}]]*/ "Invalid username format");
|
||||
$(document).ready(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
$('#formsaveuser').validate({
|
||||
rules: {
|
||||
username: {
|
||||
required: true,
|
||||
usernamePattern: true
|
||||
},
|
||||
password: {
|
||||
required: true
|
||||
},
|
||||
role: {
|
||||
required: true
|
||||
},
|
||||
authType: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
username: {
|
||||
usernamePattern: /*[[#{invalidUsernameMessage}]]*/ "Invalid username format"
|
||||
},
|
||||
},
|
||||
errorPlacement: function (error, element) {
|
||||
if (element.attr("name") === "username") {
|
||||
$("#usernameError").text(error.text()).show();
|
||||
} else if (element.attr("name") !== "role" && element.attr("name") !== "authType") {
|
||||
error.insertAfter(element);
|
||||
}
|
||||
},
|
||||
success: function (label, element) {
|
||||
if ($(element).attr("name") === "username") {
|
||||
$("#usernameError").hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#username').on('input', function () {
|
||||
var usernameInput = $(this);
|
||||
var isValid = usernameInput[0].checkValidity();
|
||||
var errorSpan = $('#usernameError');
|
||||
|
||||
if (isValid) {
|
||||
usernameInput.removeClass('invalid').addClass('valid');
|
||||
errorSpan.hide();
|
||||
} else {
|
||||
usernameInput.removeClass('valid').addClass('invalid');
|
||||
errorSpan.show();
|
||||
}
|
||||
});
|
||||
|
||||
$('#authType').on('change', function () {
|
||||
var authType = $(this).val();
|
||||
var passwordField = $('#password');
|
||||
var passwordFieldContainer = $('#passwordContainer');
|
||||
var checkboxContainer = $('#checkboxContainer');
|
||||
|
||||
if (authType === 'sso') {
|
||||
passwordField.removeAttr('required');
|
||||
passwordField.prop('disabled', true).val('');
|
||||
passwordFieldContainer.slideUp('fast');
|
||||
checkboxContainer.slideUp('fast');
|
||||
} else {
|
||||
passwordField.prop('disabled', false);
|
||||
passwordField.attr('required', 'required');
|
||||
passwordFieldContainer.slideDown('fast');
|
||||
checkboxContainer.slideDown('fast');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -14,6 +14,9 @@
|
||||
<h1 class="display-2" th:text="#{oops}"></h1>
|
||||
<p class="lead" th:if="${param.status == '404'}" th:text="#{error.404.1}"></p>
|
||||
<p class="lead" th:unless="${param.status == '404'}" th:text="#{error.404.2}"></p>
|
||||
<p class="lead" th:if="${status == 417}">
|
||||
<a th:href="@{'/userSession'}" th:text="#{userSession.maxUserSession}">Max sessions reached for this user.</a>
|
||||
</p>
|
||||
<br>
|
||||
<h2 th:text="#{error.needHelp}"></h2>
|
||||
<p th:text="#{error.contactTip}"></p>
|
||||
@ -21,7 +24,7 @@
|
||||
<a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" id="github-button" class="btn btn-primary" target="_blank" th:text="#{error.github}"></a>
|
||||
<a href="https://discord.gg/HYmhKj45pU" id="discord-button" class="btn btn-primary" target="_blank" th:text="#{joinDiscord}"></a>
|
||||
</div>
|
||||
<a th:href="@{'/'}" id="home-button" class="home-button btn btn-primary" th:text="#{goHomepage}"></a>
|
||||
<a th:href="@{'/'}" id="home-button" class="home-button btn btn-primary" th:text="#{goHomepage}"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -259,6 +259,8 @@
|
||||
<div class="modal-footer">
|
||||
<a th:if="${@loginEnabled and @activeSecurity}" class="btn btn-danger" role="button"
|
||||
th:text="#{settings.signOut}" th:href="@{'/logout'}">Sign Out</a>
|
||||
<a class="btn btn-danger" role="button"
|
||||
th:text="#{settings.userSessions}" th:href="@{'/userSession'}">Sign Out</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
52
src/main/resources/templates/userSession.html
Normal file
52
src/main/resources/templates/userSession.html
Normal 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>
|
@ -43,7 +43,7 @@ check_health() {
|
||||
capture_file_list() {
|
||||
local container_name=$1
|
||||
local output_file=$2
|
||||
|
||||
|
||||
echo "Capturing file list from $container_name..."
|
||||
# Get all files in one command, output directly from Docker to avoid path issues
|
||||
# Skip proc, sys, dev, and the specified LibreOffice config directory
|
||||
@ -60,12 +60,12 @@ capture_file_list() {
|
||||
-not -path '*/tmp/lu*' \
|
||||
-not -path '*/tmp/tmp*' \
|
||||
2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file"
|
||||
|
||||
|
||||
# Check if the output file has content
|
||||
if [ ! -s "$output_file" ]; then
|
||||
echo "WARNING: Failed to capture file list or container returned empty list"
|
||||
echo "Trying alternative approach..."
|
||||
|
||||
|
||||
# Alternative simpler approach - just get paths as a fallback
|
||||
docker exec $container_name sh -c "find / -type f \
|
||||
-not -path '*/proc/*' \
|
||||
@ -79,14 +79,14 @@ capture_file_list() {
|
||||
-not -path '*/tmp/lu*' \
|
||||
-not -path '*/tmp/tmp*' \
|
||||
2>/dev/null | sort" > "$output_file"
|
||||
|
||||
|
||||
if [ ! -s "$output_file" ]; then
|
||||
echo "ERROR: All attempts to capture file list failed"
|
||||
# Create a dummy entry to prevent diff errors
|
||||
echo "NO_FILES_FOUND 0 0" > "$output_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo "File list captured to $output_file"
|
||||
}
|
||||
|
||||
@ -96,24 +96,24 @@ compare_file_lists() {
|
||||
local after_file=$2
|
||||
local diff_file=$3
|
||||
local container_name=$4 # Added container_name parameter
|
||||
|
||||
|
||||
echo "Comparing file lists..."
|
||||
|
||||
|
||||
# Check if files exist and have content
|
||||
if [ ! -s "$before_file" ] || [ ! -s "$after_file" ]; then
|
||||
echo "WARNING: One or both file lists are empty."
|
||||
|
||||
|
||||
if [ ! -s "$before_file" ]; then
|
||||
echo "Before file is empty: $before_file"
|
||||
fi
|
||||
|
||||
|
||||
if [ ! -s "$after_file" ]; then
|
||||
echo "After file is empty: $after_file"
|
||||
fi
|
||||
|
||||
|
||||
# Create empty diff file
|
||||
> "$diff_file"
|
||||
|
||||
|
||||
# Check if we at least have the after file to look for temp files
|
||||
if [ -s "$after_file" ]; then
|
||||
echo "Checking for temp files in the after snapshot..."
|
||||
@ -128,23 +128,23 @@ compare_file_lists() {
|
||||
echo "No temporary files found in the after snapshot."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
# Both files exist and have content, proceed with diff
|
||||
diff "$before_file" "$after_file" > "$diff_file"
|
||||
|
||||
|
||||
if [ -s "$diff_file" ]; then
|
||||
echo "Detected changes in files:"
|
||||
cat "$diff_file"
|
||||
|
||||
|
||||
# Extract only added files (lines starting with ">")
|
||||
grep "^>" "$diff_file" > "${diff_file}.added" || true
|
||||
if [ -s "${diff_file}.added" ]; then
|
||||
echo "New files created during test:"
|
||||
cat "${diff_file}.added" | sed 's/^> //'
|
||||
|
||||
|
||||
# Check for tmp files
|
||||
grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true
|
||||
if [ -s "${diff_file}.tmp" ]; then
|
||||
@ -155,7 +155,7 @@ compare_file_lists() {
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Extract only removed files (lines starting with "<")
|
||||
grep "^<" "$diff_file" > "${diff_file}.removed" || true
|
||||
if [ -s "${diff_file}.removed" ]; then
|
||||
@ -165,7 +165,7 @@ compare_file_lists() {
|
||||
else
|
||||
echo "No file changes detected during test."
|
||||
fi
|
||||
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -212,8 +212,8 @@ main() {
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export COMPOSE_DOCKER_CLI_BUILD=0
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export COMPOSE_DOCKER_CLI_BUILD=0
|
||||
export DOCKER_ENABLE_SECURITY=false
|
||||
# Run the gradlew build command and check if it fails
|
||||
if ! ./gradlew clean build; then
|
||||
@ -282,27 +282,27 @@ main() {
|
||||
# Create directory for file snapshots if it doesn't exist
|
||||
SNAPSHOT_DIR="$PROJECT_ROOT/testing/file_snapshots"
|
||||
mkdir -p "$SNAPSHOT_DIR"
|
||||
|
||||
|
||||
# Capture file list before running behave tests
|
||||
BEFORE_FILE="$SNAPSHOT_DIR/files_before_behave.txt"
|
||||
AFTER_FILE="$SNAPSHOT_DIR/files_after_behave.txt"
|
||||
DIFF_FILE="$SNAPSHOT_DIR/files_diff.txt"
|
||||
|
||||
|
||||
# Define container name variable for consistency
|
||||
CONTAINER_NAME="Stirling-PDF-Security-Fat-with-login"
|
||||
|
||||
|
||||
capture_file_list "$CONTAINER_NAME" "$BEFORE_FILE"
|
||||
|
||||
|
||||
cd "testing/cucumber"
|
||||
if python -m behave; then
|
||||
# Wait 10 seconds before capturing the file list after tests
|
||||
echo "Waiting 5 seconds for any file operations to complete..."
|
||||
sleep 5
|
||||
|
||||
|
||||
# Capture file list after running behave tests
|
||||
cd "$PROJECT_ROOT"
|
||||
capture_file_list "$CONTAINER_NAME" "$AFTER_FILE"
|
||||
|
||||
|
||||
# Compare file lists
|
||||
if compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"; then
|
||||
echo "No unexpected temporary files found."
|
||||
@ -311,19 +311,19 @@ main() {
|
||||
echo "WARNING: Unexpected temporary files detected after behave tests!"
|
||||
failed_tests+=("Stirling-PDF-Regression-Temp-Files")
|
||||
fi
|
||||
|
||||
|
||||
passed_tests+=("Stirling-PDF-Regression")
|
||||
else
|
||||
failed_tests+=("Stirling-PDF-Regression")
|
||||
echo "Printing docker logs of failed regression"
|
||||
docker logs "$CONTAINER_NAME"
|
||||
echo "Printed docker logs of failed regression"
|
||||
|
||||
|
||||
# Still capture file list after failure for analysis
|
||||
# Wait 10 seconds before capturing the file list
|
||||
echo "Waiting 5 seconds before capturing file list..."
|
||||
sleep 10
|
||||
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
capture_file_list "$CONTAINER_NAME" "$AFTER_FILE"
|
||||
compare_file_lists "$BEFORE_FILE" "$AFTER_FILE" "$DIFF_FILE" "$CONTAINER_NAME"
|
||||
@ -372,4 +372,4 @@ main() {
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
main
|
||||
|
@ -9,7 +9,7 @@ check_webpage() {
|
||||
local result_file="$3"
|
||||
|
||||
# Use curl to fetch the page with timeout
|
||||
response=$(curl -s -w "\n%{http_code}" --max-time $timeout "$full_url")
|
||||
response=$(curl -b cookies.txt -s -w "\n%{http_code}" --max-time $timeout "$full_url")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "FAILED - Connection error or timeout $full_url" >> "$result_file"
|
||||
return 1
|
||||
@ -75,13 +75,13 @@ test_all_urls() {
|
||||
|
||||
((total_count++))
|
||||
((url_index++))
|
||||
|
||||
|
||||
# Run the check in background
|
||||
test_url "$url" "$base_url" "$tmp_dir" "$url_index" &
|
||||
|
||||
|
||||
# Track the job
|
||||
((active_jobs++))
|
||||
|
||||
|
||||
# If we've reached max_parallel, wait for a job to finish
|
||||
if [ $active_jobs -ge $max_parallel ]; then
|
||||
wait -n # Wait for any child process to exit
|
||||
@ -97,7 +97,7 @@ test_all_urls() {
|
||||
if [ -f "${tmp_dir}/result_${i}.txt" ]; then
|
||||
cat "${tmp_dir}/result_${i}.txt"
|
||||
fi
|
||||
|
||||
|
||||
if [ -f "${tmp_dir}/failed_${i}" ]; then
|
||||
failed_count=$((failed_count + $(cat "${tmp_dir}/failed_${i}")))
|
||||
fi
|
||||
@ -105,6 +105,7 @@ test_all_urls() {
|
||||
|
||||
# Clean up
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f cookies.txt
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
@ -158,6 +159,8 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -s -c cookies.txt -o /dev/null $base_url/
|
||||
|
||||
# Run tests using the URL list
|
||||
if test_all_urls "$url_file" "$base_url" "$max_parallel"; then
|
||||
echo "All webpage tests passed!"
|
||||
@ -171,4 +174,4 @@ main() {
|
||||
# Run main if script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
fi
|
||||
|
Loading…
x
Reference in New Issue
Block a user