Skip to content
Merged
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 @@ -92,7 +92,7 @@ public ResponseEntity<JwtResponse> authenticateUser(@Valid @RequestBody LoginReq
loginAttemptService.recordSuccessfulAttempt(clientKey);

String jwt = jwtUtils.generateJwtToken(authentication);
RefreshToken refreshToken = refreshTokenService.createRefreshToken(
RefreshTokenService.IssuedRefreshToken issuedRefreshToken = refreshTokenService.createRefreshToken(
user.getId(), request.getHeader("User-Agent"), getClientIpAddress(request));

// TODO: This is probably slow and should be fitted in the data model somewhere
Expand All @@ -109,7 +109,7 @@ public ResponseEntity<JwtResponse> authenticateUser(@Valid @RequestBody LoginReq
.collect(Collectors.toList());

return ResponseEntity.ok(new JwtResponse(jwt,
refreshToken.getTokenHash(),
issuedRefreshToken.plaintext(),
user.getFirstName(),
user.getLastName(),
user.getId(),
Expand Down Expand Up @@ -247,9 +247,10 @@ public ResponseEntity<TokenRefreshResponse> refreshToken(@Valid @RequestBody Tok
String newAccessToken = jwtUtils.generateJwtToken(auth);

refreshTokenService.revokeRefreshToken(requestRefreshToken);
RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(userId, "Token refresh", "System");
RefreshTokenService.IssuedRefreshToken newRefreshToken =
refreshTokenService.createRefreshToken(userId, "Token refresh", "System");

return ResponseEntity.ok(new TokenRefreshResponse(newAccessToken, newRefreshToken.getToken()));
return ResponseEntity.ok(new TokenRefreshResponse(newAccessToken, newRefreshToken.plaintext()));
}

@Operation(operationId = "logout",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public class RefreshToken {
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

/**
* SHA-256 (hex) of the token handed to the client — never the token itself. See
* {@code RefreshTokenService#resolveHash}.
*/
@NotBlank
@Column(nullable = false, unique = true)
private String tokenHash;
Expand All @@ -49,10 +53,6 @@ public class RefreshToken {
@Column(nullable = false)
private boolean revoked;

public String getToken() {
return tokenHash;
}

public UUID getUserId() {
return user != null ? user.getId() : null;
}
Expand Down
115 changes: 84 additions & 31 deletions src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,77 +13,112 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
* Issues and redeems refresh tokens.
* <p>
* The plaintext token exists only in the response to the client that asked for it. What is
* persisted is a SHA-256 digest of that value, so a read of {@code refresh_tokens} — a leaked
* backup, a misconfigured replica, SQL injection elsewhere — yields nothing that can be replayed
* against {@code /api/auth/refresh-token}. This mirrors {@link VerificationTokenService} and
* {@code service.agent.EnrollmentTokenService}.
* <p>
* A plain digest (not bcrypt/argon2) is the right choice here: the input is 512 bits of
* {@link SecureRandom} output, so there is no guessable password to slow an attacker down to, and
* redemption has to stay a single indexed equality lookup.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {

/** 64 random bytes -> 86 chars of url-safe base64. 512 bits of entropy. */
private static final int TOKEN_RANDOM_BYTES = 64;

private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;

@Value("${lensbridge.app.refreshTokenExpirationMs:604800000}") // 7 days default
private long refreshTokenDurationMs;

@Value("${lensbridge.app.maxRefreshTokensPerUser:5}")
private int maxRefreshTokensPerUser;


/** Optional extra input to the token hash. Empty disables it. */
@Value("${lensbridge.app.refreshTokenPepper:}")
private String pepper;

private final SecureRandom secureRandom = new SecureRandom();

/**
Comment thread
IbraTech04 marked this conversation as resolved.
* A freshly minted token: the stored row, plus the plaintext to hand back to the client.
* The plaintext is not recoverable from the row and is never persisted, so this is the only
* point at which it is available.
*/
public record IssuedRefreshToken(RefreshToken token, String plaintext) {
}

public void invalidateAllRefreshTokensForUser(UUID userId) {
List<RefreshToken> tokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId);
tokens.forEach(token -> token.setRevoked(true));
refreshTokenRepository.saveAll(tokens);
}

/**
* Create a new refresh token for a user
* Create a new refresh token for a user.
*
* @return the persisted row and the plaintext token; only the caller of this method ever sees
* the plaintext.
*/
@Transactional
public RefreshToken createRefreshToken(UUID userId, String deviceInfo, String ipAddress) {
public IssuedRefreshToken createRefreshToken(UUID userId, String deviceInfo, String ipAddress) {
// Clean up expired tokens first
deleteExpiredTokensByUser(userId);

// Check if user has too many active tokens
long activeTokenCount = refreshTokenRepository.countByUser_IdAndRevokedFalse(userId);
if (activeTokenCount >= maxRefreshTokensPerUser) {
// Revoke oldest token
List<RefreshToken> userTokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId);
if (!userTokens.isEmpty()) {
RefreshToken oldestToken = userTokens.stream()
userTokens.stream()
.min((t1, t2) -> t1.getCreatedDate().compareTo(t2.getCreatedDate()))
.orElse(null);
if (oldestToken != null) {
revokeRefreshToken(oldestToken.getTokenHash());
}
}
.ifPresent(this::revokeToken);
}
Instant now = Instant.now();


String plaintext = generateRefreshToken();

RefreshToken refreshToken = RefreshToken.builder()
.tokenHash(generateRefreshToken())
.tokenHash(resolveHash(plaintext))
.user(userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found")))
.expiryDate(now.plusSeconds(refreshTokenDurationMs / 1000))
.createdDate(now)
.lastUsedDate(now)
.revoked(false)
.build();
return refreshTokenRepository.save(refreshToken);

return new IssuedRefreshToken(refreshTokenRepository.save(refreshToken), plaintext);
}

/**
* Find refresh token by token string
* Find a refresh token by the plaintext value the client presented.
*/
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByTokenHash(token);
public Optional<RefreshToken> findByToken(String plaintextToken) {
if (plaintextToken == null || plaintextToken.isBlank()) {
return Optional.empty();
}
return refreshTokenRepository.findByTokenHash(resolveHash(plaintextToken));
}

/**
Expand All @@ -104,14 +139,11 @@ public RefreshToken verifyExpiration(RefreshToken token) {
}

/**
* Revoke a refresh token
* Revoke a refresh token, given the plaintext value the client holds.
*/
@Transactional
public void revokeRefreshToken(String token) {
refreshTokenRepository.findByTokenHash(token).ifPresent(refreshToken -> {
refreshToken.setRevoked(true);
refreshTokenRepository.save(refreshToken);
});
public void revokeRefreshToken(String plaintextToken) {
findByToken(plaintextToken).ifPresent(this::revokeToken);
}

/**
Expand All @@ -133,7 +165,7 @@ public void deleteExpiredTokensByUser(UUID userId) {
List<RefreshToken> expiredTokens = userTokens.stream()
.filter(token -> token.isExpired() || token.isRevoked())
.toList();

if (!expiredTokens.isEmpty()) {
refreshTokenRepository.deleteAll(expiredTokens);
}
Expand All @@ -146,15 +178,36 @@ public List<RefreshToken> getActiveTokensForUser(UUID userId) {
return refreshTokenRepository.findByUser_IdAndRevokedFalse(userId);
}

private void revokeToken(RefreshToken token) {
token.setRevoked(true);
refreshTokenRepository.save(token);
}

/**
* Generate a secure random refresh token
*/
private String generateRefreshToken() {
byte[] randomBytes = new byte[64];
byte[] randomBytes = new byte[TOKEN_RANDOM_BYTES];
secureRandom.nextBytes(randomBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}

/**
* SHA-256 of the plaintext, hex-encoded, optionally peppered. 64 chars, so it fits the
* existing {@code varchar(255)} column.
*/
String resolveHash(String plaintext) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
if (pepper != null && !pepper.isEmpty()) {
digest.update(pepper.getBytes(StandardCharsets.UTF_8));
}
return HexFormat.of().formatHex(digest.digest(plaintext.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}

/**
* Scheduled task to clean up expired tokens (runs daily at 2 AM)
*/
Expand All @@ -164,16 +217,16 @@ public void cleanupExpiredTokens() {
try {
Instant now = Instant.now();
refreshTokenRepository.deleteByExpiryDateBefore(now);

// Clean up revoked tokens (older than 7 days)
List<RefreshToken> oldRevokedTokens = refreshTokenRepository
.findByRevokedTrueAndCreatedDateBefore(now.minus(Duration.ofDays(7)));

if (!oldRevokedTokens.isEmpty()) {
refreshTokenRepository.deleteAll(oldRevokedTokens);
log.info("Cleaned up {} old revoked refresh tokens", oldRevokedTokens.size());
}

log.info("Cleaned up expired refresh tokens");
} catch (Exception e) {
log.error("Failed to cleanup expired refresh tokens", e);
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.properties.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ lensbridge.app.jwtSecret=replace-with-base64-32-bytes
lensbridge.app.jwtExpirationMs=900000
lensbridge.app.refreshTokenExpirationMs=604800000
lensbridge.app.maxRefreshTokensPerUser=5
# Optional extra input to the refresh-token hash. Leave empty to disable.
# Changing or setting this invalidates every existing refresh token (everyone signs in again).
lensbridge.app.refreshTokenPepper=

# Auth token TTLs
auth.token.verification-expiration-ms=86400000
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
--

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is all this yap - bruh

-- V8: invalidate every pre-existing refresh token, because refresh_tokens.token_hash
-- used to hold the raw token rather than a hash of it.
--
-- See the SQLite twin for the full reasoning. In short: the column named token_hash stored
-- the base64 SecureRandom value verbatim and redemption was an equality lookup against it,
-- so any read of this table yielded directly replayable 7-day sessions. The service now
-- stores sha256(pepper || plaintext) hex-encoded and looks up by that digest, matching
-- VerificationTokenService and EnrollmentTokenService.
--
-- Deleting rather than rehashing in place is deliberate. Postgres could do the rehash with
-- pgcrypto's digest(), but that would require the extension on every deployment target and,
-- more to the point, those values have sat at rest in plaintext for the life of the table:
-- rehashing keeps any already-leaked copy valid for another 7 days. Deleting is the clean
-- cut. Nothing here can be salvaged anyway -- an old token cannot match a hash column.
--
-- Consequence: everyone with a live session is logged out once, at deploy. Access tokens
-- (15 min) keep working until they expire; the next refresh attempt returns 401 and the
-- client re-authenticates. No user data is touched.
--
-- No schema change is needed: token_hash is already varchar(255) not null unique, and a
-- SHA-256 hex digest is 64 chars.
--
-- The SQLite twin of this file must stay at the same version.

delete from refresh_tokens;
33 changes: 33 additions & 0 deletions src/main/resources/db/migration/sqlite/V8__hash_refresh_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
--
-- V8: invalidate every pre-existing refresh token, because refresh_tokens.token_hash
-- used to hold the raw token rather than a hash of it.
--
-- RefreshTokenService generated 64 bytes of SecureRandom, base64'd it, handed it to the
-- client, and stored that exact string in a column named token_hash. Redemption was a
-- direct equality lookup against the value the client holds, so anything that could read
-- the table -- a backup, a replica, SQL injection elsewhere, ORM debug logging -- came away
-- with usable 7-day sessions needing no further work. The service now stores
-- sha256(pepper || plaintext) hex-encoded, matching VerificationTokenService and
-- EnrollmentTokenService, and looks up by that digest.
--
-- The rows already in the table are plaintext and cannot be distinguished from hashes by
-- the lookup path -- a client presenting its old token would hash it, miss, and be rejected
-- anyway. Rather than leave dead plaintext secrets sitting in the table, they are deleted.
--
-- We could instead have rehashed in place (update ... set token_hash = sha256(token_hash)),
-- which would have kept every session alive. We deliberately did not: neither SQLite nor a
-- stock Postgres install has SHA-256 available in plain SQL without an extension, and more
-- importantly those values have been at rest in plaintext for the life of the table. Any
-- copy already taken stays valid for up to 7 days if we preserve them. Deleting is the
-- clean cut.
--
-- Consequence: everyone with a live session is logged out once, at deploy. Access tokens
-- (15 min) keep working until they expire; the next refresh attempt returns 401 and the
-- client re-authenticates. No user data is touched.
--
-- No schema change is needed: token_hash is already varchar(255) not null unique, and a
-- SHA-256 hex digest is 64 chars.
--
-- The postgres twin of this file must stay at the same version.

delete from refresh_tokens;
Loading
Loading