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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-18 12:43:19 +02:00
parent 38818998e5
commit 99a4230bb9
2 changed files with 95 additions and 0 deletions

View File

@@ -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<AppUser> 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<Map<String, Object>> 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();
}

View File

@@ -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());
}
}