From c533257e9884229a83576ce95db2ca46e7a1c0cc Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 3 Oct 2026 02:05:24 +0100 Subject: [PATCH] add refresh method & update login to use refresh tokens --- .../vodsystem/controllers/UserController.java | 13 ++-- .../repositories/RefreshTokenRepository.java | 7 +- .../repositories/TokenFamilyRepository.java | 1 + .../ddf/vodsystem/security/TokenService.java | 29 +++++++- .../ddf/vodsystem/services/UserService.java | 68 +++++++++++++++++-- 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 815e8bd..47baf7a 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -2,6 +2,7 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.controllers.dto.Token; +import com.ddf.vodsystem.dto.TokenPackage; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.services.UserService; @@ -58,12 +59,12 @@ public class UserController { * @return {@code 200 OK} wrapping the issued JWT as a {@link Token} */ @PostMapping("/login") - public ResponseEntity> login(@RequestBody Token token, - HttpServletRequest request, - HttpServletResponse response) { - String jwt = userService.login(token.token()); + public ResponseEntity> login(@RequestBody Token token, + HttpServletRequest request, + HttpServletResponse response) { + TokenPackage tokenPackage = userService.login(token.token()); - ResponseCookie cookie = ResponseCookie.from("token", jwt) + ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken()) .httpOnly(true) .maxAge(jwtExpiration / 1000) .sameSite(request.isSecure() ? "None" : "Lax") @@ -74,7 +75,7 @@ public class UserController { response.addHeader("Set-Cookie", cookie.toString()); return ResponseEntity.ok( - new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt)) + new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage) ); } diff --git a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java index eead9cf..4b925cd 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java @@ -2,9 +2,14 @@ package com.ddf.vodsystem.repositories; import com.ddf.vodsystem.entities.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.sql.Ref; +import java.util.Optional; + @Repository public interface RefreshTokenRepository extends JpaRepository { - + @Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1") + Optional findByHash(byte[] tokenHash); } diff --git a/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java index 9fbbf90..a0ec109 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java @@ -2,6 +2,7 @@ package com.ddf.vodsystem.repositories; import com.ddf.vodsystem.entities.TokenFamily; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 0294e08..30615ef 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -5,12 +5,15 @@ import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; 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 java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; +import java.util.HexFormat; +import java.util.Optional; @Service public class TokenService { @@ -18,6 +21,7 @@ public class TokenService { private final RefreshTokenRepository refreshTokenRepository; private static final SecureRandom RNG = new SecureRandom(); + private final HexFormat hex = HexFormat.of(); public TokenService ( TokenFamilyRepository tokenFamilyRepository, @@ -38,7 +42,7 @@ public class TokenService { return tokenFamilyRepository.saveAndFlush(tokenFamily); } - public RefreshToken getRefreshToken( + public RefreshToken createRefreshToken( byte[] token, TokenFamily tokenFamily, long expiryTimeMs @@ -53,7 +57,28 @@ public class TokenService { return refreshTokenRepository.saveAndFlush(refreshToken); } - private static byte[] hashToken(byte[] token) { + 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) { diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index acd40d1..fc3354c 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.entities.RefreshToken; import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; @@ -11,6 +12,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.gson.GsonFactory; +import org.springframework.transaction.annotation.Transactional; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -20,10 +22,10 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.util.Collections; import java.util.HexFormat; -import java.util.MissingResourceException; import java.util.Optional; @Service @@ -32,7 +34,14 @@ public class UserService { private final UserRepository userRepository; private final JwtService jwtService; + @Value("token-family.expiration") + private long tokenFamilyExpirationMs; + + @Value("refresh-token.expiration") + private 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, @@ -81,7 +90,8 @@ public class UserService { * @return a signed JWT for the authenticated user * @throws NotAuthenticated if the token is invalid or its subject is missing */ - public String login(String idToken) { + @Transactional + public TokenPackage login(String idToken) { GoogleIdToken googleIdToken = getGoogleIdToken(idToken); String googleId = googleIdToken.getPayload().getSubject(); @@ -92,18 +102,62 @@ public class UserService { User googleUser = getGoogleUser(googleIdToken); User user = createOrUpdateUser(googleUser); - TokenFamily tokenFamily = tokenService.createTokenFamily(user, ) + byte[] rawRefresh = generateRandomBytes(32); + TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs); + tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs); + String jwt = jwtService.generateToken(user.getId()); - - - return jwtService.generateToken(user.getId()); + return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt); } + @Transactional(noRollbackFor = NotAuthenticated.class) public TokenPackage refresh(String refreshToken) { - if (refreshToken == null) { + if (refreshToken == null || refreshToken.isBlank()) { throw new NotAuthenticated("Missing refresh token"); } + // 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); } /**