feat: track user login and logout timestamps in session management
This commit is contained in:
@@ -36,4 +36,10 @@ public class AppUser {
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
private Set<Role> roles = new HashSet<>();
|
||||
|
||||
@Column(name = "last_login_at")
|
||||
private java.time.LocalDateTime lastLoginAt;
|
||||
|
||||
@Column(name = "last_logout_at")
|
||||
private java.time.LocalDateTime lastLogoutAt;
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.astalange.core.security;
|
||||
|
||||
import com.astalange.core.entity.AppUser;
|
||||
import com.astalange.core.repository.AppUserRepository;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
|
||||
import org.springframework.security.authentication.event.LogoutSuccessEvent;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.web.session.HttpSessionDestroyedEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class AuthenticationEventListener {
|
||||
|
||||
private final AppUserRepository userRepository;
|
||||
|
||||
public AuthenticationEventListener(AppUserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
|
||||
String username = extractUsername(event.getAuthentication().getPrincipal());
|
||||
if (username != null) {
|
||||
userRepository.findByUsername(username).ifPresent(user -> {
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void onLogoutSuccess(LogoutSuccessEvent event) {
|
||||
if (event.getAuthentication() != null) {
|
||||
String username = extractUsername(event.getAuthentication().getPrincipal());
|
||||
if (username != null) {
|
||||
userRepository.findByUsername(username).ifPresent(user -> {
|
||||
user.setLastLogoutAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void onSessionDestroyed(HttpSessionDestroyedEvent event) {
|
||||
List<SecurityContext> contexts = event.getSecurityContexts();
|
||||
for (SecurityContext context : contexts) {
|
||||
if (context != null && context.getAuthentication() != null) {
|
||||
String username = extractUsername(context.getAuthentication().getPrincipal());
|
||||
if (username != null) {
|
||||
userRepository.findByUsername(username).ifPresent(user -> {
|
||||
if (user.getLastLogoutAt() == null || (user.getLastLoginAt() != null && user.getLastLogoutAt().isBefore(user.getLastLoginAt()))) {
|
||||
user.setLastLogoutAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String extractUsername(Object principal) {
|
||||
if (principal instanceof UserDetails) {
|
||||
return ((UserDetails) principal).getUsername();
|
||||
} else if (principal instanceof String) {
|
||||
return (String) principal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE app_user ADD COLUMN last_login_at TIMESTAMP;
|
||||
ALTER TABLE app_user ADD COLUMN last_logout_at TIMESTAMP;
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.astalange.web.controller;
|
||||
|
||||
import com.astalange.core.entity.AppUser;
|
||||
import com.astalange.core.entity.Role;
|
||||
import com.astalange.core.repository.AppUserRepository;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.session.SessionRegistry;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
@@ -8,7 +11,9 @@ import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Controller
|
||||
@@ -16,22 +21,73 @@ import java.util.stream.Collectors;
|
||||
public class SessionController {
|
||||
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final AppUserRepository userRepository;
|
||||
|
||||
public SessionController(SessionRegistry sessionRegistry) {
|
||||
public SessionController(SessionRegistry sessionRegistry, AppUserRepository userRepository) {
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public static class UserSessionDTO {
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final boolean online;
|
||||
private final LocalDateTime lastLoginAt;
|
||||
private final LocalDateTime lastLogoutAt;
|
||||
private final String roles;
|
||||
|
||||
public UserSessionDTO(Long id, String username, boolean online, LocalDateTime lastLoginAt, LocalDateTime lastLogoutAt, String roles) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.online = online;
|
||||
this.lastLoginAt = lastLoginAt;
|
||||
this.lastLogoutAt = lastLogoutAt;
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getUsername() { return username; }
|
||||
public boolean isOnline() { return online; }
|
||||
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
||||
public LocalDateTime getLastLogoutAt() { return lastLogoutAt; }
|
||||
public String getRoles() { return roles; }
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public String viewSessions(Model model) {
|
||||
List<Object> principals = sessionRegistry.getAllPrincipals();
|
||||
List<String> activeUsers = principals.stream()
|
||||
Set<String> activeUsernames = principals.stream()
|
||||
.filter(principal -> principal instanceof UserDetails)
|
||||
.map(principal -> ((UserDetails) principal).getUsername())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
model.addAttribute("activeUsers", activeUsers);
|
||||
model.addAttribute("activeCount", activeUsers.size());
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<AppUser> allUsers = userRepository.findAll();
|
||||
|
||||
List<UserSessionDTO> userSessions = allUsers.stream().map(user -> {
|
||||
boolean isOnline = activeUsernames.contains(user.getUsername());
|
||||
String rolesStr = user.getRoles().stream()
|
||||
.map(Role::getName)
|
||||
.map(r -> r.replace("ROLE_", ""))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
return new UserSessionDTO(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
isOnline,
|
||||
user.getLastLoginAt(),
|
||||
user.getLastLogoutAt(),
|
||||
rolesStr
|
||||
);
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
long activeCount = userSessions.stream().filter(UserSessionDTO::isOnline).count();
|
||||
long offlineCount = userSessions.size() - activeCount;
|
||||
|
||||
model.addAttribute("userSessions", userSessions);
|
||||
model.addAttribute("activeCount", activeCount);
|
||||
model.addAttribute("offlineCount", offlineCount);
|
||||
model.addAttribute("totalCount", userSessions.size());
|
||||
return "admin/sessions";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Utilisateurs Connectés - AS Talange</title>
|
||||
<title>Utilisateurs & Sessions - AS Talange</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
@@ -19,41 +19,99 @@
|
||||
<main class="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Utilisateurs Connectés</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-800">Suivi des Connectivité & Sessions</h2>
|
||||
</header>
|
||||
|
||||
<!-- Main section -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
|
||||
<!-- Cards summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Total Utilisateurs</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1" th:text="${totalCount}">0</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center font-bold">
|
||||
👥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Sessions Actives (En ligne)</p>
|
||||
<p class="text-2xl font-bold text-green-600 mt-1" th:text="${activeCount}">0</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-lg bg-green-50 text-green-600 flex items-center justify-center font-bold">
|
||||
🟢
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-5 rounded-xl shadow-sm border border-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Hors Ligne</p>
|
||||
<p class="text-2xl font-bold text-gray-600 mt-1" th:text="${offlineCount}">0</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-100 text-gray-500 flex items-center justify-center font-bold">
|
||||
⚪
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header & Action -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
Sessions Actives (<span th:text="${activeCount}">0</span>)
|
||||
Liste des Utilisateurs
|
||||
</h3>
|
||||
<button hx-get="/admin/sessions" hx-target="body" hx-swap="outerHTML" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Rafraîchir
|
||||
<button hx-get="/admin/sessions" hx-target="body" hx-swap="outerHTML" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors flex items-center space-x-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span>Rafraîchir</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
|
||||
<th class="py-3 px-6 font-medium text-left">Nom d'utilisateur</th>
|
||||
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-200">
|
||||
<th class="py-3 px-6 font-medium text-left">Utilisateur</th>
|
||||
<th class="py-3 px-6 font-medium text-left">Rôle(s)</th>
|
||||
<th class="py-3 px-6 font-medium text-left">Statut</th>
|
||||
<th class="py-3 px-6 font-medium text-left">Dernière Connexion</th>
|
||||
<th class="py-3 px-6 font-medium text-left">Dernière Déconnexion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 text-sm">
|
||||
<tr th:if="${#lists.isEmpty(activeUsers)}">
|
||||
<td colspan="2" class="py-8 text-center text-gray-500">Aucun utilisateur connecté pour le moment.</td>
|
||||
<tr th:if="${#lists.isEmpty(userSessions)}">
|
||||
<td colspan="5" class="py-8 text-center text-gray-500">Aucun utilisateur enregistré.</td>
|
||||
</tr>
|
||||
<tr th:each="username : ${activeUsers}" class="hover:bg-gray-50 transition-colors">
|
||||
<tr th:each="userSession : ${userSessions}" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="py-4 px-6 font-medium text-gray-900 flex items-center">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold mr-3">
|
||||
<span th:text="${#strings.substring(username, 0, 1).toUpperCase()}">U</span>
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold mr-3 text-xs">
|
||||
<span th:text="${#strings.substring(userSession.username, 0, 1).toUpperCase()}">U</span>
|
||||
</div>
|
||||
<span th:text="${username}">username</span>
|
||||
<span th:text="${userSession.username}">username</span>
|
||||
</td>
|
||||
<td class="py-4 px-6">
|
||||
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded-full">En ligne</span>
|
||||
<span class="px-2.5 py-0.5 bg-gray-100 text-gray-700 text-xs font-medium rounded border border-gray-200" th:text="${userSession.roles}">ADMIN</span>
|
||||
</td>
|
||||
<td class="py-4 px-6">
|
||||
<span th:if="${userSession.online}" class="inline-flex items-center px-2.5 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded-full">
|
||||
<span class="w-2 h-2 mr-1.5 bg-green-500 rounded-full animate-pulse"></span>
|
||||
En ligne
|
||||
</span>
|
||||
<span th:unless="${userSession.online}" class="inline-flex items-center px-2.5 py-1 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
|
||||
<span class="w-2 h-2 mr-1.5 bg-gray-400 rounded-full"></span>
|
||||
Hors ligne
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-gray-600 font-mono text-xs">
|
||||
<span th:text="${userSession.lastLoginAt != null ? #temporals.format(userSession.lastLoginAt, 'dd/MM/yyyy HH:mm:ss') : 'Jamais'}">01/01/2026 10:00:00</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-gray-600 font-mono text-xs">
|
||||
<span th:text="${userSession.lastLogoutAt != null ? #temporals.format(userSession.lastLogoutAt, 'dd/MM/yyyy HH:mm:ss') : '-'}">01/01/2026 12:00:00</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user