add refresh method & update login to use refresh tokens
This commit is contained in:
@@ -2,6 +2,7 @@ package com.ddf.vodsystem.controllers;
|
|||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.controllers.dto.Token;
|
import com.ddf.vodsystem.controllers.dto.Token;
|
||||||
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
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.services.UserService;
|
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}
|
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<TokenPackage>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(token.token());
|
TokenPackage tokenPackage = userService.login(token.token());
|
||||||
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken())
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(jwtExpiration / 1000)
|
.maxAge(jwtExpiration / 1000)
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||||
@@ -74,7 +75,7 @@ public class UserController {
|
|||||||
response.addHeader("Set-Cookie", cookie.toString());
|
response.addHeader("Set-Cookie", cookie.toString());
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
|
new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ package com.ddf.vodsystem.repositories;
|
|||||||
|
|
||||||
import com.ddf.vodsystem.entities.RefreshToken;
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Ref;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||||
|
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
|
||||||
|
Optional<RefreshToken> findByHash(byte[] tokenHash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ddf.vodsystem.repositories;
|
|||||||
|
|
||||||
import com.ddf.vodsystem.entities.TokenFamily;
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import com.ddf.vodsystem.entities.TokenFamily;
|
|||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
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 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.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TokenService {
|
public class TokenService {
|
||||||
@@ -18,6 +21,7 @@ public class TokenService {
|
|||||||
private final RefreshTokenRepository refreshTokenRepository;
|
private final RefreshTokenRepository refreshTokenRepository;
|
||||||
|
|
||||||
private static final SecureRandom RNG = new SecureRandom();
|
private static final SecureRandom RNG = new SecureRandom();
|
||||||
|
private final HexFormat hex = HexFormat.of();
|
||||||
|
|
||||||
public TokenService (
|
public TokenService (
|
||||||
TokenFamilyRepository tokenFamilyRepository,
|
TokenFamilyRepository tokenFamilyRepository,
|
||||||
@@ -38,7 +42,7 @@ public class TokenService {
|
|||||||
return tokenFamilyRepository.saveAndFlush(tokenFamily);
|
return tokenFamilyRepository.saveAndFlush(tokenFamily);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RefreshToken getRefreshToken(
|
public RefreshToken createRefreshToken(
|
||||||
byte[] token,
|
byte[] token,
|
||||||
TokenFamily tokenFamily,
|
TokenFamily tokenFamily,
|
||||||
long expiryTimeMs
|
long expiryTimeMs
|
||||||
@@ -53,7 +57,28 @@ public class TokenService {
|
|||||||
return refreshTokenRepository.saveAndFlush(refreshToken);
|
return refreshTokenRepository.saveAndFlush(refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] hashToken(byte[] token) {
|
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 {
|
try {
|
||||||
return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes
|
return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ddf.vodsystem.services;
|
package com.ddf.vodsystem.services;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.TokenPackage;
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
|
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.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.http.javanet.NetHttpTransport;
|
||||||
import com.google.api.client.json.JsonFactory;
|
import com.google.api.client.json.JsonFactory;
|
||||||
import com.google.api.client.json.gson.GsonFactory;
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -20,10 +22,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.MissingResourceException;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -32,7 +34,14 @@ public class UserService {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtService jwtService;
|
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 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,
|
||||||
@@ -81,7 +90,8 @@ public class UserService {
|
|||||||
* @return a signed JWT for the authenticated user
|
* @return a signed JWT for the authenticated user
|
||||||
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
* @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);
|
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
||||||
String googleId = googleIdToken.getPayload().getSubject();
|
String googleId = googleIdToken.getPayload().getSubject();
|
||||||
|
|
||||||
@@ -92,18 +102,62 @@ public class UserService {
|
|||||||
User googleUser = getGoogleUser(googleIdToken);
|
User googleUser = getGoogleUser(googleIdToken);
|
||||||
User user = createOrUpdateUser(googleUser);
|
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());
|
String jwt = jwtService.generateToken(user.getId());
|
||||||
|
return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt);
|
||||||
|
|
||||||
return jwtService.generateToken(user.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(noRollbackFor = NotAuthenticated.class)
|
||||||
public TokenPackage refresh(String refreshToken) {
|
public TokenPackage refresh(String refreshToken) {
|
||||||
if (refreshToken == null) {
|
if (refreshToken == null || refreshToken.isBlank()) {
|
||||||
throw new NotAuthenticated("Missing refresh token");
|
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> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user