From 1b178767abecac7e94df6c0e5022bd90593098e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:43:19 +0200 Subject: [PATCH] feat(auth): revoke other sessions on password change; add force-logout endpoint changePassword now calls authService.revokeOtherSessions() after the password is updated and emits a LOGOUT audit with reason=password_change. POST /api/users/{id}/force-logout (ADMIN_USER permission) revokes all sessions for the target user and emits ADMIN_FORCE_LOGOUT audit. Returns {"revokedCount": N} with 200. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/user/UserController.java | 23 ++++++ .../user/UserControllerTest.java | 72 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java index 543074e1..69eba03d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java @@ -4,7 +4,11 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.user.AdminUpdateUserRequest; import org.raddatz.familienarchiv.user.ChangePasswordDTO; import org.raddatz.familienarchiv.user.CreateUserRequest; @@ -33,6 +37,8 @@ import lombok.AllArgsConstructor; @AllArgsConstructor public class UserController { private UserService userService; + private AuthService authService; + private AuditService auditService; @GetMapping("users/me") public ResponseEntity getCurrentUser(Authentication authentication) { @@ -56,9 +62,14 @@ public class UserController { @PostMapping("users/me/password") @ResponseStatus(HttpStatus.NO_CONTENT) public void changePassword(Authentication authentication, + HttpSession session, @RequestBody ChangePasswordDTO dto) { AppUser current = userService.findByEmail(authentication.getName()); userService.changePassword(current.getId(), dto); + int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName()); + auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of( + "reason", "password_change", + "revokedCount", revoked)); } @GetMapping("users/{id}") @@ -101,6 +112,18 @@ public class UserController { return ResponseEntity.ok().build(); } + @PostMapping("/users/{id}/force-logout") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity> forceLogout(Authentication authentication, + @PathVariable UUID id) { + AppUser target = userService.getById(id); + int revoked = authService.revokeAllSessions(target.getEmail()); + auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of( + "targetUserId", target.getId().toString(), + "revokedCount", revoked)); + return ResponseEntity.ok(Map.of("revokedCount", revoked)); + } + private UUID actorId(Authentication auth) { return userService.findByEmail(auth.getName()).getId(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java index ccdda803..1adb2cc5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.user; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.security.PermissionAspect; @@ -10,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -33,6 +36,8 @@ class UserControllerTest { @Autowired MockMvc mockMvc; @MockitoBean UserService userService; + @MockitoBean AuthService authService; + @MockitoBean AuditService auditService; @MockitoBean CustomUserDetailsService customUserDetailsService; // ─── GET /api/users/me ──────────────────────────────────────────────────────── @@ -158,4 +163,71 @@ class UserControllerTest { mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } + + // ─── POST /api/users/me/password (changePassword + session revocation) ──── + + @Test + @WithMockUser(username = "user@example.com") + void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception { + AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build(); + when(userService.findByEmail("user@example.com")).thenReturn(user); + when(authService.revokeOtherSessions(any(), any())).thenReturn(1); + + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isNoContent()); + + org.mockito.Mockito.verify(authService).revokeOtherSessions(any(), org.mockito.ArgumentMatchers.eq("user@example.com")); + } + + @Test + void changePassword_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isUnauthorized()); + } + + // ─── POST /api/users/{id}/force-logout ──────────────────────────────────── + + @Test + @WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER") + void forceLogout_returns200_and_revokes_target_sessions() throws Exception { + UUID targetId = UUID.randomUUID(); + AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build(); + AppUser target = AppUser.builder().id(targetId).email("target@example.com").build(); + when(userService.findByEmail("admin@example.com")).thenReturn(actor); + when(userService.getById(targetId)).thenReturn(target); + when(authService.revokeAllSessions("target@example.com")).thenReturn(2); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2)); + } + + @Test + void forceLogout_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void forceLogout_returns403_whenMissingPermission() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_USER") + void forceLogout_returns404_whenUserNotFound() throws Exception { + UUID targetId = UUID.randomUUID(); + when(userService.getById(targetId)).thenThrow( + org.raddatz.familienarchiv.exception.DomainException.notFound( + org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found")); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isNotFound()); + } }