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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 09:21:47 +02:00
parent b4c6e45d35
commit 9031533077
2 changed files with 84 additions and 0 deletions

View File

@@ -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<Void> 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:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
* just the bare username. FKs on {@code alert_rules.created_by},

View File

@@ -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<Void> 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<String> meResp = restTemplate.exchange(
"/api/v1/auth/me", HttpMethod.GET, new HttpEntity<>(authed), String.class);
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void logoutWithoutTokenReturns204NoOp() {
ResponseEntity<Void> resp = restTemplate.exchange(
"/api/v1/auth/logout", HttpMethod.POST, HttpEntity.EMPTY, Void.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}