From 11115a435a8828a6373b3ce57a19737b45b864dd Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 5 Oct 2026 19:30:50 +0100 Subject: [PATCH] extract rotation logic to TokenService --- .../security/GoogleVerifierService.java | 8 + .../ddf/vodsystem/security/TokenService.java | 150 +++++++++++++----- .../ddf/vodsystem/services/UserService.java | 71 +-------- 3 files changed, 125 insertions(+), 104 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java index e3497db..e4ef817 100644 --- a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java +++ b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java @@ -25,12 +25,20 @@ public class GoogleVerifierService { throw new NotAuthenticated("Invalid ID token: " + e.getMessage()); } + if (token == null) { + throw new NotAuthenticated("Invalid ID token"); + } + String googleId = token.getPayload().getSubject(); if (googleId == null) { throw new NotAuthenticated("Google ID does not exist"); } + if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) { + throw new NotAuthenticated("Email not verified"); + } + return new GoogleUser( googleId, token.getPayload().getEmail(), diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 30615ef..099d0d8 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -1,88 +1,158 @@ package com.ddf.vodsystem.security; +import com.ddf.vodsystem.dto.properties.AuthProperties; import com.ddf.vodsystem.entities.RefreshToken; import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.RefreshTokenRepository; import com.ddf.vodsystem.repositories.TokenFamilyRepository; -import org.apache.commons.codec.binary.Hex; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.util.HexFormat; import java.util.Optional; @Service public class TokenService { + private static final SecureRandom RNG = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + private final TokenFamilyRepository tokenFamilyRepository; private final RefreshTokenRepository refreshTokenRepository; - private static final SecureRandom RNG = new SecureRandom(); - private final HexFormat hex = HexFormat.of(); + private final Duration tokenFamilyExpirationMs; + private final Duration refreshTokenExpirationMs; + private final Duration refreshGracePeriod; public TokenService ( TokenFamilyRepository tokenFamilyRepository, - RefreshTokenRepository refreshTokenRepository + RefreshTokenRepository refreshTokenRepository, + AuthProperties props ) { this.tokenFamilyRepository = tokenFamilyRepository; this.refreshTokenRepository = refreshTokenRepository; + + this.refreshTokenExpirationMs = props.expiration().refreshToken(); + this.tokenFamilyExpirationMs = props.expiration().tokenFamily(); + this.refreshGracePeriod = props.expiration().gracePeriod(); } - public TokenFamily createTokenFamily(User user, long expiryTimeMs) { + @Transactional + public String createSession(User user) { Instant now = Instant.now(); TokenFamily tokenFamily = new TokenFamily(); tokenFamily.setUser(user); tokenFamily.setCreatedAt(now); - tokenFamily.setExpiresAt(now.plusMillis(expiryTimeMs)); + tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpirationMs.toMillis())); + tokenFamilyRepository.save(tokenFamily); - return tokenFamilyRepository.saveAndFlush(tokenFamily); + byte[] token = generateRandomBytes(32); + + createRefreshToken( + token, + tokenFamily + ); + + return HEX.formatHex(token); } - public RefreshToken createRefreshToken( + @Transactional(noRollbackFor = NotAuthenticated.class) + public Rotation rotate(String hexToken) { + if (hexToken == null || hexToken.isBlank()) { + throw new NotAuthenticated("Missing refresh token"); + } + + byte[] hash; + try { + hash = sha256(HEX.parseHex(hexToken)); + } catch (IllegalArgumentException e) { + throw new NotAuthenticated("Malformed refresh token"); + } + + Instant now = Instant.now(); + + // Check Token is real + RefreshToken refreshToken = refreshTokenRepository.findByHash(hash) + .orElseThrow(() -> new NotAuthenticated("No such refresh token")); + + // Find Token Family + Optional tokenFamily = tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); + + if (tokenFamily.isEmpty()) { + throw new IllegalStateException("Token must be tied to a family"); + } + + if (tokenFamily.get().getRevokedAt() != null) { + throw new NotAuthenticated("Token family has been revoked"); + } + + // Reuse Check + if (refreshToken.getUsedAt() != null) { + if (now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) { + tokenFamily.get().setRevokedAt(Instant.now()); + tokenFamilyRepository.save(tokenFamily.get()); + } + + throw new NotAuthenticated("Refresh token already being used"); + } + + // Expiry Check + if ( + now.isAfter(refreshToken.getExpiresAt()) || + now.isAfter(tokenFamily.get().getExpiresAt()) + ) { + throw new NotAuthenticated("Refresh token or family expired"); + } + + // Rotate + refreshToken.setUsedAt(now); + refreshTokenRepository.save(refreshToken); + + byte[] newRawToken = generateRandomBytes(32); + createRefreshToken( + newRawToken, + tokenFamily.get() + ); + + return new Rotation(tokenFamily.get().getUser().getId(), HEX.formatHex(newRawToken)); + } + + public record Rotation(Long userId, String newRefreshToken) {} + + private static byte[] sha256(byte[] in) { + try { + return MessageDigest.getInstance("SHA-256").digest(in); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private byte[] generateRandomBytes(int length) { + byte[] bytes = new byte[length]; + RNG.nextBytes(bytes); + return bytes; + } + + + private RefreshToken createRefreshToken( byte[] token, - TokenFamily tokenFamily, - long expiryTimeMs + TokenFamily tokenFamily ) { Instant now = Instant.now(); RefreshToken refreshToken = new RefreshToken(); refreshToken.setTokenFamily(tokenFamily); - refreshToken.setTokenHash(hashToken(token)); + refreshToken.setTokenHash(sha256(token)); refreshToken.setCreatedAt(now); - refreshToken.setExpiresAt(now.plusMillis(expiryTimeMs)); + refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpirationMs.toMillis())); return refreshTokenRepository.saveAndFlush(refreshToken); } - public Optional getRefreshTokenByHash(byte[] refreshHash) { - return refreshTokenRepository.findByHash(refreshHash); - } - - public Optional getFamilyByRefreshToken(RefreshToken refreshToken) { - return tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); - } - - public TokenFamily revokeTokenFamily(TokenFamily tokenFamily) { - tokenFamily.setRevokedAt(Instant.now()); - return tokenFamilyRepository.saveAndFlush(tokenFamily); - } - - public String bytesToHex(byte[] bytes) { - return hex.formatHex(bytes); - } - - public byte[] hexToBytes(String hexS) { - return hex.parseHex(hexS); - } - - public byte[] hashToken(byte[] token) { - try { - return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } - } } diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 6dcb332..8fd4130 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -2,9 +2,6 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.dto.GoogleUser; import com.ddf.vodsystem.dto.TokenPackage; -import com.ddf.vodsystem.dto.properties.AuthProperties; -import com.ddf.vodsystem.entities.RefreshToken; -import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.UserRepository; @@ -17,7 +14,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.security.SecureRandom; -import java.time.Duration; import java.time.Instant; import java.util.HexFormat; import java.util.Optional; @@ -28,25 +24,17 @@ public class UserService { private final UserRepository userRepository; private final JwtService jwtService; - private final long tokenFamilyExpirationMs; - private final long refreshTokenExpirationMs; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - private static final int REFRESH_GRACE_PERIOD_SECONDS = 5; private final TokenService tokenService; public UserService(UserRepository userRepository, JwtService jwtService, GoogleVerifierService googleVerifierService, - AuthProperties props, TokenService tokenService) { this.userRepository = userRepository; this.googleVerifierService = googleVerifierService; this.jwtService = jwtService; this.tokenService = tokenService; - - this.refreshTokenExpirationMs = props.expiration().refreshToken().toMillis(); - this.tokenFamilyExpirationMs = props.expiration().tokenFamily().toMillis(); } /** @@ -84,62 +72,17 @@ public class UserService { GoogleUser googleUser = googleVerifierService.verify(idToken); User user = createOrUpdateGoogleUser(googleUser); - byte[] rawRefresh = generateRandomBytes(32); - TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs); - tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs); - + String refreshToken = tokenService.createSession(user); String jwt = jwtService.generateToken(user.getId()); - return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt); + + return new TokenPackage(refreshToken, jwt); } - @Transactional(noRollbackFor = NotAuthenticated.class) public TokenPackage refresh(String refreshToken) { - if (refreshToken == null || refreshToken.isBlank()) { - throw new NotAuthenticated("Missing refresh token"); - } + TokenService.Rotation rotation = tokenService.rotate(refreshToken); + String jwt = jwtService.generateToken(rotation.userId()); - // Check Refresh Token is real - byte[] rawToken = tokenService.hexToBytes(refreshToken); - RefreshToken token = tokenService.getRefreshTokenByHash(rawToken) - .orElseThrow(() -> new NotAuthenticated("No such refresh token")); - - // Find Token Family - Optional tokenFamily = tokenService.getFamilyByRefreshToken(token); - - if (tokenFamily.isEmpty()) { - throw new IllegalStateException("Token must be tied to a family"); - } - - if (tokenFamily.get().getRevokedAt() != null) { - throw new NotAuthenticated("Token family has been revoked"); - } - - Instant now = Instant.now(); - - // Reuse Check - if (token.getUsedAt() != null) { - if (Duration.between(token.getUsedAt(), now).getSeconds() > REFRESH_GRACE_PERIOD_SECONDS) { - tokenService.revokeTokenFamily(tokenFamily.get()); - } - throw new NotAuthenticated("Refresh token already used"); - } - - // Expiry Check - if (now.isAfter(token.getExpiresAt()) || now.isAfter(tokenFamily.get().getExpiresAt())) { - throw new NotAuthenticated("Refresh token or family expired"); - } - - // Rotate - token.setUsedAt(now); - byte[] newRawToken = generateRandomBytes(32); - tokenService.createRefreshToken( - newRawToken, - tokenFamily.get(), - refreshTokenExpirationMs - ); - - String jwt = jwtService.generateToken(tokenFamily.get().getUser().getId()); - return new TokenPackage(tokenService.bytesToHex(newRawToken), jwt); + return new TokenPackage(rotation.newRefreshToken(), jwt); } /** @@ -170,7 +113,7 @@ public class UserService { user.setEmail(googleUser.email()); user.setName(googleUser.name()); user.setProfilePictureUrl(googleUser.profilePictureUrl()); - return userRepository.saveAndFlush(user); + return userRepository.save(user); } private byte[] generateRandomBytes(int length) {