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> login(@RequestBody Token token, - HttpServletRequest request, - HttpServletResponse response) { - String jwt = userService.login(token.token()); - - ResponseCookie cookie = ResponseCookie.from("token", jwt) - .httpOnly(true) - .maxAge(jwtExpiration / 1000) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + public ResponseEntity> login(@RequestBody Token token, + HttpServletRequest request, + HttpServletResponse response) { + TokenPackage tokenPackage = userService.login(token.token()); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); return ResponseEntity.ok( - new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt)) + new APIResponse<>(SUCCESS, "Logged in successfully", sessionResponse()) ); } /** - * Logs the user out by clearing the session cookie. + * Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies. * - * @param response the response the expired cookie is written to - * @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags + * @param refreshToken the refresh token from the {@code refreshToken} cookie, if present + * @param request the incoming request, used to decide the cookies' {@code Secure} flag + * @param response the response the new cookies are written to + * @return {@code 200 OK} wrapping the new access token lifetime as a {@link SessionResponse} + * @throws NotAuthenticated if the refresh token is missing or rejected + */ + @PostMapping("/refresh") + public ResponseEntity> refresh( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletRequest request, + HttpServletResponse response) { + TokenPackage tokenPackage = userService.refresh(refreshToken); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Refreshed tokens successfully", sessionResponse()) + ); + } + + /** + * Logs the user out by revoking the session tied to the {@code refreshToken} cookie and clearing + * both auth cookies. Succeeds even if no refresh token cookie is present. + * + * @param refreshToken the refresh token from the {@code refreshToken} cookie, if present + * @param request the incoming request, used to decide the cookies' {@code Secure} flag + * @param response the response the expired cookies are written to * @return {@code 200 OK} confirming logout */ @PostMapping("/logout") - public ResponseEntity> logout(HttpServletResponse response, - HttpServletRequest request) { - ResponseCookie cookie = ResponseCookie.from("token", "") - .httpOnly(true) - .maxAge(0) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + public ResponseEntity> logout( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletRequest request, + HttpServletResponse response) { + userService.logout(refreshToken); + writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO); return ResponseEntity.ok( new APIResponse<>(SUCCESS, "Logged out successfully", null) ); } + + private SessionResponse sessionResponse() { + return new SessionResponse(jwtExpiration.getSeconds()); + } + + private void addCookies( + HttpServletRequest request, + HttpServletResponse response, + String accessToken, + String refreshToken) { + writeCookies(request, response, accessToken, jwtExpiration, refreshToken, refreshTokenExpiration); + } + + + private void writeCookies( + HttpServletRequest request, + HttpServletResponse response, + String accessToken, + Duration accessTokenMaxAge, + String refreshToken, + Duration refreshTokenMaxAge) { + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .maxAge(accessTokenMaxAge) + .sameSite("Lax") + .secure(request.isSecure()) + .path(ACCESS_COOKIE_PATH) + .build(); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .maxAge(refreshTokenMaxAge) + .sameSite("Lax") + .secure(request.isSecure()) + .path(REFRESH_COOKIE_PATH) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + } } diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/SessionResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/SessionResponse.java new file mode 100644 index 0000000..79f8a3a --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/SessionResponse.java @@ -0,0 +1,11 @@ +package com.ddf.vodsystem.controllers.dto; + +/** + * Session metadata returned after login or refresh. The tokens themselves are only ever sent as + * HttpOnly cookies; this tells the client when to refresh them. + * + * @param accessTokenExpiresIn seconds until the access token cookie expires + */ +public record SessionResponse( + long accessTokenExpiresIn +) {} diff --git a/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java b/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java new file mode 100644 index 0000000..68beaa7 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java @@ -0,0 +1,9 @@ +package com.ddf.vodsystem.dto; + +public record GoogleUser( + String googleId, + String email, + String name, + String profilePictureUrl +) { +} diff --git a/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java b/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java new file mode 100644 index 0000000..315f466 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.dto; + +public record TokenPackage ( + String refreshToken, + String accessToken +) { +} diff --git a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java new file mode 100644 index 0000000..da9fd31 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java @@ -0,0 +1,32 @@ +package com.ddf.vodsystem.dto.properties; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +@Validated +@ConfigurationProperties(prefix = "app.security") +public record AuthProperties ( + @NotBlank @Size(min = 32) String jwtSecret, + @Valid @NotNull Expiration expiration +) { + + /** + * + * @param jwt + * @param refreshToken + * @param tokenFamily + * @param gracePeriod + */ + public record Expiration ( + @NotNull Duration jwt, + @NotNull Duration refreshToken, + @NotNull Duration tokenFamily, + @NotNull Duration gracePeriod + ) {} +} diff --git a/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java new file mode 100644 index 0000000..96d7e81 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java @@ -0,0 +1,37 @@ +package com.ddf.vodsystem.entities; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; + +@Entity +@Table(name = "refresh_tokens") +@Data +public class RefreshToken { + @Id + @Column(name = "id", nullable = false, unique = true) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "family_id", nullable = false) + private TokenFamily tokenFamily; + + @Column(name = "token_hash", unique = true, nullable = false, length = 32) + private byte[] tokenHash; + + @Column(name = "created_at", nullable = false) + @CreationTimestamp + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} diff --git a/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java new file mode 100644 index 0000000..ee2311b --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java @@ -0,0 +1,31 @@ +package com.ddf.vodsystem.entities; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; + +@Entity +@Table(name = "token_families") +@Data +public class TokenFamily { + @Id + @Column(name = "id", nullable = false, unique = true) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "created_at", nullable = false) + @CreationTimestamp + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java new file mode 100644 index 0000000..c8542d6 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java @@ -0,0 +1,17 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.RefreshToken; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @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 new file mode 100644 index 0000000..55000c1 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java @@ -0,0 +1,30 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.TokenFamily; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.Instant; + +@Repository +public interface TokenFamilyRepository extends JpaRepository { + /** + * Deletes families that can no longer be refreshed: revoked, expired, or with every refresh + * token expired. Their refresh tokens are removed by the {@code ON DELETE CASCADE} foreign key. + * + * @return the number of families deleted + */ + @Modifying + @Query(""" + DELETE FROM TokenFamily f + WHERE f.revokedAt IS NOT NULL + OR f.expiresAt <= ?1 + OR NOT EXISTS ( + SELECT 1 FROM RefreshToken r + WHERE r.tokenFamily = f AND r.expiresAt > ?1 + ) + """) + int deleteDead(Instant now); +} diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java b/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java new file mode 100644 index 0000000..6edde2f --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java @@ -0,0 +1,28 @@ +package com.ddf.vodsystem.security; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Collections; + +@Configuration +public class GoogleAuthConfig { + + /** + * Provides the verifier used to validate Google ID tokens issued for this application's client ID. + * + * @param googleClientId the OAuth client ID that tokens must be issued for + * @return the configured {@link GoogleIdTokenVerifier} + */ + @Bean + public GoogleIdTokenVerifier googleIdTokenVerifier( + @Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId) { + return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(googleClientId)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java new file mode 100644 index 0000000..e4ef817 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java @@ -0,0 +1,49 @@ +package com.ddf.vodsystem.security; + +import com.ddf.vodsystem.dto.GoogleUser; +import com.ddf.vodsystem.exceptions.NotAuthenticated; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +@Service +public class GoogleVerifierService { + private final GoogleIdTokenVerifier verifier; + + public GoogleVerifierService(GoogleIdTokenVerifier verifier) { + this.verifier = verifier; + } + + public GoogleUser verify(String idToken) { + GoogleIdToken token; + try { + token = verifier.verify(idToken); + } catch (GeneralSecurityException | IOException e) { + throw new NotAuthenticated("Invalid ID token: " + e.getMessage()); + } + + if (token == null) { + throw new NotAuthenticated("Invalid ID token"); + } + + String googleId = token.getPayload().getSubject(); + + if (googleId == null) { + throw new NotAuthenticated("Google ID does not exist"); + } + + if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) { + throw new NotAuthenticated("Email not verified"); + } + + return new GoogleUser( + googleId, + token.getPayload().getEmail(), + (String) token.getPayload().get("name"), + (String) token.getPayload().get("picture") + ); + } +} diff --git a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java index e72ad72..b0552c6 100644 --- a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java +++ b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java @@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter { Cookie[] cookies = request.getCookies(); if (cookies != null) { jwt = Arrays.stream(cookies) - .filter(cookie -> "token".equals(cookie.getName())) + .filter(cookie -> "accessToken".equals(cookie.getName())) .map(Cookie::getValue) .findFirst() .orElse(null); diff --git a/src/main/java/com/ddf/vodsystem/security/JwtService.java b/src/main/java/com/ddf/vodsystem/security/JwtService.java index ce8bfb5..df2a05e 100644 --- a/src/main/java/com/ddf/vodsystem/security/JwtService.java +++ b/src/main/java/com/ddf/vodsystem/security/JwtService.java @@ -7,6 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.TokenExpiredException; import com.auth0.jwt.interfaces.DecodedJWT; +import com.ddf.vodsystem.dto.properties.AuthProperties; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; @@ -20,10 +21,9 @@ public class JwtService { private static final String USER_ID_CLAIM = "userId"; private static final String ISSUER = "vodsystem"; - public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey, - @Value("${jwt.expiration}") long jwtExpiration) { - this.jwtExpiration = jwtExpiration; - this.algorithm = Algorithm.HMAC256(jwtSecretKey); + public JwtService(AuthProperties props) { + this.jwtExpiration = props.expiration().jwt().toMillis(); + this.algorithm = Algorithm.HMAC256(props.jwtSecret()); } /** diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java new file mode 100644 index 0000000..8d7e83c --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -0,0 +1,248 @@ +package com.ddf.vodsystem.security; + +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.exceptions.NotAuthenticated; +import com.ddf.vodsystem.repositories.RefreshTokenRepository; +import com.ddf.vodsystem.repositories.TokenFamilyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Optional; + +/** + * Issues, rotates and revokes opaque refresh tokens. + *

+ * 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 found = refreshTokenRepository.findByHash(hash); + if (found.isEmpty()) return; + + Instant now = Instant.now(clock); + RefreshToken refreshToken = found.get(); + TokenFamily family = refreshToken.getTokenFamily(); + + if (family.getRevokedAt() == null) { + family.setRevokedAt(now); + tokenFamilyRepository.save(family); + } + + if (refreshToken.getRevokedAt() == null) { + refreshToken.setRevokedAt(now); + refreshTokenRepository.save(refreshToken); + } + } + + /** + * Scheduled cleanup that deletes token families that can no longer be refreshed, along with all + * their refresh tokens. + *

+ * 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 existingUser = userRepository.findByGoogleId(user.getGoogleId()); + private User createOrUpdateGoogleUser(GoogleUser googleUser) { + Optional existingUser = userRepository.findByGoogleId(googleUser.googleId()); + User user; if (existingUser.isEmpty()) { + user = new User(); + user.setGoogleId(googleUser.googleId()); + user.setUsername(googleUser.email()); user.setRole(0); user.setCreatedAt(Instant.now()); - user.setStreamKey(generateStreamKey()); - return userRepository.saveAndFlush(user); + user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24))); + } else { + user = existingUser.get(); } - User existing = existingUser.get(); - existing.setEmail(user.getEmail()); - existing.setName(user.getName()); - existing.setProfilePictureUrl(user.getProfilePictureUrl()); - existing.setUsername(user.getUsername()); - return userRepository.saveAndFlush(existing); + user.setEmail(googleUser.email()); + user.setName(googleUser.name()); + user.setProfilePictureUrl(googleUser.profilePictureUrl()); + return userRepository.save(user); } - 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 String generateStreamKey() { - byte[] bytes = new byte[24]; + private byte[] generateRandomBytes(int length) { + byte[] bytes = new byte[length]; SECURE_RANDOM.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); + return bytes; } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ff5b7a..8b3b35b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -20,8 +20,12 @@ spring.flyway.locations=classpath:db/migrations spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=profile,email -jwt.secret.key=${JWT_SECRET_KEY} -jwt.expiration=3600000 +app.security.jwt-secret=${JWT_SECRET_KEY} + +app.security.expiration.jwt=5m +app.security.expiration.refresh-token=7d +app.security.expiration.token-family=90d +app.security.expiration.grace-period=5s # Frontend frontend.url=${FRONTEND_URL} diff --git a/src/main/resources/db/migrations/V2__refresh_tokens.sql b/src/main/resources/db/migrations/V2__refresh_tokens.sql new file mode 100644 index 0000000..2fede26 --- /dev/null +++ b/src/main/resources/db/migrations/V2__refresh_tokens.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS token_families +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_token_families_user_id + ON token_families (user_id); + +CREATE TABLE IF NOT EXISTS refresh_tokens +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES token_families (id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE, -- unique automatically creates a B-tree index + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + + CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32) +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id + ON refresh_tokens (family_id);