diff --git a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java index ff02ad6..7f26e6c 100644 --- a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java +++ b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java @@ -1,13 +1,16 @@ package com.ddf.vodsystem; +import com.ddf.vodsystem.dto.properties.AuthProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling @EnableAsync +@EnableConfigurationProperties(AuthProperties.class) public class VodSystemApplication { /** diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index 6747bca..6abb7be 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -1,10 +1,12 @@ package com.ddf.vodsystem.configuration; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -24,7 +26,7 @@ public class SecurityConfig { /** * Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter} - * installed, and per-endpoint authorization rules. + * installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules. * * @param http the HTTP security builder to configure * @return the built {@link SecurityFilterChain} @@ -41,11 +43,14 @@ public class SecurityConfig { .requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated .requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs .requestMatchers("/api/v1/media/**").authenticated() - .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() - .requestMatchers("/api/v1/users/login").permitAll() + .requestMatchers("/api/v1/users/me").authenticated() + // authenticated by the refresh token cookie, not the (possibly expired) access token + .requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .anyRequest().authenticated() ) + // 401 rather than the default 403, so clients know to refresh their session + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); diff --git a/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java b/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java new file mode 100644 index 0000000..5e40187 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java @@ -0,0 +1,15 @@ +package com.ddf.vodsystem.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 815e8bd..350a7d0 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -1,17 +1,21 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.controllers.dto.SessionResponse; import com.ddf.vodsystem.controllers.dto.Token; +import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.dto.properties.AuthProperties; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.services.UserService; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Duration; import java.util.Optional; @RestController @@ -19,12 +23,18 @@ import java.util.Optional; public class UserController { private final UserService userService; - @Value("${jwt.expiration}") - private long jwtExpiration; + private final Duration jwtExpiration; + private final Duration refreshTokenExpiration; private static final String SUCCESS = "success"; - public UserController(UserService userService) { + private static final String ACCESS_COOKIE_PATH = "/"; + private static final String REFRESH_COOKIE_PATH = "/api/v1/users"; + + public UserController(UserService userService, + AuthProperties props) { this.userService = userService; + this.jwtExpiration = props.expiration().jwt(); + this.refreshTokenExpiration = props.expiration().refreshToken(); } /** @@ -47,59 +57,110 @@ public class UserController { } /** - * Authenticates a user from a Google ID token and issues a session JWT. + * Authenticates a user from a Google ID token and starts a session. *
- * The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
- * returned in the response body.
+ * Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
+ * (path {@code /api/v1/users}). Neither token is returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
- * @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
- * @param response the response the session cookie is written to
- * @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
+ * @param request the incoming request, used to decide the cookies' {@code Secure} flag
+ * @param response the response the cookies are written to
+ * @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse}
+ * @throws NotAuthenticated if the Google ID token is rejected
*/
@PostMapping("/login")
- public ResponseEntity
+ * Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it.
+ * Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace
+ * period is treated as theft and revokes the whole family.
+ */
+@Service
+public class TokenService {
+ private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
+ private static final SecureRandom RNG = new SecureRandom();
+ private static final HexFormat HEX = HexFormat.of();
+
+ private final Clock clock;
+
+ private final TokenFamilyRepository tokenFamilyRepository;
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ private final Duration tokenFamilyExpiration;
+ private final Duration refreshTokenExpiration;
+ private final Duration refreshGracePeriod;
+
+ /**
+ * @param tokenFamilyRepository stores token families (one per login session)
+ * @param refreshTokenRepository stores hashed refresh tokens
+ * @param props supplies token family lifetime, refresh token lifetime and reuse grace period
+ * @param clock time source for all timestamps and expiry checks
+ */
+ public TokenService (
+ TokenFamilyRepository tokenFamilyRepository,
+ RefreshTokenRepository refreshTokenRepository,
+ AuthProperties props,
+ Clock clock
+ ) {
+ this.tokenFamilyRepository = tokenFamilyRepository;
+ this.refreshTokenRepository = refreshTokenRepository;
+ this.clock = clock;
+
+ this.refreshTokenExpiration = props.expiration().refreshToken();
+ this.tokenFamilyExpiration = props.expiration().tokenFamily();
+ this.refreshGracePeriod = props.expiration().gracePeriod();
+ }
+
+ /**
+ * Starts a new session for a user by creating a token family and its first refresh token.
+ *
+ * @param user the user the session belongs to
+ * @return the raw refresh token as a 64-character hex string; only its hash is persisted
+ */
+ @Transactional
+ public String createSession(User user) {
+ Instant now = Instant.now(clock);
+
+ TokenFamily tokenFamily = new TokenFamily();
+ tokenFamily.setUser(user);
+ tokenFamily.setCreatedAt(now);
+ tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
+ tokenFamilyRepository.save(tokenFamily);
+
+ byte[] token = generateRandomBytes(32);
+
+ createRefreshToken(
+ token,
+ tokenFamily
+ );
+
+ return HEX.formatHex(token);
+ }
+
+ /**
+ * Exchanges a refresh token for a new one in the same family.
+ *
+ * The presented token is marked used and a new token is issued. Presenting a used token again
+ * within the grace period returns another new token without extending the grace period. Presenting
+ * it after the grace period revokes the family; this revocation is committed even though the
+ * method throws.
+ *
+ * @param hexToken the raw refresh token as a hex string
+ * @return the owning user's ID and the new raw refresh token
+ * @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked
+ * or expired family, or is reused after the grace period
+ */
+ @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(clock);
+
+ // Check Token is real
+ RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
+ .orElseThrow(() -> new NotAuthenticated("No such refresh token"));
+
+ // Find Token Family
+ TokenFamily tokenFamily = refreshToken.getTokenFamily();
+
+ if (tokenFamily.getRevokedAt() != null) {
+ throw new NotAuthenticated("Token family has been revoked");
+ }
+
+ // Reuse Check
+ if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
+ tokenFamily.setRevokedAt(now);
+ tokenFamilyRepository.save(tokenFamily);
+ throw new NotAuthenticated("Refresh token already being used");
+ }
+
+ // Expiry Check
+ if (
+ now.isAfter(refreshToken.getExpiresAt()) ||
+ now.isAfter(tokenFamily.getExpiresAt())
+ ) {
+ throw new NotAuthenticated("Refresh token or family expired");
+ }
+
+ // Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
+ if (refreshToken.getUsedAt() == null) {
+ refreshToken.setUsedAt(now);
+ refreshTokenRepository.save(refreshToken);
+ }
+
+ byte[] newRawToken = generateRandomBytes(32);
+ createRefreshToken(
+ newRawToken,
+ tokenFamily
+ );
+
+ return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
+ }
+
+ /**
+ * Revokes a refresh token and its whole token family, ending the session. Does nothing if the
+ * token is missing or unknown, and leaves existing revocation timestamps unchanged.
+ *
+ * @param hexToken the raw refresh token as a hex string, may be {@code null}
+ * @throws NotAuthenticated if the token is not valid hex
+ */
+ @Transactional
+ public void revoke(String hexToken) {
+ if (hexToken == null || hexToken.isBlank()) return;
+
+ byte[] hash;
+ try {
+ hash = sha256(HEX.parseHex(hexToken));
+ } catch (IllegalArgumentException e) {
+ throw new NotAuthenticated("Malformed refresh token");
+ }
+
+ Optional
+ * Runs hourly. Used tokens in live families are kept, since reuse detection needs them.
+ */
+ @Scheduled(fixedDelay = 3_600_000)
+ @Transactional
+ public void deleteDeadSessions() {
+ int deleted = tokenFamilyRepository.deleteDead(Instant.now(clock));
+ if (deleted > 0) logger.info("Deleted {} dead token families", deleted);
+ }
+
+ /**
+ * Result of a successful {@link #rotate(String)}.
+ *
+ * @param userId ID of the user who owns the token family
+ * @param newRefreshToken the newly issued raw refresh token as a hex string
+ */
+ 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,
+ TokenFamily tokenFamily
+ ) {
+ Instant now = Instant.now(clock);
+
+ RefreshToken refreshToken = new RefreshToken();
+ refreshToken.setTokenFamily(tokenFamily);
+ refreshToken.setTokenHash(sha256(token));
+ refreshToken.setCreatedAt(now);
+ refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
+ return refreshTokenRepository.save(refreshToken);
+ }
+}
diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java
index f29d16c..f328a59 100644
--- a/src/main/java/com/ddf/vodsystem/services/UserService.java
+++ b/src/main/java/com/ddf/vodsystem/services/UserService.java
@@ -1,47 +1,44 @@
package com.ddf.vodsystem.services;
+import com.ddf.vodsystem.dto.GoogleUser;
+import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository;
+import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService;
-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.beans.factory.annotation.Value;
+import com.ddf.vodsystem.security.TokenService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.transaction.annotation.Transactional;
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.Instant;
-import java.util.Collections;
import java.util.HexFormat;
import java.util.Optional;
@Service
public class UserService {
- private final GoogleIdTokenVerifier verifier;
+ private static final Logger logger = LoggerFactory.getLogger(UserService.class);
+
+ private final GoogleVerifierService googleVerifierService;
private final UserRepository userRepository;
private final JwtService jwtService;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private final TokenService tokenService;
public UserService(UserRepository userRepository,
JwtService jwtService,
- @Value("${google.client.id}") String googleClientId) {
+ GoogleVerifierService googleVerifierService,
+ 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.googleVerifierService = googleVerifierService;
this.jwtService = jwtService;
+ this.tokenService = tokenService;
}
/**
@@ -68,24 +65,58 @@ public class UserService {
}
/**
- * Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
+ * Verifies a Google ID token, creates or updates the matching user, and starts a new session.
+ *
+ * A new user gets a username equal to their email, the default role and a random stream key.
+ * Email, name and profile picture are refreshed from Google on every login.
*
* @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
+ * @return a new refresh token and a signed JWT access token for the user
+ * @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified
*/
- public String login(String idToken) {
- GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
- String googleId = googleIdToken.getPayload().getSubject();
+ @Transactional
+ public TokenPackage login(String idToken) {
+ GoogleUser googleUser = googleVerifierService.verify(idToken);
+ User user = createOrUpdateGoogleUser(googleUser);
- if (googleId == null) {
- throw new NotAuthenticated("Invalid ID token");
+ String refreshToken = tokenService.createSession(user);
+ String jwt = jwtService.generateToken(user.getId());
+
+ logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
+
+ return new TokenPackage(refreshToken, jwt);
+ }
+
+ /**
+ * Rotates a refresh token and issues a new JWT access token for its owner.
+ *
+ * @param refreshToken the raw refresh token as a hex string
+ * @return the new refresh token and a signed JWT access token
+ * @throws NotAuthenticated if the refresh token is rejected; see {@link TokenService#rotate(String)}
+ */
+ public TokenPackage refresh(String refreshToken) {
+ TokenService.Rotation rotation = tokenService.rotate(refreshToken);
+ String jwt = jwtService.generateToken(rotation.userId());
+
+ logger.debug("New refresh and access token issued for user ID {}", rotation.userId());
+
+ return new TokenPackage(rotation.newRefreshToken(), jwt);
+ }
+
+ /**
+ * Ends the session tied to a refresh token by revoking its token family.
+ * Already-issued JWT access tokens stay valid until they expire.
+ *
+ * Does nothing if the token is missing, malformed or unknown.
+ *
+ * @param refreshToken the raw refresh token as a hex string, may be {@code null}
+ */
+ public void logout(String refreshToken) {
+ try {
+ tokenService.revoke(refreshToken);
+ } catch (NotAuthenticated e) {
+ logger.info("Malformed or missing token on logout()");
}
-
- User googleUser = getGoogleUser(googleIdToken);
- User user = createOrUpdateUser(googleUser);
-
- return jwtService.generateToken(user.getId());
}
/**
@@ -98,51 +129,30 @@ public class UserService {
return userRepository.findByStreamKey(streamKey);
}
- private User createOrUpdateUser(User user) {
- Optional