extract rotation logic to TokenService
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-10-05 19:30:50 +01:00
parent cbb8c2195f
commit 11115a435a
3 changed files with 125 additions and 104 deletions

View File

@@ -25,12 +25,20 @@ public class GoogleVerifierService {
throw new NotAuthenticated("Invalid ID token: " + e.getMessage()); throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
} }
if (token == null) {
throw new NotAuthenticated("Invalid ID token");
}
String googleId = token.getPayload().getSubject(); String googleId = token.getPayload().getSubject();
if (googleId == null) { if (googleId == null) {
throw new NotAuthenticated("Google ID does not exist"); throw new NotAuthenticated("Google ID does not exist");
} }
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
throw new NotAuthenticated("Email not verified");
}
return new GoogleUser( return new GoogleUser(
googleId, googleId,
token.getPayload().getEmail(), token.getPayload().getEmail(),

View File

@@ -1,88 +1,158 @@
package com.ddf.vodsystem.security; package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.RefreshToken; import com.ddf.vodsystem.entities.RefreshToken;
import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.TokenFamily;
import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository; import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository; import com.ddf.vodsystem.repositories.TokenFamilyRepository;
import org.apache.commons.codec.binary.Hex;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class TokenService { public class TokenService {
private static final SecureRandom RNG = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
private final TokenFamilyRepository tokenFamilyRepository; private final TokenFamilyRepository tokenFamilyRepository;
private final RefreshTokenRepository refreshTokenRepository; private final RefreshTokenRepository refreshTokenRepository;
private static final SecureRandom RNG = new SecureRandom(); private final Duration tokenFamilyExpirationMs;
private final HexFormat hex = HexFormat.of(); private final Duration refreshTokenExpirationMs;
private final Duration refreshGracePeriod;
public TokenService ( public TokenService (
TokenFamilyRepository tokenFamilyRepository, TokenFamilyRepository tokenFamilyRepository,
RefreshTokenRepository refreshTokenRepository RefreshTokenRepository refreshTokenRepository,
AuthProperties props
) { ) {
this.tokenFamilyRepository = tokenFamilyRepository; this.tokenFamilyRepository = tokenFamilyRepository;
this.refreshTokenRepository = refreshTokenRepository; 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(); Instant now = Instant.now();
TokenFamily tokenFamily = new TokenFamily(); TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setUser(user); tokenFamily.setUser(user);
tokenFamily.setCreatedAt(now); 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> 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, byte[] token,
TokenFamily tokenFamily, TokenFamily tokenFamily
long expiryTimeMs
) { ) {
Instant now = Instant.now(); Instant now = Instant.now();
RefreshToken refreshToken = new RefreshToken(); RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily); refreshToken.setTokenFamily(tokenFamily);
refreshToken.setTokenHash(hashToken(token)); refreshToken.setTokenHash(sha256(token));
refreshToken.setCreatedAt(now); refreshToken.setCreatedAt(now);
refreshToken.setExpiresAt(now.plusMillis(expiryTimeMs)); refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpirationMs.toMillis()));
return refreshTokenRepository.saveAndFlush(refreshToken); return refreshTokenRepository.saveAndFlush(refreshToken);
} }
public Optional<RefreshToken> getRefreshTokenByHash(byte[] refreshHash) {
return refreshTokenRepository.findByHash(refreshHash);
}
public Optional<TokenFamily> 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);
}
}
} }

View File

@@ -2,9 +2,6 @@ package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.GoogleUser; import com.ddf.vodsystem.dto.GoogleUser;
import com.ddf.vodsystem.dto.TokenPackage; 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.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository; import com.ddf.vodsystem.repositories.UserRepository;
@@ -17,7 +14,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
@@ -28,25 +24,17 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtService jwtService; private final JwtService jwtService;
private final long tokenFamilyExpirationMs;
private final long refreshTokenExpirationMs;
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int REFRESH_GRACE_PERIOD_SECONDS = 5;
private final TokenService tokenService; private final TokenService tokenService;
public UserService(UserRepository userRepository, public UserService(UserRepository userRepository,
JwtService jwtService, JwtService jwtService,
GoogleVerifierService googleVerifierService, GoogleVerifierService googleVerifierService,
AuthProperties props,
TokenService tokenService) { TokenService tokenService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.googleVerifierService = googleVerifierService; this.googleVerifierService = googleVerifierService;
this.jwtService = jwtService; this.jwtService = jwtService;
this.tokenService = tokenService; 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); GoogleUser googleUser = googleVerifierService.verify(idToken);
User user = createOrUpdateGoogleUser(googleUser); User user = createOrUpdateGoogleUser(googleUser);
byte[] rawRefresh = generateRandomBytes(32); String refreshToken = tokenService.createSession(user);
TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs);
tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs);
String jwt = jwtService.generateToken(user.getId()); 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) { public TokenPackage refresh(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) { TokenService.Rotation rotation = tokenService.rotate(refreshToken);
throw new NotAuthenticated("Missing refresh token"); String jwt = jwtService.generateToken(rotation.userId());
}
// Check Refresh Token is real return new TokenPackage(rotation.newRefreshToken(), jwt);
byte[] rawToken = tokenService.hexToBytes(refreshToken);
RefreshToken token = tokenService.getRefreshTokenByHash(rawToken)
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
// Find Token Family
Optional<TokenFamily> 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);
} }
/** /**
@@ -170,7 +113,7 @@ public class UserService {
user.setEmail(googleUser.email()); user.setEmail(googleUser.email());
user.setName(googleUser.name()); user.setName(googleUser.name());
user.setProfilePictureUrl(googleUser.profilePictureUrl()); user.setProfilePictureUrl(googleUser.profilePictureUrl());
return userRepository.saveAndFlush(user); return userRepository.save(user);
} }
private byte[] generateRandomBytes(int length) { private byte[] generateRandomBytes(int length) {