Compare commits

...

84 Commits

Author SHA1 Message Date
Ludy
1e1a78cded
Merge fa8df329dfcd0299d533fd6dd4e92a52975a6564 into 6906344178f102e5aa5273a10fe6a92db1389761 2025-04-16 07:02:30 +02:00
Ludy
fa8df329df
Merge branch 'main' into session_2025_03_22 2025-04-16 05:02:27 +00:00
Ludy87
fe71ab0115
Update AnonymusSessionListener.java 2025-04-14 19:11:33 +02:00
Ludy87
69ebc7ce42
add settings.userSessions 2025-04-14 19:09:47 +02:00
Ludy87
79763d0b76
Update AnonymusSessionListener.java 2025-04-14 19:04:36 +02:00
Ludy87
4ed49d0c15
clean up and more 2025-04-14 18:58:34 +02:00
Ludy
e76e83c427
Merge branch 'main' into session_2025_03_22 2025-04-14 10:14:43 +00:00
Ludy87
1343e41149
add session UI for Users 2025-04-13 20:56:02 +02:00
Ludy
95f289b9a3
Merge branch 'main' into session_2025_03_22 2025-04-13 06:30:26 +00:00
Ludy
6888c342da
Merge branch 'main' into session_2025_03_22 2025-04-12 15:32:11 +00:00
Ludy
8ae52105a0
Merge branch 'main' into session_2025_03_22 2025-04-11 09:30:16 +00:00
Ludy87
d9355e4609
Update build.gradle 2025-04-10 08:51:39 +02:00
Ludy87
09da81643c
add expired by Admin 2025-04-10 08:14:06 +02:00
Ludy
cfa71e537b
Merge branch 'main' into session_2025_03_22 2025-04-10 05:50:22 +00:00
Ludy
b68a3dfd6b
Merge branch 'main' into session_2025_03_22 2025-04-05 15:30:36 +00:00
Ludy87
bf9a4358b8
Update CustomHttpSessionListener.java 2025-04-05 17:30:09 +02:00
Ludy87
ce1d153a71
Update AnonymusSessionStatusController.java 2025-04-05 17:30:01 +02:00
Ludy87
2101d21a78
Update AnonymusSessionListener.java 2025-04-05 17:29:54 +02:00
Ludy87
b95da36454
Update EndpointInterceptor.java 2025-04-05 17:29:41 +02:00
Ludy87
fea1ba2e4c
Update EndpointInterceptor.java 2025-04-03 17:21:13 +02:00
Ludy
5b6cd42706
Merge branch 'main' into session_2025_03_22 2025-04-02 20:36:52 +00:00
Ludy
a8d6e6318b
Merge branch 'main' into session_2025_03_22 2025-04-02 15:56:03 +00:00
Ludy87
16996968df
delete cookie after process 2025-04-02 17:35:38 +02:00
Ludy87
263789a4bf
rename 2025-04-02 17:25:13 +02:00
Ludy
a65200abb3
Merge branch 'main' into session_2025_03_22 2025-04-02 15:03:06 +00:00
Ludy
bc5312d421
Merge branch 'main' into session_2025_03_22 2025-04-02 14:52:58 +00:00
Ludy
5bc40cbd45
Merge branch 'main' into session_2025_03_22 2025-04-01 14:02:40 +00:00
Ludy87
a16b6478a7
any more change 2025-03-30 23:12:04 +02:00
Ludy
f33d8b0f23
Merge branch 'main' into session_2025_03_22 2025-03-30 18:19:18 +02:00
Ludy87
d96d85ed64
set server.servlet.session.timeout back 2025-03-28 22:22:09 +01:00
Ludy87
fe4d2823aa
clean up 2025-03-28 21:53:17 +01:00
Ludy87
762571f42b
logout process 2025-03-28 13:01:31 +01:00
Ludy
79439f392f
Merge branch 'main' into session_2025_03_22 2025-03-28 12:40:07 +01:00
Ludy
128bd8af82
Merge branch 'main' into session_2025_03_22 2025-03-27 22:07:55 +01:00
Ludy87
e30776cd4d
Update adminSettings.html 2025-03-27 18:51:20 +01:00
Ludy87
0154e46c8a
cookie for session 2025-03-27 18:38:39 +01:00
Ludy
ec2caa4134
Merge branch 'main' into session_2025_03_22 2025-03-27 16:57:14 +01:00
Ludy
637f046cc7
Merge branch 'main' into session_2025_03_22 2025-03-27 15:10:53 +01:00
Ludy
f18abc9bb1
Merge branch 'main' into session_2025_03_22 2025-03-27 14:04:49 +01:00
Ludy87
9ec728ef00
Update AnonymusSessionRegistry.java 2025-03-27 13:14:41 +01:00
Ludy87
c14cb03390
Update AnonymusSessionService.java 2025-03-27 13:14:33 +01:00
Ludy87
27db4d6de2
Update AnonymusSessionStatusController.java 2025-03-27 13:14:28 +01:00
Ludy87
cb725ccf8c
Update EndpointInterceptor.java 2025-03-27 13:14:18 +01:00
Ludy87
6529382d93
Update SessionsInterface.java 2025-03-27 13:14:06 +01:00
Ludy87
5011b5c8ad
Create SessionsModelInterface.java 2025-03-27 13:13:57 +01:00
Ludy87
b080704bcd
Update CustomHttpSessionListener.java 2025-03-27 13:13:46 +01:00
Ludy87
8876d31bf7
Update SessionPersistentRegistry.java 2025-03-27 13:13:37 +01:00
Ludy87
d1ec9ccb84
Update SessionRepository.java 2025-03-27 13:13:28 +01:00
Ludy87
355c09e509
Update SessionStatusController.java 2025-03-27 13:13:13 +01:00
Ludy87
287a815793
Update UserAuthenticationFilter.java 2025-03-27 13:13:05 +01:00
Ludy87
813897175c
Update SessionEntity.java 2025-03-27 13:10:18 +01:00
Ludy87
41619d47c5
Update AnonymusSessionInfo.java 2025-03-27 13:07:33 +01:00
Ludy87
389f1f31e9
Create SessionStatusController.java 2025-03-27 12:17:33 +01:00
Ludy
a11e688e4c
Merge branch 'main' into session_2025_03_22 2025-03-26 23:33:29 +01:00
Ludy87
5e5dc2e0c3
Update messages_de_DE.properties 2025-03-26 16:59:05 +01:00
Ludy87
7b709775a6
Update RequestUriUtils.java 2025-03-26 16:42:51 +01:00
Ludy87
dcac4cbbb1
Update messages_en_GB.properties 2025-03-26 16:42:39 +01:00
Ludy87
1a1bb8b701
Update EndpointInterceptor.java 2025-03-26 16:42:34 +01:00
Ludy87
b6c6a3445c
main -> branch 2025-03-26 16:34:29 +01:00
Ludy87
1b6ce2a3e7
Merge branch 'main' into session_2025_03_22 2025-03-26 16:27:48 +01:00
Ludy87
bb5284b2f9
Update build.gradle 2025-03-26 15:58:59 +01:00
Ludy87
c7e65cfd26
Update AnonymusSessionRegistry.java 2025-03-26 13:19:41 +01:00
Ludy87
de8cc4f338
Update AccountWebController.java 2025-03-26 12:42:36 +01:00
Ludy87
8e91c49dc4
Update messages_en_GB.properties 2025-03-26 12:39:05 +01:00
Ludy87
a5fcd2b3d2
Create SessionsInterface.java 2025-03-26 12:07:22 +01:00
Ludy87
8cb44a40a2
Update EndpointInterceptor.java 2025-03-26 12:07:17 +01:00
Ludy87
5ca84f4aa3
Update AnonymusSessionStatusController.java 2025-03-26 12:06:10 +01:00
Ludy87
fe378042f0
Update AnonymusSessionService.java 2025-03-26 12:05:14 +01:00
Ludy87
d9755c9658
Update AnonymusSessionRegistry.java 2025-03-26 12:04:47 +01:00
Ludy87
69b12030d5
AnonymusSession 2025-03-25 18:13:19 +01:00
Ludy87
1c33c39c57
Skip anonymousUser if login is enabled 2025-03-25 12:44:28 +01:00
Ludy87
aaa3739856
Update CustomHttpSessionListener.java 2025-03-24 22:33:41 +01:00
Ludy
423013f032
Merge branch 'main' into session_2025_03_22 2025-03-24 14:27:25 +01:00
Ludy87
4e67aa52e6
Update UserAuthenticationFilter.java 2025-03-24 00:54:10 +01:00
Ludy87
3400cd0751
Update addUsers.html 2025-03-24 00:51:50 +01:00
Ludy87
495913bc4f
Update messages_en_GB.properties 2025-03-24 00:51:13 +01:00
Ludy87
cc7800a66f
Update AccountWebController.java 2025-03-24 00:50:44 +01:00
Ludy87
7222e992da
Update UserAuthenticationFilter.java 2025-03-24 00:50:32 +01:00
Ludy87
3bb1bfa399
Update SessionPersistentRegistry.java 2025-03-24 00:50:18 +01:00
Ludy87
cedda44bb0
Update SecurityConfiguration.java 2025-03-24 00:50:06 +01:00
Ludy87
969ca7be50
add runningEE and more 2025-03-23 21:18:54 +01:00
Ludy
e7b3fd0859
Merge branch 'main' into session_2025_03_22 2025-03-23 09:40:53 +01:00
Ludy87
d0ed33f2cf
Update AppUpdateAuthService.java 2025-03-22 20:36:12 +01:00
Ludy87
df31733501
Add support for expired sessions and improve user management 2025-03-22 18:22:46 +01:00
32 changed files with 1869 additions and 454 deletions

View File

@ -65,6 +65,8 @@ sourceSets {
exclude "stirling/software/SPDF/model/SessionEntity.java"
exclude "stirling/software/SPDF/model/User.java"
exclude "stirling/software/SPDF/repository/**"
} else {
exclude "stirling/software/SPDF/config/anonymus/**"
}
if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {

View File

@ -1,28 +1,225 @@
package stirling.software.SPDF.config;
import java.security.Principal;
import java.util.Collection;
import java.util.List;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsInterface;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Component
@Slf4j
public class EndpointInterceptor implements HandlerInterceptor {
private final EndpointConfiguration endpointConfiguration;
private final SessionsInterface sessionsInterface;
public EndpointInterceptor(EndpointConfiguration endpointConfiguration) {
public EndpointInterceptor(
EndpointConfiguration endpointConfiguration, SessionsInterface sessionsInterface) {
this.endpointConfiguration = endpointConfiguration;
this.sessionsInterface = sessionsInterface;
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
HttpSession session = request.getSession(false);
if (session == null) {
session = request.getSession(true);
}
String requestURI = request.getRequestURI();
boolean isApiRequest = requestURI.contains("/api/v1");
if ("GET".equalsIgnoreCase(request.getMethod()) && !isApiRequest) {
Principal principal = request.getUserPrincipal();
// allowlist for public or static routes
if ("/login".equals(requestURI)
// || "/".equals(requestURI)
// || "/home".equals(requestURI)
// || "/home-legacy".equals(requestURI)
|| "/userSession".equals(requestURI)
|| requestURI.contains("/userSession/invalidate/")
|| requestURI.contains("/js/")
|| requestURI.contains("/css/")
|| requestURI.contains("/fonts/")
|| requestURI.contains("/images/")
|| requestURI.contains("/favicon")
|| requestURI.contains("/pdfjs-legacy/")
|| requestURI.contains("/error")
|| requestURI.contains("/session")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".webmanifest")
|| requestURI.contains("/files/")) {
return true;
} else if (principal != null) {
if (session == null) {
session = request.getSession(true);
}
final HttpSession finalSession = session;
String sessionId = finalSession.getId();
boolean isExpiredByAdmin =
sessionsInterface.getAllSessions().stream()
.filter(s -> s.getSessionId().equals(finalSession.getId()))
.anyMatch(s -> s.isExpired());
if (isExpiredByAdmin
&& !"/".equals(requestURI)
&& !"/home".equals(requestURI)
&& !"/home-legacy".equals(requestURI)) {
response.sendRedirect("/logout");
log.info("Session expired. Logging out user {}", principal.getName());
return false;
} else if (isExpiredByAdmin
&& ("/".equals(requestURI)
|| "/home".equals(requestURI)
|| "/home-legacy".equals(requestURI))) {
log.info(
"Max sessions reached for this user. To continue on this device, please"
+ " close your session in another browser. ");
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
return false;
}
int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions();
Collection<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
long totalSessionsNonExpired =
allSessions.stream().filter(s -> !s.isExpired()).count();
List<SessionsModelInterface> activeSessions =
allSessions.stream()
.filter(s -> !s.isExpired())
.sorted(
(s1, s2) ->
Long.compare(
s2.getLastRequest().getTime(),
s1.getLastRequest().getTime()))
.limit(maxApplicationSessions)
.toList();
boolean hasUserActiveSession =
// activeSessions.stream().anyMatch(s ->
// s.getSessionId().equals(sessionId));
activeSessions.stream()
.anyMatch(
s ->
s.getSessionId().equals(sessionId)
// && !s.isExpired()
&& s.getPrincipalName()
.equals(principal.getName()));
final String currentPrincipal = principal.getName();
long userSessions =
sessionsInterface.getAllSessions().stream()
.filter(
s ->
!s.isExpired()
&& currentPrincipal.equals(
s.getPrincipalName()))
.count();
int maxUserSessions = sessionsInterface.getMaxUserSessions();
log.info(
"Active sessions for {}: {} (max: {}) | Total: {} (max: {}) | Active"
+ " sessions: {}",
currentPrincipal,
userSessions,
maxUserSessions,
totalSessionsNonExpired,
maxApplicationSessions,
hasUserActiveSession);
if ((userSessions >= maxUserSessions
|| totalSessionsNonExpired >= maxApplicationSessions)
&& !hasUserActiveSession) {
log.info(
"Max sessions reached for this user. To continue on this device, please"
+ " close your session in another browser.");
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
return false;
}
// If session is not registered yet, register it; otherwise, update the last request
// timestamp.
if (!hasUserActiveSession) {
log.info("Register session: {}", sessionId);
sessionsInterface.registerSession(finalSession);
} else {
log.info("Update session last request: {}", sessionId);
sessionsInterface.updateSessionLastRequest(sessionId);
}
return true;
} else if (principal == null) {
if (session == null) {
session = request.getSession(true);
}
final HttpSession finalSession = session;
String sessionId = finalSession.getId();
int maxApplicationSessions = sessionsInterface.getMaxApplicationSessions();
Collection<SessionsModelInterface> allSessions = sessionsInterface.getAllSessions();
long totalSessions = allSessions.stream().filter(s -> !s.isExpired()).count();
List<SessionsModelInterface> activeSessions =
allSessions.stream()
.filter(s -> !s.isExpired())
.sorted(
(s1, s2) ->
Long.compare(
s2.getLastRequest().getTime(),
s1.getLastRequest().getTime()))
.limit(maxApplicationSessions)
.toList();
boolean hasUserActiveSession =
activeSessions.stream().anyMatch(s -> s.getSessionId().equals(sessionId));
log.info(
"Active sessions for anonymous: Total: {} (max: {}) | Active sessions: {}",
totalSessions,
maxApplicationSessions,
hasUserActiveSession);
if (totalSessions >= maxApplicationSessions && !hasUserActiveSession) {
sessionsInterface.removeSession(finalSession);
log.info(
"Max sessions reached for this user. To continue on this device, please"
+ " close your session in another browser.");
response.sendError(HttpServletResponse.SC_EXPECTATION_FAILED);
return false;
}
if (!hasUserActiveSession) {
log.debug("Register session: {}", sessionId);
sessionsInterface.registerSession(finalSession);
} else {
log.debug("Update session last request: {}", sessionId);
sessionsInterface.updateSessionLastRequest(sessionId);
}
return true;
}
}
boolean isEnabled;
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)

View File

@ -0,0 +1,55 @@
package stirling.software.SPDF.config.anonymus.session;
import java.util.Date;
import jakarta.servlet.http.HttpSession;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Setter
@ToString(exclude = "session") // exclude session from toString to avoid verbose output or sensitive
// data
@AllArgsConstructor
public class AnonymusSessionInfo implements SessionsModelInterface {
private static final String principalName = "anonymousUser";
private HttpSession session;
@Setter(AccessLevel.NONE)
private final Date createdAt;
private Date lastRequest;
private Boolean expired;
public HttpSession getSession() {
return session;
}
public Date getCreatedAt() {
return createdAt;
}
@Override
public Date getLastRequest() {
return lastRequest;
}
@Override
public boolean isExpired() {
return expired;
}
@Override
public String getSessionId() {
return session.getId();
}
@Override
public String getPrincipalName() {
return principalName;
}
}

View File

@ -0,0 +1,217 @@
package stirling.software.SPDF.config.anonymus.session;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsInterface;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Component
@Slf4j
public class AnonymusSessionListener implements HttpSessionListener, SessionsInterface {
@Value("${server.servlet.session.timeout:30m}")
private Duration defaultMaxInactiveInterval;
// Map for storing sessions including timestamp
private static final Map<String, SessionsModelInterface> sessions = new ConcurrentHashMap<>();
@Override
public void sessionCreated(HttpSessionEvent event) {
HttpSession session = event.getSession();
if (session == null) {
return;
}
if (sessions.containsKey(session.getId())) {
return;
}
// Save creation timestamp
Date creationTime = new Date();
int allNonExpiredSessions = getAllNonExpiredSessions().size();
if (allNonExpiredSessions >= getMaxUserSessions()) {
sessions.put(
session.getId(),
new AnonymusSessionInfo(session, creationTime, creationTime, true));
} else {
sessions.put(
session.getId(),
new AnonymusSessionInfo(session, creationTime, creationTime, false));
}
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
HttpSession session = event.getSession();
if (session == null) {
return;
}
AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId());
if (sessionsInfo == null) {
return;
}
Date lastRequest = sessionsInfo.getLastRequest();
int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds();
Instant now = Instant.now();
Instant expirationTime =
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionsInfo.setExpired(true);
session.invalidate();
log.debug("Session {} expired=TRUE", session.getId());
}
}
// Mark a single session as expired
public void expireSession(String sessionId) {
if (sessions.containsKey(sessionId)) {
AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId);
sessionInfo.setExpired(true);
try {
sessionInfo.getSession().invalidate();
} catch (IllegalStateException e) {
log.debug("Session {} already invalidated", sessionInfo.getSession().getId());
}
}
}
// Expire first session sorted by last request time aufsteigend
public void expireFirstSession(String sessionId) {
sessions.values().stream()
.filter(info -> !info.isExpired())
.filter(info -> !info.getSessionId().equals(sessionId))
.sorted((s1, s2) -> s1.getLastRequest().compareTo(s2.getLastRequest()))
.findFirst()
.ifPresent(
session -> {
AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) session;
sessionInfo.setExpired(true);
try {
log.info(
"Session {} expired by first Session",
sessionInfo.getSession().getId());
} catch (IllegalStateException e) {
log.info(
"Session {} already invalidated",
sessionInfo.getSession().getId());
}
});
}
// Mark all sessions as expired
public void expireAllSessions() {
sessions.values()
.forEach(
sessionInfo -> {
AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo;
info.setExpired(true);
HttpSession session = info.getSession();
try {
session.invalidate();
} catch (IllegalStateException e) {
log.debug("Session {} already invalidated", session.getId());
}
});
}
// Expire all sessions by username
public void expireAllSessionsByUsername(String username) {
sessions.values().stream()
.filter(
sessionInfo -> {
AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo;
return info.getPrincipalName().equals(username);
})
.forEach(
sessionInfo -> {
AnonymusSessionInfo info = (AnonymusSessionInfo) sessionInfo;
info.setExpired(true);
HttpSession session = info.getSession();
try {
session.invalidate();
} catch (IllegalStateException e) {
log.debug("Session {} already invalidated", session.getId());
}
});
}
@Override
public void updateSessionLastRequest(String sessionId) {
if (sessions.containsKey(sessionId)) {
AnonymusSessionInfo sessionInfo = (AnonymusSessionInfo) sessions.get(sessionId);
sessionInfo.setLastRequest(new Date());
}
}
@Override
public Collection<SessionsModelInterface> getAllSessions() {
return sessions.values().stream().toList();
}
@Override
public Collection<SessionsModelInterface> getAllNonExpiredSessions() {
return sessions.values().stream().filter(info -> !info.isExpired()).toList();
}
public Collection<SessionsModelInterface> getAllIsExpiredSessions() {
return sessions.values().stream().filter(SessionsModelInterface::isExpired).toList();
}
public void clear() {
sessions.clear();
}
@Override
public void registerSession(HttpSession session) {
if (!sessions.containsKey(session.getId())) {
AnonymusSessionInfo sessionInfo =
new AnonymusSessionInfo(session, new Date(), new Date(), false);
sessions.put(session.getId(), sessionInfo);
log.debug("Session {} registered", session.getId());
}
}
@Override
public void removeSession(HttpSession session) {
AnonymusSessionInfo sessionsInfo = (AnonymusSessionInfo) sessions.get(session.getId());
if (sessionsInfo != null) {
sessionsInfo.setExpired(true);
}
try {
session.invalidate();
} catch (IllegalStateException e) {
log.debug("Session {} already invalidated", session.getId());
}
sessions.remove(session.getId());
}
@Override
public int getMaxApplicationSessions() {
// return getMaxUserSessions();
return Integer.MAX_VALUE;
}
@Override
public int getMaxUsers() {
return 1;
}
}

View File

@ -0,0 +1,45 @@
package stirling.software.SPDF.config.anonymus.session;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class AnonymusSessionService {
@Autowired private AnonymusSessionListener sessionRegistry;
@Value("${server.servlet.session.timeout:30m}")
private Duration defaultMaxInactiveInterval;
// Runs every minute to expire inactive sessions
@Scheduled(cron = "0 0/5 * * * ?")
public void expireSessions() {
Instant now = Instant.now();
sessionRegistry.getAllSessions().stream()
.filter(session -> !session.isExpired())
.forEach(
session -> {
Date lastRequest = session.getLastRequest();
int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds();
Instant expirationTime =
lastRequest
.toInstant()
.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
log.debug("Session expiration triggered");
sessionRegistry.expireSession(session.getSessionId());
}
});
}
}

View File

@ -0,0 +1,56 @@
package stirling.software.SPDF.config.anonymus.session;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Controller
@Slf4j
public class AnonymusSessionStatusController {
@Autowired private AnonymusSessionListener sessionRegistry;
@GetMapping("/userSession")
public String getUserSessions(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
if (session != null) {
boolean isSessionValid =
sessionRegistry.getAllNonExpiredSessions().stream()
.allMatch(
sessionEntity ->
sessionEntity.getSessionId().equals(session.getId()));
// Get all sessions for the user
List<SessionsModelInterface> sessionList =
sessionRegistry.getAllNonExpiredSessions().stream()
.filter(
sessionEntity ->
!sessionEntity.getSessionId().equals(session.getId()))
.toList();
model.addAttribute("sessionList", sessionList);
return "userSession";
}
return "redirect:/";
}
@GetMapping("/userSession/invalidate/{sessionId}")
public String invalidateUserSession(
HttpServletRequest request, @PathVariable String sessionId) {
sessionRegistry.expireSession(sessionId);
sessionRegistry.registerSession(request.getSession(false));
return "redirect:/userSession";
}
}

View File

@ -0,0 +1,30 @@
package stirling.software.SPDF.config.interfaces;
import java.util.Collection;
import jakarta.servlet.http.HttpSession;
public interface SessionsInterface {
void updateSessionLastRequest(String sessionId);
Collection<SessionsModelInterface> getAllSessions();
Collection<SessionsModelInterface> getAllNonExpiredSessions();
void registerSession(HttpSession session);
void removeSession(HttpSession session);
default int getMaxUserSessions() {
return 3;
}
default int getMaxApplicationSessions() {
return getMaxUserSessions() * 3;
}
default int getMaxUsers() {
return 10;
}
}

View File

@ -0,0 +1,14 @@
package stirling.software.SPDF.config.interfaces;
import java.util.Date;
public interface SessionsModelInterface {
String getSessionId();
String getPrincipalName();
Date getLastRequest();
boolean isExpired();
}

View File

@ -35,7 +35,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
if (authentication == null || !authentication.isAuthenticated()) {
return !showUpdateOnlyAdmin;
}
if (authentication.getName().equalsIgnoreCase("anonymousUser")) {
if ("anonymousUser".equalsIgnoreCase(authentication.getName())) {
return !showUpdateOnlyAdmin;
}
Optional<User> user = userRepository.findByUsername(authentication.getName());

View File

@ -36,6 +36,7 @@ import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.SPDF.config.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.SPDF.config.security.session.PreLogoutDataCaptureHandler;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.User;
@ -149,7 +150,7 @@ public class SecurityConfiguration {
sessionManagement ->
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maximumSessions(sessionRegistry.getMaxUserSessions())
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true"));
@ -158,6 +159,8 @@ public class SecurityConfiguration {
http.logout(
logout ->
logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.addLogoutHandler(
new PreLogoutDataCaptureHandler(sessionRegistry))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(applicationProperties))
.clearAuthentication(true)

View File

@ -11,7 +11,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -397,23 +396,10 @@ public class UserService implements UserServiceInterface {
}
public void invalidateUserSessions(String username) {
String usernameP = "";
for (Object principal : sessionRegistry.getAllPrincipals()) {
for (SessionInformation sessionsInformation :
sessionRegistry.getAllSessions(principal, false)) {
if (principal instanceof UserDetails detailsUser) {
usernameP = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
usernameP = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
usernameP = saml2User.name();
} else if (principal instanceof String stringUser) {
usernameP = stringUser;
}
if (usernameP.equalsIgnoreCase(username)) {
sessionRegistry.expireSession(sessionsInformation.getSessionId());
}
String usernameP = UserUtils.getUsernameFromPrincipal(principal);
if (usernameP.equalsIgnoreCase(username)) {
sessionRegistry.expireAllSessionsByPrincipalName(usernameP);
}
}
}

View File

@ -0,0 +1,22 @@
package stirling.software.SPDF.config.security;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
public class UserUtils {
public static String getUsernameFromPrincipal(Object principal) {
if (principal instanceof UserDetails detailsUser) {
return detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
return oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
return saml2User.name();
} else if (principal instanceof String stringUser) {
return stringUser;
} else {
return null;
}
}
}

View File

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

View File

@ -1,30 +1,276 @@
package stirling.software.SPDF.config.security.session;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsInterface;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
import stirling.software.SPDF.config.security.UserUtils;
import stirling.software.SPDF.model.ApplicationProperties;
@Component
@Slf4j
public class CustomHttpSessionListener implements HttpSessionListener {
public class CustomHttpSessionListener implements HttpSessionListener, SessionsInterface {
private SessionPersistentRegistry sessionPersistentRegistry;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final ApplicationProperties applicationProperties;
private final boolean loginEnabled;
private final boolean runningEE;
@Autowired
public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) {
@Value("${server.servlet.session.timeout:30m}")
private Duration defaultMaxInactiveInterval;
public CustomHttpSessionListener(
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabled,
@Qualifier("runningEE") boolean runningEE,
ApplicationProperties applicationProperties) {
super();
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabled = loginEnabled;
this.runningEE = runningEE;
this.applicationProperties = applicationProperties;
}
@Override
public void sessionCreated(HttpSessionEvent se) {}
public Collection<SessionsModelInterface> getAllNonExpiredSessions() {
return sessionPersistentRegistry.getAllSessionsNotExpired().stream()
.map(session -> (SessionsModelInterface) session)
.toList();
}
public List<SessionsModelInterface> getAllSessions(Object principalName, boolean expired) {
return sessionPersistentRegistry.getAllSessions().stream()
.filter(s -> s.getPrincipalName().equals(principalName))
.filter(s -> expired == s.isExpired())
.sorted(Comparator.comparing(SessionsModelInterface::getLastRequest))
.collect(Collectors.toList());
}
@Override
public Collection<SessionsModelInterface> getAllSessions() {
return new ArrayList<>(sessionPersistentRegistry.getAllSessions());
}
@Override
public void updateSessionLastRequest(String sessionId) {
sessionPersistentRegistry.refreshLastRequest(sessionId);
}
public Optional<SessionsModelInterface> findLatestSession(String principalName) {
return getAllSessions(principalName, false).stream()
.filter(s -> s.getPrincipalName().equals(principalName))
.max(Comparator.comparing(SessionsModelInterface::getLastRequest));
}
public void expireSession(String sessionId) {
sessionPersistentRegistry.expireSession(sessionId);
}
public void expireSession(String sessionId, boolean expiredByAdmin) {
sessionPersistentRegistry.expireSession(sessionId, expiredByAdmin);
}
public int getMaxInactiveInterval() {
return (int) defaultMaxInactiveInterval.getSeconds();
}
@Override
public void sessionCreated(HttpSessionEvent se) {
HttpSession session = se.getSession();
if (session == null) {
return;
}
SecurityContext securityContext = SecurityContextHolder.getContext();
if (securityContext == null) {
return;
}
Authentication authentication = securityContext.getAuthentication();
if (authentication == null) {
return;
}
Object principal = authentication.getPrincipal();
if (principal == null) {
return;
}
String principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName == null) {
return;
}
if ("anonymousUser".equals(principalName) && loginEnabled) {
return;
}
int allNonExpiredSessions;
if ("anonymousUser".equals(principalName) && !loginEnabled) {
allNonExpiredSessions =
(int) getAllSessions().stream().filter(s -> !s.isExpired()).count();
} else {
allNonExpiredSessions =
(int)
getAllSessions().stream()
.filter(s -> !s.isExpired())
.filter(s -> s.getPrincipalName().equals(principalName))
.count();
}
int all =
getAllSessions().stream()
.filter(s -> !s.isExpired() && s.getPrincipalName().equals(principalName))
.toList()
.size();
boolean isAnonymousUserWithLogin = "anonymousUser".equals(principalName) && loginEnabled;
log.info(
"all {} allNonExpiredSessions {} {} isAnonymousUserWithLogin {}",
all,
allNonExpiredSessions,
getMaxUserSessions(),
isAnonymousUserWithLogin);
if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithLogin) {
log.info("Session {} Expired=TRUE", session.getId());
sessionPersistentRegistry.expireSession(session.getId());
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
// if (allNonExpiredSessions > getMaxUserSessions()) {
// enforceMaxSessionsForPrincipal(principalName);
// }
} else if (all >= getMaxUserSessions() && !isAnonymousUserWithLogin) {
enforceMaxSessionsForPrincipal(principalName);
log.info("Session {} Expired=TRUE", session.getId());
} else if (isAnonymousUserWithLogin) {
sessionPersistentRegistry.expireSession(session.getId());
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
} else {
log.info("Session created: {}", session.getId());
sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName);
}
}
private void enforceMaxSessionsForPrincipal(String principalName) {
// Alle aktiven Sessions des Benutzers über das gemeinsame Interface abrufen
List<SessionsModelInterface> userSessions =
getAllSessions().stream()
.filter(s -> !s.isExpired() && principalName.equals(s.getPrincipalName()))
.sorted(Comparator.comparing(SessionsModelInterface::getLastRequest))
.collect(Collectors.toList());
int maxAllowed = getMaxUserSessions();
if (userSessions.size() > maxAllowed) {
int sessionsToRemove = userSessions.size() - maxAllowed;
log.debug(
"User {} has {} active sessions, removing {} oldest session(s).",
principalName,
userSessions.size(),
sessionsToRemove);
for (int i = 0; i < sessionsToRemove; i++) {
SessionsModelInterface sessionModel = userSessions.get(i);
// Statt auf die HttpSession zuzugreifen, rufen wir die Registry-Methoden auf,
// die die Session anhand der Session-ID invalidieren und entfernen.
sessionPersistentRegistry.expireSession(sessionModel.getSessionId());
sessionPersistentRegistry.removeSessionInformation(sessionModel.getSessionId());
log.debug(
"Removed session {} for principal {}",
sessionModel.getSessionId(),
principalName);
}
}
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
sessionPersistentRegistry.expireSession(se.getSession().getId());
HttpSession session = se.getSession();
if (session == null) {
return;
}
SessionInformation sessionsInfo =
sessionPersistentRegistry.getSessionInformation(session.getId());
if (sessionsInfo == null) {
return;
}
Date lastRequest = sessionsInfo.getLastRequest();
int maxInactiveInterval = (int) defaultMaxInactiveInterval.getSeconds();
Instant now = Instant.now();
Instant expirationTime =
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(session.getId());
session.invalidate();
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
log.debug("Session {} expired=TRUE", session.getId());
}
}
@Override
public void registerSession(HttpSession session) {
sessionCreated(new HttpSessionEvent(session));
}
@Override
public void removeSession(HttpSession session) {
sessionPersistentRegistry.expireSession(session.getId());
session.invalidate();
sessionPersistentRegistry.removeSessionInformation(session.getId());
log.debug("Session {} expired=TRUE", session.getId());
}
// Get the maximum number of application sessions
@Override
public int getMaxApplicationSessions() {
if (runningEE) {
return getMaxUsers() * getMaxUserSessions();
}
return Integer.MAX_VALUE;
}
// Get the maximum number of user sessions
@Override
public int getMaxUserSessions() {
if (loginEnabled) {
if (runningEE) {
return 3;
}
return Integer.MAX_VALUE; // (3)
}
return Integer.MAX_VALUE; // (10)
}
// Get the maximum number of user sessions
@Override
public int getMaxUsers() {
if (loginEnabled) {
if (runningEE) {
int maxUsers = applicationProperties.getPremium().getMaxUsers();
if (maxUsers > 0) {
return maxUsers;
}
}
return Integer.MAX_VALUE; // (50)
}
return Integer.MAX_VALUE; // (1)
}
}

View File

@ -0,0 +1,51 @@
package stirling.software.SPDF.config.security.session;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@AllArgsConstructor
public class PreLogoutDataCaptureHandler implements LogoutHandler {
private final SessionPersistentRegistry sessionPersistentRegistry;
@Override
public void logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
String sessionId = session.getId();
if (sessionId == null) {
return;
}
String path = request.getServletPath();
if (path == null) {
return;
}
// Only handle explicit logout requests
if (!"/logout".equals(path)) {
return;
}
log.debug("Session ID: {} Principal: {}", sessionId, authentication.getPrincipal());
// Mark the session as expired and remove its record
sessionPersistentRegistry.expireSession(sessionId);
sessionPersistentRegistry.removeSessionInformation(sessionId);
}
}

View File

@ -1,30 +1,45 @@
package stirling.software.SPDF.config.security.session;
import java.time.Duration;
import java.util.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import jakarta.transaction.Transactional;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.UserUtils;
import stirling.software.SPDF.model.SessionEntity;
@Component
@Slf4j
public class SessionPersistentRegistry implements SessionRegistry {
private final SessionRepository sessionRepository;
private final boolean runningEE;
private final boolean loginEnabled;
@Value("${server.servlet.session.timeout:30m}")
private Duration defaultMaxInactiveInterval;
public SessionPersistentRegistry(SessionRepository sessionRepository) {
public SessionPersistentRegistry(
SessionRepository sessionRepository,
@Qualifier("runningEE") boolean runningEE,
@Qualifier("loginEnabled") boolean loginEnabled) {
this.runningEE = runningEE;
this.sessionRepository = sessionRepository;
this.loginEnabled = loginEnabled;
}
@Override
@ -41,17 +56,7 @@ public class SessionPersistentRegistry implements SessionRegistry {
public List<SessionInformation> getAllSessions(
Object principal, boolean includeExpiredSessions) {
List<SessionInformation> sessionInformations = new ArrayList<>();
String principalName = null;
if (principal instanceof UserDetails detailsUser) {
principalName = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
principalName = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
principalName = saml2User.name();
} else if (principal instanceof String stringUser) {
principalName = stringUser;
}
String principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName != null) {
List<SessionEntity> sessionEntities =
@ -72,33 +77,28 @@ public class SessionPersistentRegistry implements SessionRegistry {
@Override
@Transactional
public void registerNewSession(String sessionId, Object principal) {
String principalName = null;
if (principal instanceof UserDetails detailsUser) {
principalName = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
principalName = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
principalName = saml2User.name();
} else if (principal instanceof String stringUser) {
principalName = stringUser;
}
String principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName != null) {
// Clear old sessions for the principal (unsure if needed)
// List<SessionEntity> existingSessions =
// sessionRepository.findByPrincipalName(principalName);
// for (SessionEntity session : existingSessions) {
// session.setExpired(true);
// sessionRepository.save(session);
// }
SessionEntity sessionEntity = new SessionEntity();
sessionEntity.setSessionId(sessionId);
int sessionUserCount = getAllSessions(principalName, false).size();
SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId);
if (sessionEntity == null) {
sessionEntity = new SessionEntity();
sessionEntity.setSessionId(sessionId);
log.info("Registering new session for principal: {}", principalName);
}
sessionEntity.setPrincipalName(principalName);
sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date
sessionEntity.setExpired(false);
if (sessionUserCount >= getMaxUserSessions()) {
sessionEntity.setExpired(true);
} else {
sessionEntity.setExpired(false);
}
sessionRepository.save(sessionEntity);
sessionRepository.flush();
}
}
@ -106,16 +106,57 @@ public class SessionPersistentRegistry implements SessionRegistry {
@Transactional
public void removeSessionInformation(String sessionId) {
sessionRepository.deleteById(sessionId);
sessionRepository.flush();
}
@Transactional
public void removeSessionInformationByPrincipalName(String principalName) {
sessionRepository.deleteByPrincipalName(principalName);
sessionRepository.flush();
}
@Override
@Transactional
public void refreshLastRequest(String sessionId) {
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
if (sessionEntityOpt.isPresent()) {
SessionEntity sessionEntity = sessionEntityOpt.get();
SessionEntity sessionEntity = sessionRepository.findBySessionId(sessionId);
if (sessionEntity != null) {
sessionEntity.setLastRequest(new Date()); // Update lastRequest to the current date
sessionRepository.save(sessionEntity);
} else {
log.error("Session not found for session ID: {}", sessionId);
}
}
@Transactional
public void expireOldestSessionForPrincipal(String principalName) {
// Alle Sessions des principalName abrufen
List<SessionEntity> sessionsForPrincipal =
sessionRepository.findByPrincipalName(principalName);
// Nur die nicht abgelaufenen Sessions filtern
List<SessionEntity> nonExpiredSessions =
sessionsForPrincipal.stream()
.filter(session -> !session.isExpired())
.collect(Collectors.toList());
if (nonExpiredSessions.isEmpty()) {
log.debug("No active sessions found for principal {}", principalName);
return;
}
// Die Session mit dem ältesten lastRequest ermitteln
Optional<SessionEntity> oldestSessionOpt =
nonExpiredSessions.stream()
.min(Comparator.comparing(SessionEntity::getLastRequest));
if (oldestSessionOpt.isPresent()) {
SessionEntity oldestSession = oldestSessionOpt.get();
expireSession(oldestSession.getSessionId());
removeSessionInformation(oldestSession.getSessionId());
log.debug(
"Oldest session {} for principal {} has been marked as expired",
oldestSession.getSessionId(),
principalName);
}
}
@ -149,6 +190,61 @@ public class SessionPersistentRegistry implements SessionRegistry {
SessionEntity sessionEntity = sessionEntityOpt.get();
sessionEntity.setExpired(true); // Set expired to true
sessionRepository.save(sessionEntity);
log.debug("Session expired: {}", sessionId);
}
}
public void expireSession(String sessionId, boolean expiredByAdmin) {
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
if (sessionEntityOpt.isPresent()) {
SessionEntity sessionEntity = sessionEntityOpt.get();
sessionEntity.setExpired(true); // Set expired to true
sessionEntity.setAdminExpired(expiredByAdmin);
sessionRepository.save(sessionEntity);
log.debug("Session expired: {}", sessionId);
}
}
// Mark all sessions as expired
public void expireAllSessions() {
List<SessionEntity> sessionEntities = sessionRepository.findAll();
for (SessionEntity sessionEntity : sessionEntities) {
sessionEntity.setExpired(true); // Set expired to true
sessionRepository.save(sessionEntity);
log.debug("Session expired: {}", sessionEntity.getSessionId());
}
}
// Mark all sessions as expired by username
public void expireAllSessionsByUsername(String username) {
List<SessionEntity> sessionEntities = sessionRepository.findByPrincipalName(username);
for (SessionEntity sessionEntity : sessionEntities) {
sessionEntity.setExpired(true); // Set expired to true
sessionRepository.save(sessionEntity);
log.debug("Session expired: {}", sessionEntity.getSessionId());
}
}
// Mark all sessions as expired for a given principal name
public void expireAllSessionsByPrincipalName(String principalName) {
List<SessionEntity> sessionEntities = sessionRepository.findByPrincipalName(principalName);
log.debug("Session entities: {}", sessionEntities.size());
for (SessionEntity sessionEntity : sessionEntities) {
log.debug(
"Session expired: {} {} {}",
sessionEntity.getPrincipalName(),
sessionEntity.isExpired(),
sessionEntity.getSessionId());
sessionEntity.setExpired(true); // Set expired to true
removeSessionInformation(sessionEntity.getSessionId());
}
sessionEntities = sessionRepository.findByPrincipalName(principalName);
log.debug("Session entities: {}", sessionEntities.size());
for (SessionEntity sessionEntity : sessionEntities) {
if (sessionEntity.getPrincipalName().equals(principalName)) {
log.debug("Session expired: {}", sessionEntity.getSessionId());
}
}
}
@ -163,10 +259,19 @@ public class SessionPersistentRegistry implements SessionRegistry {
}
// Update session details by principal name
public void updateSessionByPrincipalName(
String principalName, boolean expired, Date lastRequest) {
sessionRepository.saveByPrincipalName(expired, lastRequest, principalName);
}
// public void updateSessionByPrincipalName(
// String principalName, boolean expired, Date lastRequest) {
// sessionRepository.saveByPrincipalName(expired, lastRequest, principalName);
// }
// Update session details by session ID
// public void updateSessionBySessionId(String sessionId) {
// SessionEntity sessionEntity = getSessionEntity(sessionId);
// if (sessionEntity != null) {
// sessionEntity.setLastRequest(new Date());
// sessionRepository.save(sessionEntity);
// }
// }
// Find the latest session for a given principal name
public Optional<SessionEntity> findLatestSession(String principalName) {
@ -178,15 +283,29 @@ public class SessionPersistentRegistry implements SessionRegistry {
// Sort sessions by lastRequest in descending order
Collections.sort(
allSessions,
new Comparator<SessionEntity>() {
@Override
public int compare(SessionEntity s1, SessionEntity s2) {
// Sort by lastRequest in descending order
return s2.getLastRequest().compareTo(s1.getLastRequest());
}
});
(SessionEntity s1, SessionEntity s2) ->
s2.getLastRequest().compareTo(s1.getLastRequest()));
// The first session in the list is the latest session for the given principal name
return Optional.of(allSessions.get(0));
}
// Get the maximum number of sessions
public int getMaxSessions() {
if (runningEE) {
return Integer.MAX_VALUE;
}
return getMaxUserSessions() * 10;
}
// Get the maximum number of user sessions
public int getMaxUserSessions() {
if (loginEnabled) {
if (runningEE) {
return 3;
}
return Integer.MAX_VALUE; // (3)
}
return Integer.MAX_VALUE; // (10)
}
}

View File

@ -1,5 +1,6 @@
package stirling.software.SPDF.config.security.session;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.session.SessionRegistryImpl;
@ -14,7 +15,9 @@ public class SessionRegistryConfig {
@Bean
public SessionPersistentRegistry sessionPersistentRegistry(
SessionRepository sessionRepository) {
return new SessionPersistentRegistry(sessionRepository);
SessionRepository sessionRepository,
@Qualifier("runningEE") boolean runningEE,
@Qualifier("loginEnabled") boolean loginEnabled) {
return new SessionPersistentRegistry(sessionRepository, runningEE, loginEnabled);
}
}

View File

@ -21,10 +21,13 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
SessionEntity findBySessionId(String sessionId);
void deleteByPrincipalName(String principalName);
@Modifying
@Transactional
@Query(
"UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE s.principalName = :principalName")
"UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE"
+ " s.principalName = :principalName")
void saveByPrincipalName(
@Param("expired") boolean expired,
@Param("lastRequest") Date lastRequest,

View File

@ -5,23 +5,43 @@ import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
public class SessionScheduled {
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
public SessionScheduled(SessionPersistentRegistry sessionPersistentRegistry) {
public SessionScheduled(
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
}
@Scheduled(cron = "0 0/5 * * * ?")
public void expireSessions() {
Instant now = Instant.now();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
for (Object principal : sessionPersistentRegistry.getAllPrincipals()) {
if (principal == null) {
continue;
} else if (principal instanceof String stringPrincipal) {
// Expire anonymousUser sessions if login is enabled
if ("anonymousUser".equals(stringPrincipal) && loginEnabledValue) {
sessionPersistentRegistry.expireAllSessionsByPrincipalName(stringPrincipal);
continue;
}
}
List<SessionInformation> sessionInformations =
sessionPersistentRegistry.getAllSessions(principal, false);
for (SessionInformation sessionInformation : sessionInformations) {
@ -31,6 +51,19 @@ public class SessionScheduled {
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionInformation.getSessionId());
sessionInformation.expireNow();
// Invalidate current authentication if expired session belongs to current user
if (authentication != null && principal.equals(authentication.getPrincipal())) {
authentication.setAuthenticated(false);
}
SecurityContextHolder.clearContext();
log.debug(
"Session expired for principal: {} SessionID: {}",
principal,
sessionInformation.getSessionId());
}
}
}

View File

@ -0,0 +1,136 @@
package stirling.software.SPDF.config.anonymus.session;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.interfaces.SessionsInterface;
import stirling.software.SPDF.config.security.UserUtils;
import stirling.software.SPDF.config.security.session.CustomHttpSessionListener;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
@Controller
@Slf4j
public class SessionStatusController {
@Qualifier("loginEnabled")
private boolean loginEnabled;
@Autowired private SessionPersistentRegistry sessionPersistentRegistry;
@Autowired private SessionsInterface sessionInterface;
@Autowired private CustomHttpSessionListener customHttpSessionListener;
// list all sessions from authentication user, return String redirect userSession.html
@GetMapping("/userSession")
public String getUserSessions(
HttpServletRequest request, Model model, Authentication authentication) {
if ((authentication == null || !authentication.isAuthenticated()) && loginEnabled) {
return "redirect:/login";
}
HttpSession session = request.getSession(false);
if (session != null) {
String principalName = null;
if (authentication != null && authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName == null) {
return "redirect:/login";
}
} else {
principalName = "anonymousUser";
}
boolean isSessionValid =
sessionPersistentRegistry.getAllSessions(principalName, false).stream()
.allMatch(
sessionEntity ->
sessionEntity.getSessionId().equals(session.getId()));
if (isSessionValid) {
return "redirect:/";
}
// Get all sessions for the user
List<SessionInformation> sessionList =
sessionPersistentRegistry.getAllSessions(principalName, false).stream()
.filter(
sessionEntity ->
!sessionEntity.getSessionId().equals(session.getId()))
.toList();
model.addAttribute("sessionList", sessionList);
return "userSession";
}
return "redirect:/login";
}
@GetMapping("/userSession/invalidate/{sessionId}")
public String invalidateUserSession(
HttpServletRequest request,
Authentication authentication,
@PathVariable String sessionId)
throws ServletException {
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/login";
}
Object principal = authentication.getPrincipal();
String principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName == null) {
return "redirect:/login";
}
boolean isOwner =
sessionPersistentRegistry.getAllSessions(principalName, false).stream()
.anyMatch(session -> session.getSessionId().equals(sessionId));
if (isOwner) {
customHttpSessionListener.expireSession(sessionId, false);
sessionPersistentRegistry.registerNewSession(
request.getRequestedSessionId().split(".node0")[0], principal);
// return "redirect:/userSession?messageType=sessionInvalidated"
return "redirect:/userSession";
} else {
return "redirect:/login";
}
}
@GetMapping("/session/invalidate/{sessionId}")
public String invalidateSession(
HttpServletRequest request,
Authentication authentication,
@PathVariable String sessionId) {
// ist ROLE_ADMIN oder session inhaber
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/login";
}
Object principal = authentication.getPrincipal();
String principalName = UserUtils.getUsernameFromPrincipal(principal);
if (principalName == null) {
return "redirect:/login";
}
boolean isAdmin =
authentication.getAuthorities().stream()
.anyMatch(role -> "ROLE_ADMIN".equals(role.getAuthority()));
boolean isOwner =
sessionPersistentRegistry.getAllSessions(principalName, false).stream()
.anyMatch(session -> session.getSessionId().equals(sessionId));
if (isAdmin || isOwner) {
customHttpSessionListener.expireSession(sessionId, isAdmin);
return "redirect:/adminSettings?messageType=sessionInvalidated";
} else {
return "redirect:/login";
}
}
}

View File

@ -13,8 +13,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@ -30,7 +28,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.SPDF.config.security.UserUtils;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.AuthenticationType;
@ -304,19 +302,11 @@ public class UserController {
if (!enabled) {
// Invalidate all sessions if the user is being disabled
List<Object> principals = sessionRegistry.getAllPrincipals();
String userNameP = "";
for (Object principal : principals) {
List<SessionInformation> sessionsInformation =
sessionRegistry.getAllSessions(principal, false);
if (principal instanceof UserDetails detailsUser) {
userNameP = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
userNameP = oAuth2User.getName();
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
userNameP = saml2User.name();
} else if (principal instanceof String stringUser) {
userNameP = stringUser;
}
String userNameP = UserUtils.getUsernameFromPrincipal(principal);
if (userNameP.equalsIgnoreCase(username)) {
for (SessionInformation sessionInfo : sessionsInformation) {
sessionRegistry.expireSession(sessionInfo.getSessionId());

View File

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

View File

@ -9,15 +9,36 @@ import jakarta.persistence.Table;
import lombok.Data;
import stirling.software.SPDF.config.interfaces.SessionsModelInterface;
@Entity
@Data
@Table(name = "sessions")
public class SessionEntity implements Serializable {
public class SessionEntity implements Serializable, SessionsModelInterface {
@Id private String sessionId;
private String principalName;
private Date lastRequest;
private boolean expired;
private Boolean adminExpired = false;
@Override
public String getSessionId() {
return sessionId;
}
@Override
public String getPrincipalName() {
return principalName;
}
@Override
public Date getLastRequest() {
return lastRequest;
}
@Override
public boolean isExpired() {
return expired;
}
}

View File

@ -176,6 +176,7 @@ settings.accountSettings=Kontoeinstellungen
settings.bored.help=Aktiviert das Easter-Egg-Spiel
settings.cacheInputs.name=Formulareingaben speichern
settings.cacheInputs.help=Aktivieren, um zuvor verwendete Eingaben für zukünftige Durchläufe zu speichern
settings.userSessions=Benutzersitzungen
changeCreds.title=Anmeldeinformationen ändern
changeCreds.header=Aktualisieren Sie Ihre Kontodaten
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Aktive Benutzer:
adminUserSettings.disabledUsers=Deaktivierte Benutzer:
adminUserSettings.totalUsers=Gesamtzahl der Benutzer:
adminUserSettings.lastRequest=Letzte Anfrage
adminUserSettings.userSessions=Benutzersitzungen
adminUserSettings.totalSessions=Gesamtzahl der Sitzungen:
adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics
@ -285,6 +288,14 @@ database.notSupported=Diese Funktion ist für deine Datenbankverbindung nicht ve
session.expired=Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.
session.refreshPage=Seite aktualisieren
#################
# USER SESSION #
#################
userSession.title=Benutzersitzungen
userSession.header=Benutzersitzungen
userSession.maxUserSession=Wenn die maximale Anzahl Sitzungen für diesen Benutzer erreicht ist, können Sie hier andere Anmeldungen beenden, um auf diesem Gerät fortzufahren.
userSession.lastRequest=Letzte Aufrufe
#############
# HOME-PAGE #
#############

View File

@ -176,6 +176,7 @@ settings.accountSettings=Account Settings
settings.bored.help=Enables easter egg game
settings.cacheInputs.name=Save form inputs
settings.cacheInputs.help=Enable to store previously used inputs for future runs
settings.userSessions=User Sessions
changeCreds.title=Change Credentials
changeCreds.header=Update Your Account Details
@ -237,6 +238,8 @@ adminUserSettings.activeUsers=Active Users:
adminUserSettings.disabledUsers=Disabled Users:
adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request
adminUserSettings.userSessions=User sessions
adminUserSettings.totalSessions=Total Sessions:
adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics
@ -285,6 +288,14 @@ database.notSupported=This function is not available for your database connectio
session.expired=Your session has expired. Please refresh the page and try again.
session.refreshPage=Refresh Page
#################
# USER SESSION #
#################
userSession.title=User Sessions
userSession.header=User Sessions
userSession.maxUserSession=If the maximum number of sessions for this user is reached, you can end other logins here to continue on this device.
userSession.lastRequest=last Request
#############
# HOME-PAGE #
#############

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ check_webpage() {
local result_file="$3"
# Use curl to fetch the page with timeout
response=$(curl -s -w "\n%{http_code}" --max-time $timeout "$full_url")
response=$(curl -b cookies.txt -s -w "\n%{http_code}" --max-time $timeout "$full_url")
if [ $? -ne 0 ]; then
echo "FAILED - Connection error or timeout $full_url" >> "$result_file"
return 1
@ -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!"