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(); + } +}