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:
@@ -183,6 +183,22 @@ public class UiAuthController {
|
|||||||
return ResponseEntity.ok(detail);
|
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:
|
* 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},
|
* just the bare username. FKs on {@code alert_rules.created_by},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user