From 903153307725af72b403ec70a16424f77a0b1b0a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:21:47 +0200 Subject: [PATCH] feat(auth): add POST /auth/logout that revokes all user tokens Bumps users.token_revoked_before = now() for the calling user, audited under AuditCategory.AUTH. Best-effort: returns 204 even when the request is unauthenticated, so the SPA can call it on every logout regardless of token state. Token-rejection is enforced by the existing JwtAuthenticationFilter revocation check (fixed in 7066795c). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/security/UiAuthController.java | 16 +++++ .../app/security/LogoutControllerIT.java | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java index 8413c9f3..467171de 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java @@ -183,6 +183,22 @@ public class UiAuthController { return ResponseEntity.ok(detail); } + @PostMapping("/logout") + @Operation(summary = "Log out the current user (revoke all outstanding tokens)") + @ApiResponse(responseCode = "204", description = "Logged out (or no-op if not authenticated)") + public ResponseEntity logout(Authentication authentication, HttpServletRequest httpRequest) { + if (authentication == null || authentication.getName() == null + || !authentication.getName().startsWith("user:")) { + return ResponseEntity.noContent().build(); + } + String userId = stripSubjectPrefix(authentication.getName()); + userRepository.revokeTokensBefore(userId, Instant.now()); + auditService.log(userId, "logout", AuditCategory.AUTH, null, null, + AuditResult.SUCCESS, httpRequest); + log.info("UI user logged out: {}", userId); + return ResponseEntity.noContent().build(); + } + /** * Map a JWT subject ({@code "user:"} or {@code "user:oidc:"}) to the DB key: * just the bare username. FKs on {@code alert_rules.created_by}, diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java new file mode 100644 index 00000000..baa81cd0 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java @@ -0,0 +1,68 @@ +package com.cameleer.server.app.security; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.security.JwtService; +import com.cameleer.server.core.security.UserInfo; +import com.cameleer.server.core.security.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogoutControllerIT extends AbstractPostgresIT { + + @Autowired TestRestTemplate restTemplate; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired JdbcTemplate jdbc; + + @AfterEach + void cleanup() { + userRepository.delete("logout-test"); + } + + @Test + void logoutRevokesTokensAuditsAndRejectsSubsequentCalls() { + userRepository.upsert(new UserInfo("logout-test", "local", "", "Logout Test", Instant.now())); + String accessToken = jwtService.createAccessToken("user:logout-test", "user", List.of("VIEWER")); + + HttpHeaders authed = new HttpHeaders(); + authed.setBearerAuth(accessToken); + + ResponseEntity logoutResp = restTemplate.exchange( + "/api/v1/auth/logout", HttpMethod.POST, new HttpEntity<>(authed), Void.class); + assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + Instant revokedAt = jdbc.queryForObject( + "SELECT token_revoked_before FROM users WHERE user_id = ?", + (rs, n) -> rs.getTimestamp(1).toInstant(), "logout-test"); + assertThat(revokedAt).isAfter(Instant.now().minusSeconds(10)); + + Long auditCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE category = 'AUTH' AND action = 'logout' AND username = ?", + Long.class, "logout-test"); + assertThat(auditCount).isEqualTo(1L); + + ResponseEntity meResp = restTemplate.exchange( + "/api/v1/auth/me", HttpMethod.GET, new HttpEntity<>(authed), String.class); + assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void logoutWithoutTokenReturns204NoOp() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/auth/logout", HttpMethod.POST, HttpEntity.EMPTY, Void.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } +}