extract rotation logic to TokenService
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user