add expired by Admin

This commit is contained in:
Ludy87 2025-04-10 08:14:06 +02:00
parent cfa71e537b
commit 09da81643c
No known key found for this signature in database
GPG Key ID: 92696155E0220F94
9 changed files with 220 additions and 89 deletions

View File

@ -536,6 +536,9 @@ dependencies {
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
// https://mvnrepository.com/artifact/org.springframework/spring-webflux
implementation("org.springframework.boot:spring-boot-starter-webflux")
}
tasks.withType(JavaCompile).configureEach {

View File

@ -39,14 +39,14 @@ public class EndpointInterceptor implements HandlerInterceptor {
}
String requestURI = request.getRequestURI();
if ("GET".equalsIgnoreCase(request.getMethod())) {
boolean isApiRequest = requestURI.contains("/api/v1");
if ("GET".equalsIgnoreCase(request.getMethod()) && !isApiRequest) {
Principal principal = request.getUserPrincipal();
boolean isApiRequest = requestURI.contains("/api/v1");
// allowlist for public or static routes
if (("/".equals(requestURI)
if ("/".equals(requestURI)
|| "/login".equals(requestURI)
|| "/home".equals(requestURI)
|| "/home-legacy".equals(requestURI)
@ -61,9 +61,9 @@ public class EndpointInterceptor implements HandlerInterceptor {
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".webmanifest")
|| requestURI.contains("/files/")) && !isApiRequest) {
|| requestURI.contains("/files/")) {
return true;
} else if (principal != null && !isApiRequest) {
} else if (principal != null) {
if (session == null) {
session = request.getSession(true);
}
@ -71,6 +71,38 @@ public class EndpointInterceptor implements HandlerInterceptor {
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) {
response.sendRedirect("/logout");
log.info("Session expired. Logging out user {}", principal.getName());
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));
final String currentPrincipal = principal.getName();
long userSessions =
@ -82,29 +114,21 @@ public class EndpointInterceptor implements HandlerInterceptor {
s.getPrincipalName()))
.count();
long totalSessions =
sessionsInterface.getAllSessions().stream()
.filter(s -> !s.isExpired())
.count();
int maxUserSessions = sessionsInterface.getMaxUserSessions();
log.info(
"Active sessions for {}: {} (max: {}) | Total: {} (max: {})",
"Active sessions for {}: {} (max: {}) | Total: {} (max: {}) | Active"
+ " sessions: {}",
currentPrincipal,
userSessions,
maxUserSessions,
totalSessions,
sessionsInterface.getMaxApplicationSessions());
boolean isCurrentSessionRegistered =
sessionsInterface.getAllSessions().stream()
.filter(s -> !s.isExpired())
.anyMatch(s -> s.getSessionId().equals(sessionId));
totalSessionsNonExpired,
maxApplicationSessions,
hasUserActiveSession);
if ((userSessions >= maxUserSessions
|| totalSessions >= sessionsInterface.getMaxApplicationSessions())
&& !isCurrentSessionRegistered) {
|| totalSessionsNonExpired >= maxApplicationSessions)
&& !hasUserActiveSession) {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
"Max sessions reached for this user. To continue on this device, please"
@ -114,15 +138,15 @@ public class EndpointInterceptor implements HandlerInterceptor {
// If session is not registered yet, register it; otherwise, update the last request
// timestamp.
if (!isCurrentSessionRegistered) {
log.debug("Register session: {}", sessionId);
if (!hasUserActiveSession) {
log.info("Register session: {}", sessionId);
sessionsInterface.registerSession(finalSession);
} else {
log.debug("Update session last request: {}", sessionId);
log.info("Update session last request: {}", sessionId);
sessionsInterface.updateSessionLastRequest(sessionId);
}
return true;
} else if (principal == null && !isApiRequest) {
} else if (principal == null) {
if (session == null) {
session = request.getSession(true);
}

View File

@ -89,6 +89,10 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI
sessionPersistentRegistry.expireSession(sessionId);
}
public void expireSession(String sessionId, boolean expiredByAdmin) {
sessionPersistentRegistry.expireSession(sessionId, expiredByAdmin);
}
public int getMaxInactiveInterval() {
return (int) defaultMaxInactiveInterval.getSeconds();
}
@ -139,7 +143,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI
.toList()
.size();
boolean isAnonymousUserWithoutLogin = "anonymousUser".equals(principalName) && loginEnabled;
log.debug(
log.info(
"all {} allNonExpiredSessions {} {} isAnonymousUserWithoutLogin {}",
all,
allNonExpiredSessions,
@ -147,7 +151,7 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI
isAnonymousUserWithoutLogin);
if (allNonExpiredSessions >= getMaxApplicationSessions() && !isAnonymousUserWithoutLogin) {
log.debug("Session {} Expired=TRUE", session.getId());
log.info("Session {} Expired=TRUE", session.getId());
sessionPersistentRegistry.expireSession(session.getId());
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
// if (allNonExpiredSessions > getMaxUserSessions()) {
@ -155,12 +159,12 @@ public class CustomHttpSessionListener implements HttpSessionListener, SessionsI
// }
} else if (all >= getMaxUserSessions() && !isAnonymousUserWithoutLogin) {
enforceMaxSessionsForPrincipal(principalName);
log.debug("Session {} Expired=TRUE", session.getId());
log.info("Session {} Expired=TRUE", session.getId());
} else if (isAnonymousUserWithoutLogin) {
sessionPersistentRegistry.expireSession(session.getId());
sessionPersistentRegistry.removeSessionInformation(se.getSession().getId());
} else {
log.debug("Session created: {}", session.getId());
log.info("Session created: {}", session.getId());
sessionPersistentRegistry.registerNewSession(se.getSession().getId(), principalName);
}
}

View File

@ -28,14 +28,18 @@ 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, @Qualifier("runningEE") boolean runningEE) {
SessionRepository sessionRepository,
@Qualifier("runningEE") boolean runningEE,
@Qualifier("loginEnabled") boolean loginEnabled) {
this.runningEE = runningEE;
this.sessionRepository = sessionRepository;
this.loginEnabled = loginEnabled;
}
@Override
@ -86,7 +90,7 @@ public class SessionPersistentRegistry implements SessionRegistry {
if (sessionEntity == null) {
sessionEntity = new SessionEntity();
sessionEntity.setSessionId(sessionId);
log.debug("Registering new session for principal: {}", principalName);
log.info("Registering new session for principal: {}", principalName);
}
sessionEntity.setPrincipalName(principalName);
sessionEntity.setLastRequest(new Date()); // Set lastRequest to the current date
@ -188,6 +192,17 @@ public class SessionPersistentRegistry implements SessionRegistry {
}
}
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();
@ -283,9 +298,12 @@ public class SessionPersistentRegistry implements SessionRegistry {
// Get the maximum number of user sessions
public int getMaxUserSessions() {
if (runningEE) {
return Integer.MAX_VALUE;
if (loginEnabled) {
if (runningEE) {
return 3;
}
return Integer.MAX_VALUE; // (3)
}
return 3;
return Integer.MAX_VALUE; // (10)
}
}

View File

@ -15,7 +15,9 @@ public class SessionRegistryConfig {
@Bean
public SessionPersistentRegistry sessionPersistentRegistry(
SessionRepository sessionRepository, @Qualifier("runningEE") boolean runningEE) {
return new SessionPersistentRegistry(sessionRepository, runningEE);
SessionRepository sessionRepository,
@Qualifier("runningEE") boolean runningEE,
@Qualifier("loginEnabled") boolean loginEnabled) {
return new SessionPersistentRegistry(sessionRepository, runningEE, loginEnabled);
}
}

View File

@ -9,9 +9,9 @@ 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.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@ -20,15 +20,18 @@ 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;
@RestController
@Controller
@Slf4j
public class SessionStatusController {
@Autowired private SessionPersistentRegistry sessionPersistentRegistry;
@Autowired private SessionsInterface sessionInterface;
@Autowired private CustomHttpSessionListener customHttpSessionListener;
// Returns the current session ID or 401 if no session exists
@GetMapping("/session")
public ResponseEntity<String> getSession(HttpServletRequest request) {
@ -40,6 +43,35 @@ public class SessionStatusController {
}
}
@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";
}
}
// Checks if the session is active and valid according to user session limits
@GetMapping("/session/status")
public ResponseEntity<String> getSessionStatus(HttpServletRequest request) {

View File

@ -215,13 +215,15 @@ 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, Integer> userActiveSessions = new HashMap<>();
Map<String, List<SessionsModelInterface>> userActiveSessions = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
int maxSessions = customHttpSessionListener.getMaxApplicationSessions();
@ -273,7 +275,7 @@ public class AccountWebController {
}
List<SessionsModelInterface> sessionInformations =
customHttpSessionListener.getAllSessions(user.getUsername(), false);
userActiveSessions.put(user.getUsername(), sessionInformations.size());
userActiveSessions.put(user.getUsername(), sessionInformations);
}
}
// Sort users by active status and last request date
@ -335,6 +337,7 @@ public class AccountWebController {
}
model.addAttribute("users", sortedUsers);
model.addAttribute("currentSessionId", currentSessionId);
if (authentication != null) {
model.addAttribute("currentUsername", authentication.getName());
}

View File

@ -20,6 +20,8 @@ public class SessionEntity implements Serializable, SessionsModelInterface {
private Date lastRequest;
private boolean expired;
private Boolean adminExpired = false;
@Override
public String getSessionId() {
return sessionId;

View File

@ -66,7 +66,7 @@
<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>
<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>
@ -75,7 +75,8 @@
<span th:text="${disabledUsers}"></span>
<th:block>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalSessions}">Total Sessions:</strong>
<span th:text="${sessionCount}"></span><span th:text="' | ' + ${maxSessions}"></span>
<span th:if="${@runningProOrHigher}" th:text="${sessionCount}"></span>
<span th:text="' | ' + ${maxSessions}"></span>
</th:block>
</div>
</div>
@ -107,59 +108,101 @@
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.userSessions}" th:text="#{adminUserSettings.userSessions}">
User Sessions</th>
<th scope="col" th:title="#{adminUserSettings.actions}" th:text="#{adminUserSettings.actions}" colspan="2">
Actions</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>
<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>
<th:block th:if="${@runningProOrHigher}">
<td style="align-content: center;"
th:text="${userActiveSessions[user.username] != null ? userActiveSessions[user.username] : 0}">
<th:block th:each="user, iterStat : ${users}">
<tr>
<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>
</th:block>
<th:block th:if="${!@runningProOrHigher}">
<td style="align-content: center;"
th:text="${userActiveSessions[user.username] != null ? userActiveSessions[user.username] : 0} + '/' + ${maxUserSessions}">
<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>
</th:block>
<td style="align-content: center;">
<form th:if="${user.username != currentUsername}"
th:action="@{'/api/v1/user/admin/deleteUser/' + ${user.username}}" method="post"
onsubmit="return confirmDeleteUser()">
<button type="submit" th:title="#{adminUserSettings.deleteUser}" class="btn btn-info btn-sm"><span
class="material-symbols-rounded">person_remove</span></button>
</form>
<a th:if="${user.username == currentUsername}" th:title="#{adminUserSettings.editOwnProfil}"
th:href="@{'/account'}" class="btn btn-outline-success btn-sm"><span
class="material-symbols-rounded">edit</span></a>
</td>
<td style="align-content: center;">
<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">
<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">
<span class="material-symbols-rounded">person_off</span>
</button>
</form>
</td>
</tr>
<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">
<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">
<span class="material-symbols-rounded">person_off</span>
</button>
</form>
</td>
</tr>
<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>