fix(auth): strip user: prefix before token-revocation lookup
JwtAuthenticationFilter compared the JWT subject (user:alice) against users.user_id (bare alice), so token_revoked_before was never read for any user. Strips the prefix to match the convention documented in CLAUDE.md. Adds JwtRevocationIT as a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,9 +84,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
JwtValidationResult result = jwtService.validateAccessToken(token);
|
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
String subject = result.subject();
|
String subject = result.subject();
|
||||||
|
|
||||||
// Token revocation check: reject tokens issued before revocation timestamp
|
// Token revocation check: reject tokens issued before revocation timestamp.
|
||||||
|
// JWT subject carries the "user:" prefix; users.user_id is the bare form
|
||||||
|
// (see CLAUDE.md "User ID conventions"). Strip before lookup.
|
||||||
if (subject.startsWith("user:") && result.issuedAt() != null) {
|
if (subject.startsWith("user:") && result.issuedAt() != null) {
|
||||||
userRepository.findById(subject).ifPresent(user -> {
|
String userId = subject.substring(5);
|
||||||
|
userRepository.findById(userId).ifPresent(user -> {
|
||||||
Instant revoked = user.tokenRevokedBefore();
|
Instant revoked = user.tokenRevokedBefore();
|
||||||
if (revoked != null && result.issuedAt().isBefore(revoked)) {
|
if (revoked != null && result.issuedAt().isBefore(revoked)) {
|
||||||
serverMetrics.recordAuthFailure("revoked");
|
serverMetrics.recordAuthFailure("revoked");
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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.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 java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test verifying that {@code users.token_revoked_before} is honoured
|
||||||
|
* by {@link JwtAuthenticationFilter}. Regression for the prefix-mismatch bug
|
||||||
|
* where the filter looked up the JWT subject ({@code user:alice}) against
|
||||||
|
* {@code users.user_id} (bare {@code alice}), so revocation never fired.
|
||||||
|
*/
|
||||||
|
class JwtRevocationIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokedTokenIsRejectedOnAuthenticatedRequest() {
|
||||||
|
userRepository.upsert(new UserInfo(
|
||||||
|
"revoke-me", "local", "", "Revoke Me", Instant.now()));
|
||||||
|
String accessToken = jwtService.createAccessToken(
|
||||||
|
"user:revoke-me", "user", List.of("VIEWER"));
|
||||||
|
|
||||||
|
ResponseEntity<String> before = call(accessToken);
|
||||||
|
assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
|
userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1));
|
||||||
|
|
||||||
|
ResponseEntity<String> after = call(accessToken);
|
||||||
|
assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> call(String accessToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setBearerAuth(accessToken);
|
||||||
|
return restTemplate.exchange(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
HttpMethod.GET,
|
||||||
|
new HttpEntity<>(headers),
|
||||||
|
String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user