From 611ad5a97543192162100d6b7971604d8190751f Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 25 Aug 2026 21:38:16 -0400 Subject: [PATCH] Make access tokens revocable with a per-account token generation Password change revoked every refresh token but left the already-issued JWT working until its 15-minute expiry, because a JWT is self-contained and there was no jti denylist or version check. A stolen access token stayed usable for up to 15 minutes after the victim changed their password specifically to stop it. Same gap on logout-all-devices. Every token now carries a "tv" claim holding the account's token generation at mint time. AuthTokenFilter compares it against users.token_version on each request and leaves the context unauthenticated when they differ, so the entry point answers 401. The filter already loads the user on every request, so this adds no query. Authorities are still resolved from the database rather than read off the token, which is the existing behaviour and is preserved. StompJwtChannelInterceptor makes the same check at CONNECT: a WebSocket is authenticated once and lives long, so honouring an invalidated token there would outlive the JWT's own expiry rather than merely precede it. AuthenticatedUser.tokenVersionOf returns -1 for any principal that cannot report a generation, so an unexpected UserDetails fails closed rather than silently matching version 0. V9 rather than V8: V8 is taken by the refresh-token hashing migration on its own branch. No ordering dependency; Flyway tolerates the gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm --- .../stomp/StompJwtChannelInterceptor.java | 10 ++ .../lensbridge/controller/AuthController.java | 20 ++- .../ibrasoft/lensbridge/model/auth/User.java | 39 +++++- .../security/jwt/AuthTokenFilter.java | 28 +++-- .../lensbridge/security/jwt/JwtUtils.java | 47 ++++++- .../security/services/AuthenticatedUser.java | 43 +++++++ .../services/UserDetailsServiceImpl.java | 16 +-- .../postgresql/V9__add_user_token_version.sql | 22 ++++ .../sqlite/V9__add_user_token_version.sql | 22 ++++ .../stomp/StompJwtChannelInterceptorTest.java | 15 ++- .../jwt/AuthTokenFilterRevocationTest.java | 116 ++++++++++++++++++ 11 files changed, 349 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/services/AuthenticatedUser.java create mode 100644 src/main/resources/db/migration/postgresql/V9__add_user_token_version.sql create mode 100644 src/main/resources/db/migration/sqlite/V9__add_user_token_version.sql create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilterRevocationTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java index d5ee5ae..d58b386 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java @@ -2,6 +2,7 @@ import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.AuthenticatedUser; import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -94,6 +95,15 @@ private void authenticate(StompHeaderAccessor accessor) { String username = jwtUtils.getUserNameFromJwtToken(token); UserDetails user = userDetailsService.loadUserByUsername(username); + + // Same revocation check the HTTP filter makes. A WebSocket is a long-lived + // connection authenticated once at CONNECT, so honouring a token the account has + // already invalidated would outlive the JWT's own expiry, not just precede it. + if (jwtUtils.getTokenVersionFromJwtToken(token) != AuthenticatedUser.tokenVersionOf(user)) { + log.warn("STOMP CONNECT rejected: stale token version for {}", username); + throw new IllegalArgumentException("Revoked JWT on STOMP CONNECT"); + } + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); accessor.setUser(auth); diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index 92bbf78..bb78680 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -91,7 +91,7 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginReq loginAttemptService.recordSuccessfulAttempt(clientKey); - String jwt = jwtUtils.generateJwtToken(authentication); + String jwt = jwtUtils.generateJwtToken(authentication, user.getTokenVersion()); RefreshToken refreshToken = refreshTokenService.createRefreshToken( user.getId(), request.getHeader("User-Agent"), getClientIpAddress(request)); @@ -202,7 +202,8 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPa @Operation(operationId = "changePassword", summary = "Change the signed-in user's password", - description = "Revokes every refresh token for the user, so all sessions must sign in again.") + description = "Revokes every refresh token for the user and invalidates every access token " + + "already issued, so all sessions must sign in again immediately.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "Password changed; all sessions revoked"), @ApiResponse(responseCode = "400", description = "Current password incorrect or new password rejected", @@ -214,6 +215,9 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPa @PreAuthorize("hasRole('" + Role.Authority.USER + "')") public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, @CurrentUser User user) { + // User.setPassword bumps tokenVersion, and changePassword persists the entity, so the + // access tokens minted against the old password stop being accepted on the next request + // rather than at their 15-minute expiry. Refresh tokens live in their own table. userService.changePassword(user, changePasswordRequest); refreshTokenService.revokeAllUserTokens(user.getId()); return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); @@ -244,7 +248,7 @@ public ResponseEntity refreshToken(@Valid @RequestBody Tok List authorities = new ArrayList<>(user.getRoles()); Authentication auth = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities); - String newAccessToken = jwtUtils.generateJwtToken(auth); + String newAccessToken = jwtUtils.generateJwtToken(auth, user.getTokenVersion()); refreshTokenService.revokeRefreshToken(requestRefreshToken); RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(userId, "Token refresh", "System"); @@ -264,7 +268,10 @@ public ResponseEntity logout(@RequestBody(required = false) Tok return ResponseEntity.ok(new MessageResponse("Logged out successfully")); } - @Operation(operationId = "logoutAllDevices", summary = "Revoke every refresh token for the signed-in user") + @Operation(operationId = "logoutAllDevices", + summary = "End every session for the signed-in user", + description = "Revokes every refresh token and invalidates every access token already " + + "issued, including the one used to make this call.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "All sessions ended"), @ApiResponse(responseCode = "403", description = "Not signed in", @@ -273,6 +280,11 @@ public ResponseEntity logout(@RequestBody(required = false) Tok @PostMapping("/logout-all-devices") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") public ResponseEntity logoutAllDevices(@CurrentUser User user) { + // "All devices" has to mean the access tokens too. Revoking only refresh tokens leaves a + // stolen JWT working for up to its full lifetime, which is the window the user pressed + // this button to close. + user.incrementTokenVersion(); + userService.saveUser(user); refreshTokenService.revokeAllUserTokens(user.getId()); return ResponseEntity.ok(new MessageResponse("Logged out from all devices successfully")); } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 27900bd..d7d3782 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -67,7 +67,23 @@ public class User { @Column() @Enumerated(EnumType.STRING) private Audience audience; - + + /** + * Generation counter for this account's access tokens. + *

+ * Every JWT carries the value that was current when it was minted; {@code AuthTokenFilter} + * compares the claim against this column on each request and rejects anything stale. Bumping + * it is therefore the only way to kill an access token before its 15-minute expiry — refresh + * tokens live in their own table and are revoked separately, but a JWT is self-contained and + * would otherwise stay usable after the victim changed their password precisely to stop it. + *

+ * Not a security boundary on its own: it is a revocation counter, not a secret, and it only + * ever moves forward. Wrapping is not a concern at {@code long} width. + */ + @JsonIgnore + @Column(nullable = false) + private long tokenVersion = 0L; + public User(String firstName, String lastName, String email, String passwordHash) { this.firstName = firstName; this.lastName = lastName; @@ -83,8 +99,29 @@ public String getPassword() { return passwordHash; } + /** + * Sets the stored hash and invalidates every access token minted against the old one. + *

+ * The bump lives here rather than in the callers because this is the single chokepoint every + * credential change goes through — self-service change-password, the emailed reset link, and + * the admin-initiated reset all land on this setter. A caller that forgot to bump would leave + * a 15-minute window in which the old password's tokens still work, which is the whole bug + * this counter exists to close. + *

+ * JPA hydration writes the field directly, not through this setter, so loading a row does not + * move the counter. + */ public void setPassword(String passwordHash) { this.passwordHash = passwordHash; + incrementTokenVersion(); + } + + /** + * Invalidates every access token issued for this account so far. The caller must persist the + * entity for it to take effect. + */ + public void incrementTokenVersion() { + this.tokenVersion++; } public void addRole(Role role) { diff --git a/src/main/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilter.java b/src/main/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilter.java index 6f2f090..3ac8b37 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilter.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilter.java @@ -18,6 +18,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import com.ibrasoft.lensbridge.security.services.AuthenticatedUser; import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; public class AuthTokenFilter extends OncePerRequestFilter { @@ -37,15 +38,28 @@ protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull Ht if (jwt != null && jwtUtils.validateJwtToken(jwt)) { String username = jwtUtils.getUserNameFromJwtToken(jwt); + // Authorities are resolved from the database, not read off the token, so a role or + // permission taken away takes effect on the next request rather than at expiry. UserDetails userDetails = userDetailsService.loadUserByUsername(username); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); + long presented = jwtUtils.getTokenVersionFromJwtToken(jwt); + long current = AuthenticatedUser.tokenVersionOf(userDetails); + if (presented != current) { + // The account's token generation moved on: password changed, or every session was + // signed out. The signature is still good and the clock has not run out, but the + // token is revoked. Leaving the context unauthenticated makes the entry point + // answer 401. + logger.debug("Rejecting access token for {}: stale token version", username); + } else { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } } } catch (Exception e) { logger.error("Cannot set user authentication: {}", e); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java index 0897abc..b197cab 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java @@ -17,21 +17,40 @@ public class JwtUtils { private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); + /** + * Generation counter for the account the token was minted for. Compared against + * {@code users.token_version} on every request; see {@link com.ibrasoft.lensbridge.model.auth.User}. + */ + public static final String TOKEN_VERSION_CLAIM = "tv"; + @Value("${lensbridge.app.jwtSecret}") private String jwtSecret; @Value("${lensbridge.app.jwtExpirationMs}") private long jwtExpirationMs; - public String generateJwtToken(Authentication authentication) { + /** + * Mints an access token for {@code authentication}, stamped with the account's current + * {@code tokenVersion}. + *

+ * The version is a required argument rather than something looked up here so that a caller + * cannot mint an unrevokable token by forgetting to supply it: a missing claim is treated as + * stale by {@code AuthTokenFilter}, but only if the caller is forced to think about it at all. + *

+ * Deliberately no authorities in the payload. Roles and permissions are re-resolved from the + * database on each request, so a demotion takes effect immediately instead of at expiry. + */ + public String generateJwtToken(Authentication authentication, long tokenVersion) { + Date now = new Date(); return Jwts.builder() - .setSubject(authentication.getName()) - .setIssuedAt(new Date()) - .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) + .setSubject(authentication.getName()) + .claim(TOKEN_VERSION_CLAIM, tokenVersion) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + jwtExpirationMs)) .signWith(key(), SignatureAlgorithm.HS256) .compact(); } - + private Key key() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret)); } @@ -41,6 +60,24 @@ public String getUserNameFromJwtToken(String token) { .parseClaimsJws(token).getBody().getSubject(); } + /** + * Reads the token-version claim. + *

+ * Returns {@code -1} when the claim is absent or not a number — the case for tokens minted + * before this claim existed, and for anything malformed. No account's counter can ever hold + * {@code -1}, so those tokens compare unequal and are rejected. That logs out every session + * once, at deploy, which is the correct trade for not honouring tokens whose revocation state + * cannot be established. + */ + public long getTokenVersionFromJwtToken(String token) { + Object claim = Jwts.parserBuilder().setSigningKey(key()).build() + .parseClaimsJws(token).getBody().get(TOKEN_VERSION_CLAIM); + if (claim instanceof Number number) { + return number.longValue(); + } + return -1L; + } + public boolean validateJwtToken(String authToken) { try { Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(authToken); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/AuthenticatedUser.java b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthenticatedUser.java new file mode 100644 index 0000000..63be8d6 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthenticatedUser.java @@ -0,0 +1,43 @@ +package com.ibrasoft.lensbridge.security.services; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * The Spring Security principal, extended with the account's current token generation. + *

+ * {@code AuthTokenFilter} has to compare a JWT's {@code tv} claim against the value stored on + * the account, and it already loads the account on every request to re-resolve authorities. + * Carrying the counter on the principal means that comparison costs no second query. Anything + * that only cares about the username, password or authorities keeps working — this is still a + * plain {@link org.springframework.security.core.userdetails.User}. + */ +public class AuthenticatedUser extends org.springframework.security.core.userdetails.User { + + private final long tokenVersion; + + public AuthenticatedUser(String username, + String password, + boolean enabled, + Collection authorities, + long tokenVersion) { + super(username, password, enabled, true, true, true, authorities); + this.tokenVersion = tokenVersion; + } + + public long getTokenVersion() { + return tokenVersion; + } + + /** + * The generation recorded for {@code details}, or {@code -1} when it is not one of ours. + *

+ * No account's counter can hold {@code -1}, so an unknown principal type fails the + * comparison rather than silently passing it. + */ + public static long tokenVersionOf(UserDetails details) { + return details instanceof AuthenticatedUser user ? user.getTokenVersion() : -1L; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index bcd4131..467fea6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -22,13 +22,15 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); - // No collision with lensbridge.model.auth.User - return org.springframework.security.core.userdetails.User - .withUsername(user.getEmail()) - .password(user.getPassword()) - .authorities(authorityResolver.resolveAuthorities(user)) - .disabled(!user.isVerified()) - .build(); + // AuthenticatedUser rather than the plain Spring principal so the account's token + // generation rides along; AuthTokenFilter needs it on every request and this lookup is + // the one it already pays for. + return new AuthenticatedUser( + user.getEmail(), + user.getPassword(), + user.isVerified(), + authorityResolver.resolveAuthorities(user), + user.getTokenVersion()); } } diff --git a/src/main/resources/db/migration/postgresql/V9__add_user_token_version.sql b/src/main/resources/db/migration/postgresql/V9__add_user_token_version.sql new file mode 100644 index 0000000..a0fb2d2 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V9__add_user_token_version.sql @@ -0,0 +1,22 @@ +-- +-- V9: add users.token_version, the generation counter that makes access tokens revocable. +-- +-- A JWT is self-contained: changing a password revoked every refresh token but left the +-- already-issued 15-minute access token working, so a stolen token stayed usable for up to +-- 15 minutes after the victim changed their password specifically to stop it. Every token now +-- carries the version current when it was minted, AuthTokenFilter compares the claim against +-- this column on each request, and bumping the column kills every outstanding token for that +-- account at once. The filter already loads the user on every request, so this costs no extra +-- query. +-- +-- Backfilled to 0 to match the entity default. Existing tokens carry no tokenVersion claim; +-- AuthTokenFilter treats a missing claim as stale, so live sessions re-authenticate once at +-- deploy -- the same one-off cost as the refresh-token hashing change. +-- +-- Version 9 rather than 8: V8 is taken by the refresh-token hashing migration, which ships +-- on its own branch. There is no ordering dependency between the two, and Flyway tolerates +-- the gap if this lands first. +-- +-- The SQLite twin of this file must stay at the same version. + +alter table users add column token_version bigint not null default 0; diff --git a/src/main/resources/db/migration/sqlite/V9__add_user_token_version.sql b/src/main/resources/db/migration/sqlite/V9__add_user_token_version.sql new file mode 100644 index 0000000..cf85a89 --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V9__add_user_token_version.sql @@ -0,0 +1,22 @@ +-- +-- V9: add users.token_version, the generation counter that makes access tokens revocable. +-- +-- A JWT is self-contained: changing a password revoked every refresh token but left the +-- already-issued 15-minute access token working, so a stolen token stayed usable for up to +-- 15 minutes after the victim changed their password specifically to stop it. Every token now +-- carries the version current when it was minted, AuthTokenFilter compares the claim against +-- this column on each request, and bumping the column kills every outstanding token for that +-- account at once. The filter already loads the user on every request, so this costs no extra +-- query. +-- +-- Backfilled to 0 to match the entity default. Existing tokens carry no tokenVersion claim; +-- AuthTokenFilter treats a missing claim as stale, so live sessions re-authenticate once at +-- deploy -- the same one-off cost as the refresh-token hashing change. +-- +-- Version 9 rather than 8: V8 is taken by the refresh-token hashing migration, which ships +-- on its own branch. There is no ordering dependency between the two, and Flyway tolerates +-- the gap if this lands first. +-- +-- The postgres twin of this file must stay at the same version. + +alter table users add column token_version bigint not null default 0; diff --git a/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java b/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java index ebef770..7ebeb74 100644 --- a/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.AuthenticatedUser; import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; import org.junit.jupiter.api.Test; import org.springframework.messaging.Message; @@ -167,11 +168,15 @@ void connectWithInvalidTokenIsRejected() { @Test void connectWithValidTokenAttachesThePrincipal() { - UserDetails user = User.withUsername("u@example.com") - .password("x") - .authorities(new SimpleGrantedAuthority( - Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority())) - .build(); + // AuthenticatedUser, not the plain Spring principal: the interceptor now compares the + // token's generation against the account's, and AuthenticatedUser.tokenVersionOf fails + // closed at -1 for any principal that cannot report one. + UserDetails user = new AuthenticatedUser( + "u@example.com", + "x", + true, + List.of(new SimpleGrantedAuthority(Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority())), + 0L); when(jwtUtils.validateJwtToken("good")).thenReturn(true); when(jwtUtils.getUserNameFromJwtToken("good")).thenReturn("u@example.com"); when(userDetailsService.loadUserByUsername("u@example.com")).thenReturn(user); diff --git a/src/test/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilterRevocationTest.java b/src/test/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilterRevocationTest.java new file mode 100644 index 0000000..cf76bc3 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/jwt/AuthTokenFilterRevocationTest.java @@ -0,0 +1,116 @@ +package com.ibrasoft.lensbridge.security.jwt; + +import com.ibrasoft.lensbridge.security.services.AuthenticatedUser; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Access-token revocation in {@link AuthTokenFilter}. + * + *

A JWT is self-contained, so before {@code tokenVersion} existed a password change revoked + * every refresh token but left the already-issued 15-minute access token working — a stolen + * token stayed usable for up to 15 minutes after the victim changed their password precisely to + * stop it. These tests pin the rule that closes that window: a token whose {@code tv} claim does + * not match the account's current generation leaves the security context unauthenticated, so the + * entry point answers 401. + */ +@ExtendWith(MockitoExtension.class) +class AuthTokenFilterRevocationTest { + + private static final String EMAIL = "u@example.com"; + private static final String TOKEN = "a.b.c"; + + @Mock + private JwtUtils jwtUtils; + @Mock + private UserDetailsServiceImpl userDetailsService; + @InjectMocks + private AuthTokenFilter filter; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private MockHttpServletRequest bearerRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + TOKEN); + return request; + } + + private UserDetails accountAtVersion(long tokenVersion) { + return new AuthenticatedUser(EMAIL, "hash", true, List.of(), tokenVersion); + } + + private void tokenPresentsVersion(long presented) { + ReflectionTestUtils.setField(filter, "jwtUtils", jwtUtils); + ReflectionTestUtils.setField(filter, "userDetailsService", userDetailsService); + when(jwtUtils.validateJwtToken(TOKEN)).thenReturn(true); + when(jwtUtils.getUserNameFromJwtToken(TOKEN)).thenReturn(EMAIL); + when(jwtUtils.getTokenVersionFromJwtToken(TOKEN)).thenReturn(presented); + } + + @Test + void tokenMintedAtTheCurrentGenerationAuthenticates() throws Exception { + tokenPresentsVersion(3L); + when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(accountAtVersion(3L)); + FilterChain chain = mock(FilterChain.class); + MockHttpServletRequest request = bearerRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo(EMAIL); + verify(chain).doFilter(request, response); + } + + @Test + void tokenFromBeforeAPasswordChangeIsRejectedDespiteAValidSignature() throws Exception { + // Signature still verifies and the clock has not run out; the account's generation moved on. + tokenPresentsVersion(3L); + when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(accountAtVersion(4L)); + FilterChain chain = mock(FilterChain.class); + MockHttpServletRequest request = bearerRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + // The chain still runs; an unauthenticated context is what makes the entry point answer 401. + verify(chain).doFilter(request, response); + } + + @Test + void principalThatCannotReportAGenerationIsRejected() throws Exception { + // AuthenticatedUser.tokenVersionOf yields -1 for any other UserDetails, so a principal + // from some future code path that forgets to carry the version fails closed. + tokenPresentsVersion(0L); + when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn( + org.springframework.security.core.userdetails.User + .withUsername(EMAIL).password("hash").authorities(List.of()).build()); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(bearerRequest(), new MockHttpServletResponse(), chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } +}