Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public ResponseEntity<JwtResponse> 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));

Expand Down Expand Up @@ -202,7 +202,8 @@ public ResponseEntity<MessageResponse> 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",
Expand All @@ -214,6 +215,9 @@ public ResponseEntity<MessageResponse> resetPassword(@Valid @RequestBody ResetPa
@PreAuthorize("hasRole('" + Role.Authority.USER + "')")
public ResponseEntity<MessageResponse> 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."));
Expand Down Expand Up @@ -244,7 +248,7 @@ public ResponseEntity<TokenRefreshResponse> refreshToken(@Valid @RequestBody Tok

List<GrantedAuthority> 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");
Expand All @@ -264,7 +268,10 @@ public ResponseEntity<MessageResponse> 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",
Expand All @@ -273,6 +280,11 @@ public ResponseEntity<MessageResponse> logout(@RequestBody(required = false) Tok
@PostMapping("/logout-all-devices")
@PreAuthorize("hasRole('" + Role.Authority.USER + "')")
public ResponseEntity<MessageResponse> 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"));
}
Expand Down
39 changes: 38 additions & 1 deletion src/main/java/com/ibrasoft/lensbridge/model/auth/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,23 @@ public class User {
@Column()
@Enumerated(EnumType.STRING)
private Audience audience;


/**
* Generation counter for this account's access tokens.
* <p>
* 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.
* <p>
* 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;
Expand All @@ -83,8 +99,29 @@ public String getPassword() {
return passwordHash;
}

/**
* Sets the stored hash and invalidates every access token minted against the old one.
* <p>
* 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.
* <p>
* 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
47 changes: 42 additions & 5 deletions src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
* <p>
* 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.
* <p>
* 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));
}
Expand All @@ -41,6 +60,24 @@ public String getUserNameFromJwtToken(String token) {
.parseClaimsJws(token).getBody().getSubject();
}

/**
* Reads the token-version claim.
* <p>
* 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* {@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<? extends GrantedAuthority> 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.
* <p>
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading