221 lines
8.1 KiB
Java
221 lines
8.1 KiB
Java
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;
|
|
import com.ddf.vodsystem.repositories.UserRepository;
|
|
import com.ddf.vodsystem.security.JwtService;
|
|
import com.ddf.vodsystem.security.TokenService;
|
|
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;
|
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
|
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.Optional;
|
|
|
|
@Service
|
|
public class UserService {
|
|
private final GoogleIdTokenVerifier verifier;
|
|
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,
|
|
JwtService jwtService,
|
|
@Value("${google.client.id}") String googleClientId,
|
|
TokenService tokenService) {
|
|
this.userRepository = userRepository;
|
|
|
|
NetHttpTransport transport = new NetHttpTransport();
|
|
JsonFactory jsonFactory = new GsonFactory();
|
|
|
|
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
|
|
.setAudience(Collections.singletonList(googleClientId))
|
|
.build();
|
|
this.jwtService = jwtService;
|
|
this.tokenService = tokenService;
|
|
}
|
|
|
|
/**
|
|
* Looks up a user by their database ID.
|
|
*
|
|
* @param userId the ID of the user to find
|
|
* @return the matching {@link User}, or empty if none exists
|
|
*/
|
|
public Optional<User> getUserById(Long userId) {
|
|
return userRepository.findById(userId);
|
|
}
|
|
|
|
/**
|
|
* Returns the user backing the current Spring Security authentication, if authenticated.
|
|
*
|
|
* @return the authenticated {@link User}, or empty if no user is authenticated
|
|
*/
|
|
public Optional<User> getLoggedInUser() {
|
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
|
return Optional.of(user);
|
|
}
|
|
return Optional.empty();
|
|
}
|
|
|
|
/**
|
|
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
|
|
*
|
|
* @param idToken the Google ID token to verify
|
|
* @return a signed JWT for the authenticated user
|
|
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
|
*/
|
|
@Transactional
|
|
public TokenPackage login(String idToken) {
|
|
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
|
String googleId = googleIdToken.getPayload().getSubject();
|
|
|
|
if (googleId == null) {
|
|
throw new NotAuthenticated("Invalid ID token");
|
|
}
|
|
|
|
User googleUser = getGoogleUser(googleIdToken);
|
|
User user = createOrUpdateUser(googleUser);
|
|
|
|
byte[] rawRefresh = generateRandomBytes(32);
|
|
TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs);
|
|
tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs);
|
|
|
|
String jwt = jwtService.generateToken(user.getId());
|
|
return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt);
|
|
}
|
|
|
|
@Transactional(noRollbackFor = NotAuthenticated.class)
|
|
public TokenPackage refresh(String refreshToken) {
|
|
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> 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);
|
|
}
|
|
|
|
/**
|
|
* Looks up a user by their stream key.
|
|
*
|
|
* @param streamKey the stream key to match
|
|
* @return the matching {@link User}, or empty if none exists
|
|
*/
|
|
public Optional<User> getUserByStreamKey(String streamKey) {
|
|
return userRepository.findByStreamKey(streamKey);
|
|
}
|
|
|
|
private User createOrUpdateUser(User user) {
|
|
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
|
|
|
if (existingUser.isEmpty()) {
|
|
user.setRole(0);
|
|
user.setCreatedAt(Instant.now());
|
|
user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
|
|
return userRepository.saveAndFlush(user);
|
|
}
|
|
|
|
User existing = existingUser.get();
|
|
existing.setEmail(user.getEmail());
|
|
existing.setName(user.getName());
|
|
existing.setProfilePictureUrl(user.getProfilePictureUrl());
|
|
existing.setUsername(user.getUsername());
|
|
return userRepository.saveAndFlush(existing);
|
|
}
|
|
|
|
private User getGoogleUser(GoogleIdToken idToken) {
|
|
String googleId = idToken.getPayload().getSubject();
|
|
String email = idToken.getPayload().getEmail();
|
|
String name = (String) idToken.getPayload().get("name");
|
|
String profilePictureUrl = (String) idToken.getPayload().get("picture");
|
|
|
|
User user = new User();
|
|
user.setGoogleId(googleId);
|
|
user.setEmail(email);
|
|
user.setName(name);
|
|
user.setUsername(email);
|
|
user.setProfilePictureUrl(profilePictureUrl);
|
|
|
|
return user;
|
|
}
|
|
|
|
private GoogleIdToken getGoogleIdToken(String idToken) {
|
|
try {
|
|
return verifier.verify(idToken);
|
|
} catch (GeneralSecurityException | IOException e) {
|
|
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
private byte[] generateRandomBytes(int length) {
|
|
byte[] bytes = new byte[length];
|
|
SECURE_RANDOM.nextBytes(bytes);
|
|
return bytes;
|
|
}
|
|
}
|