diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java index 11c34d2d..076eb8d6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -7,10 +7,12 @@ import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; import org.springframework.stereotype.Service; import java.util.Map; @@ -25,6 +27,9 @@ public class AuthService { private final UserService userService; private final AuditService auditService; + @Autowired(required = false) + private JdbcIndexedSessionRepository sessionRepository; + /** * Validates credentials and returns the authenticated user plus the Spring Security * Authentication object. The caller is responsible for persisting the Authentication @@ -53,6 +58,23 @@ public class AuthService { } } + public int revokeOtherSessions(String currentSessionId, String principalName) { + int count = 0; + for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) { + if (!id.equals(currentSessionId)) { + sessionRepository.deleteById(id); + count++; + } + } + return count; + } + + public int revokeAllSessions(String principalName) { + var sessions = sessionRepository.findByPrincipalName(principalName); + sessions.keySet().forEach(sessionRepository::deleteById); + return sessions.size(); + } + public void logout(String email, String ip, String ua) { AppUser user = userService.findByEmail(email); auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java index 9ae0182a..feacebac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -15,11 +15,16 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.test.util.ReflectionTestUtils; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; @@ -31,11 +36,17 @@ class AuthServiceTest { @Mock AuthenticationManager authenticationManager; @Mock UserService userService; @Mock AuditService auditService; + @Mock JdbcIndexedSessionRepository sessionRepository; @InjectMocks AuthService authService; private static final String IP = "127.0.0.1"; private static final String UA = "Mozilla/5.0 (Test)"; + @BeforeEach + void injectSessionRepository() { + ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository); + } + @Test void login_returns_user_on_valid_credentials() { UUID userId = UUID.randomUUID(); @@ -129,4 +140,36 @@ class AuthServiceTest { && !payload.containsKey("password")) ); } + + @SuppressWarnings("unchecked") + @Test + void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() { + var sessions = new HashMap(); + sessions.put("session-keep", null); + sessions.put("session-del-1", null); + sessions.put("session-del-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = authService.revokeOtherSessions("session-keep", "user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository, never()).deleteById("session-keep"); + verify(sessionRepository).deleteById("session-del-1"); + verify(sessionRepository).deleteById("session-del-2"); + } + + @SuppressWarnings("unchecked") + @Test + void revokeAllSessions_deletes_all_sessions_for_principal() { + var sessions = new HashMap(); + sessions.put("session-1", null); + sessions.put("session-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = authService.revokeAllSessions("user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository).deleteById("session-1"); + verify(sessionRepository).deleteById("session-2"); + } }