From 12852e034d2790c12af5c80edd8ed19211781735 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 16 Oct 2026 17:51:09 +0100 Subject: [PATCH 1/3] add backend tokens --- .../ddf/vodsystem/VodSystemApplication.java | 3 + .../configuration/SecurityConfig.java | 11 +- .../vodsystem/configuration/TimeConfig.java | 15 ++ .../vodsystem/controllers/UserController.java | 139 +++++++--- .../controllers/dto/SessionResponse.java | 11 + .../com/ddf/vodsystem/dto/GoogleUser.java | 9 + .../com/ddf/vodsystem/dto/TokenPackage.java | 7 + .../dto/properties/AuthProperties.java | 32 +++ .../ddf/vodsystem/entities/RefreshToken.java | 37 +++ .../ddf/vodsystem/entities/TokenFamily.java | 31 +++ .../repositories/RefreshTokenRepository.java | 17 ++ .../repositories/TokenFamilyRepository.java | 30 +++ .../vodsystem/security/GoogleAuthConfig.java | 28 ++ .../security/GoogleVerifierService.java | 49 ++++ .../com/ddf/vodsystem/security/JwtFilter.java | 2 +- .../ddf/vodsystem/security/JwtService.java | 8 +- .../ddf/vodsystem/security/TokenService.java | 248 ++++++++++++++++++ .../ddf/vodsystem/services/UserService.java | 146 ++++++----- src/main/resources/application.properties | 8 +- .../db/migrations/V2__refresh_tokens.sql | 27 ++ 20 files changed, 741 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/SessionResponse.java create mode 100644 src/main/java/com/ddf/vodsystem/dto/GoogleUser.java create mode 100644 src/main/java/com/ddf/vodsystem/dto/TokenPackage.java create mode 100644 src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java create mode 100644 src/main/java/com/ddf/vodsystem/entities/RefreshToken.java create mode 100644 src/main/java/com/ddf/vodsystem/entities/TokenFamily.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java create mode 100644 src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java create mode 100644 src/main/java/com/ddf/vodsystem/security/TokenService.java create mode 100644 src/main/resources/db/migrations/V2__refresh_tokens.sql 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); -- 2.49.1 From 417e52d261e5a3c278d19dc35489c7422e590410 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 16 Oct 2026 20:51:56 +0100 Subject: [PATCH 2/3] add tests --- .../vodsystem/security/TokenServiceTest.java | 628 ++++++++++++++++++ .../vodsystem/services/UserServiceTest.java | 295 ++++++++ 2 files changed, 923 insertions(+) create mode 100644 src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java create mode 100644 src/test/java/com/ddf/vodsystem/services/UserServiceTest.java diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java new file mode 100644 index 0000000..68b62ee --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -0,0 +1,628 @@ +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.MessageDigest; +import java.time.*; +import java.util.HexFormat; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class MutableClock extends Clock { + private Instant now; + + MutableClock(Instant start) { + this.now = start; + } + + void advance(Duration d) { + now = now.plus(d); + } + + @Override public Instant instant() { return now; } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } +} + + +@ExtendWith(MockitoExtension.class) +class TokenServiceTest { + @Mock private TokenFamilyRepository tokenFamilyRepository; + @Mock private RefreshTokenRepository refreshTokenRepository; + + @Captor ArgumentCaptor familyCaptor; + @Captor ArgumentCaptor tokenCaptor; + + private static final HexFormat HEX = HexFormat.of(); + private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef"; + + AuthProperties authProperties; + TokenService tokenService; + MutableClock clock; + + User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + return user; + } + + @BeforeEach + void setUp() { + + authProperties = new AuthProperties( + "a-secret", + new AuthProperties.Expiration( + Duration.ofSeconds(10), + Duration.ofSeconds(10), + Duration.ofSeconds(10), + Duration.ofSeconds(5) + ) + ); + + clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z")); + + tokenService = new TokenService( + tokenFamilyRepository, + refreshTokenRepository, + authProperties, + clock + ); + } + + @Nested + @DisplayName("createSession()") + class createSession { + @Test + void createSession_createsTokenFamily() { + User user = createUser(1L, "user", "user@gmail.com"); + + tokenService.createSession(user); + + verify(tokenFamilyRepository).save(familyCaptor.capture()); + verify(refreshTokenRepository).save(tokenCaptor.capture()); + + TokenFamily tokenFamily = familyCaptor.getValue(); + RefreshToken refreshToken = tokenCaptor.getValue(); + + assertSame(tokenFamily.getUser(), user); + assertSame(refreshToken.getTokenFamily(), tokenFamily); + assertEquals( + tokenFamily.getExpiresAt(), + clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis()) + ); + } + + @Test + void createSession_savesRefreshTokenCreatedAtAndExpires() { + User user = createUser(1L, "user", "user"); + + tokenService.createSession(user); + + verify(refreshTokenRepository).save(tokenCaptor.capture()); + RefreshToken refreshToken = tokenCaptor.getValue(); + + assertEquals(refreshToken.getCreatedAt(), clock.instant()); + assertEquals( + refreshToken.getExpiresAt(), + clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis()) + ); + } + + @Test + void createSession_returnsHexTokenOf64Characters() { + User user = createUser(1L, "user", "user"); + + String token = tokenService.createSession(user); + + assertEquals(64, token.length()); + assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token); + } + + @Test + void createSession_doesNotStoreRawToken() { + User user = createUser(1L, "user", "user"); + + String token = tokenService.createSession(user); + + verify(refreshTokenRepository).save(tokenCaptor.capture()); + RefreshToken refreshToken = tokenCaptor.getValue(); + + assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token)); + } + } + + // Rotate + @Nested + @DisplayName("rotate()") + class rotate { + // Input val + @Test + void nullToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(null) + ); + } + + @Test + void blankToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate("") + ); + } + + // Invalid cases + @Test + void unknownToken_throwsNotAuthenticated() { + when(refreshTokenRepository.findByHash(any())).thenReturn( + Optional.empty() + ); + + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(rawToken) + ); + } + + @Test + void revokedFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(Instant.now(clock)); + RefreshToken refreshToken = tokenIn(tokenFamily); + + clock.advance(Duration.ofMinutes(1)); + + when(refreshTokenRepository.findByHash(any())).thenReturn( + Optional.of(refreshToken) + ); + + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(rawToken) + ); + } + + // Happy Path + @Test + void unusedToken_returnsUserIdAndRefreshToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNotEquals(rawToken, rotation.newRefreshToken()); + } + + @Test + void unsedToken_savesRefreshToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + tokenService.rotate(rawToken); + + verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture()); + + RefreshToken newToken = tokenCaptor.getAllValues().stream() + .filter(t -> t.getUsedAt() == null) + .reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); }) + .orElseThrow(() -> new AssertionError("no unused token was saved")); + + assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash()); + assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner + assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one + assertNull(newToken.getUsedAt()); + } + + @Test + void ununsedToken_savesOldTokenWithUsedAt() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + tokenService.rotate(rawToken); + + verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture()); + + RefreshToken savedToken = tokenCaptor.getAllValues().stream() + .filter(t -> t.getUsedAt() != null) + .reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); }) + .orElseThrow(() -> new AssertionError("used_at field not set in any token")); + + assertEquals(savedToken.getUsedAt(), clock.instant()); + } + + @Test + void unusedToken_doesNotRevokeFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + tokenService.rotate(rawToken); + + verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture()); + RefreshToken newToken = tokenCaptor.getAllValues().stream() + .filter(t -> t.getUsedAt() == null) + .reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); }) + .orElseThrow(() -> new AssertionError("no unused token was saved")); + assertNull(newToken.getTokenFamily().getRevokedAt()); + } + + @Test + void unusedToken_newTokenIsDifferentFromOldToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setTokenHash(rawToken.getBytes()); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + assertNotEquals(rawToken, rotation.newRefreshToken()); + } + + // Grace period checks + @Test + void usedWithinGrace_returnsNewToken() { + Instant usedAt = Instant.now(clock); + + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setTokenHash(rawToken.getBytes()); + refreshToken.setUsedAt(usedAt); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + // within grace: 3s since usedAt, grace is 5s, and nothing has expired yet + clock.advance(Duration.ofSeconds(3)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNotEquals(rawToken, rotation.newRefreshToken()); + assertNull(tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + + // grace is not extended by a replay, and only the new token is written + assertEquals(usedAt, refreshToken.getUsedAt()); + + verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture()); + RefreshToken newToken = tokenCaptor.getValue(); + assertNull(newToken.getUsedAt()); + assertSame(tokenFamily, newToken.getTokenFamily()); + assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash()); + } + + @Test + void usedAtExactGraceBoundary_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(authProperties.expiration().gracePeriod()); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNull(tokenFamily.getRevokedAt()); + } + + // Reuse after grace period + @Test + void usedAfterGrace_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void usedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(tokenFamilyRepository).save(familyCaptor.capture()); + assertSame(tokenFamily, familyCaptor.getValue()); + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + } + + @Test + void usedAfterGrace_doesNotIssueNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void usedAfterGrace_subsequentRotateRejectedWithoutResaving() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + verify(tokenFamilyRepository, times(1)).save(any()); + } + + // Expiry + @Test + void expiredToken_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().minusSeconds(1)); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setExpiresAt(clock.instant().minusSeconds(1)); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredToken_doesNotSaveOrRevoke() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verify(tokenFamilyRepository, never()).save(any()); + assertNull(refreshToken.getUsedAt()); + assertNull(tokenFamily.getRevokedAt()); + } + + @Test + void tokenAtExactExpiry_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(10)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + } + + // Ordering + @Test + void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() { + TokenFamily tokenFamily = activeFamily(); + Instant revokedAt = clock.instant(); + tokenFamily.setRevokedAt(revokedAt); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(revokedAt, tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + } + + @Test + void revokedAndExpiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(clock.instant()); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + } + + @Test + void expiredAndUsedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository).save(tokenFamily); + } + + @Test + void expiredWithinGrace_doesNotRevokeFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(2)); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(3)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertNull(tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void unknownToken_doesNotTouchRepositoriesFurther() { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verifyNoInteractions(tokenFamilyRepository); + } + } + + @Nested + @DisplayName("revoke()") + class revoke { + @Test + void knownToken_revokesTokenAndFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + tokenService.revoke(rawToken); + + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + assertEquals(clock.instant(), refreshToken.getRevokedAt()); + } + + @Test + void knownToken_savesTokenAndFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + tokenService.revoke(rawToken); + + verify(tokenFamilyRepository).save(tokenFamily); + verify(refreshTokenRepository).save(refreshToken); + } + + @Test + void knownToken_looksUpBySingleSha256Hash() throws Exception { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + tokenService.revoke(rawToken); + + byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken)); + verify(refreshTokenRepository).findByHash(expectedHash); + } + + @Test + void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() { + Instant revokedAt = clock.instant(); + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(revokedAt); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setRevokedAt(revokedAt); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofMinutes(1)); + tokenService.revoke(rawToken); + + assertEquals(revokedAt, tokenFamily.getRevokedAt()); + assertEquals(revokedAt, refreshToken.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void unknownToken_doesNothing() { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + tokenService.revoke(rawToken); + + verify(refreshTokenRepository, never()).save(any()); + verifyNoInteractions(tokenFamilyRepository); + } + + @Test + void nullToken_doesNothing() { + tokenService.revoke(null); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); + } + + @Test + void blankToken_doesNothing() { + tokenService.revoke(""); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); + } + + @Test + void malformedToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.revoke("not-hex") + ); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); + } + } + + private TokenFamily activeFamily() { + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setUser(createUser(1L, "user", "user")); + tokenFamily.setExpiresAt(clock.instant().plusSeconds(10)); + return tokenFamily; + } + + private RefreshToken tokenIn(TokenFamily tokenFamily) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(10)); + return refreshToken; + } +} diff --git a/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java new file mode 100644 index 0000000..e6249ce --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java @@ -0,0 +1,295 @@ +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.ddf.vodsystem.security.TokenService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock private GoogleVerifierService googleVerifierService; + @Mock private JwtService jwtService; + @Mock private UserRepository userRepository; + @Mock private TokenService tokenService; + + @Captor ArgumentCaptor userCaptor; + + private static final String ID_TOKEN = "google-id-token"; + private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; + private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe"; + private static final String JWT = "header.payload.signature"; + private static final String GOOGLE_ID = "google-id"; + + UserService userService; + + User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + return user; + } + + GoogleUser googleUser() { + return new GoogleUser( + GOOGLE_ID, + "user@example.com", + "My User", + "https://lol.com/1" + ); + } + + @BeforeEach + void setUp() { + userService = new UserService( + userRepository, + jwtService, + googleVerifierService, + tokenService + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("getUserById()") + class getUserById { + @Test + void existingId_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Optional result = userService.getUserById(1L); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void unknownId_returnsEmpty() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertTrue(userService.getUserById(1L).isEmpty()); + } + } + + @Nested + @DisplayName("getLoggedInUser()") + class getLoggedInUser { + @Test + void authenticatedUserPrincipal_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of()) + ); + + Optional result = userService.getLoggedInUser(); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void noAuthentication_returnsEmpty() { + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void unauthenticated_returnsEmpty() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void nonUserPrincipal_returnsEmpty() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("anonymous", null, List.of()) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + } + + @Nested + @DisplayName("login()") + class login { + @Test + void newUser_savesUserToRepo() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("user@example.com", saved.getUsername()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals(0, saved.getRole()); + assertNotNull(saved.getCreatedAt()); + } + + @Test + void newUser_generatesHexStreamKeyOf48Characters() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + String streamKey = userCaptor.getValue().getStreamKey(); + assertEquals(48, streamKey.length()); + assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey); + } + + @Test + void existingUser_updatesUserInfo() { + User existingUser = createUser(1L, "old-username", "old@example.com"); + existingUser.setGoogleId(GOOGLE_ID); + existingUser.setName("Old Name"); + existingUser.setProfilePictureUrl("https://lol.com/old"); + existingUser.setStreamKey("123"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertSame(existingUser, saved); + assertEquals(1L, saved.getId()); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals("old-username", saved.getUsername()); + assertEquals("123", saved.getStreamKey()); + } + + @Test + void validToken_returnsRefreshTokenAndJwt() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN); + when(jwtService.generateToken(any())).thenReturn(JWT); + + TokenPackage tokenPackage = userService.login(ID_TOKEN); + + assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void validToken_createsSessionForSavedUser() { + User savedUser = createUser(42L, "user@example.com", "user@example.com"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenReturn(savedUser); + + userService.login(ID_TOKEN); + + verify(tokenService).createSession(savedUser); + verify(jwtService).generateToken(42L); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(googleVerifierService.verify(ID_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid ID token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.login(ID_TOKEN) + ); + + verifyNoInteractions(userRepository, tokenService, jwtService); + } + } + + @Nested + @DisplayName("refresh()") + class refresh { + @Test + void validToken_returnsRotatedTokenAndJwt() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN)); + when(jwtService.generateToken(1L)).thenReturn(JWT); + + TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN); + + assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid refresh token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.refresh(REFRESH_TOKEN) + ); + + verifyNoInteractions(jwtService); + } + } + + @Nested + @DisplayName("logout()") + class logout { + @Test + void revokesRefreshToken() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService).revoke(REFRESH_TOKEN); + } + + @Test + void doesNotIssueTokens() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService, never()).createSession(any()); + verify(tokenService, never()).rotate(any()); + verifyNoInteractions(jwtService); + } + } +} -- 2.49.1 From 4b4cc3145fdb771dff87cda33df48e68bc36fbac Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 16 Oct 2026 20:54:07 +0100 Subject: [PATCH 3/3] fix frontend --- frontend/src/auth/AuthContext.ts | 4 +- frontend/src/auth/AuthProvider.tsx | 97 ++++++++++++++++++++++++++---- frontend/src/utils/api/client.ts | 65 +++++++++++++++++--- frontend/src/utils/api/users.ts | 28 ++++++--- 4 files changed, 163 insertions(+), 31 deletions(-) diff --git a/frontend/src/auth/AuthContext.ts b/frontend/src/auth/AuthContext.ts index 9684f2e..1fa7b5d 100644 --- a/frontend/src/auth/AuthContext.ts +++ b/frontend/src/auth/AuthContext.ts @@ -8,9 +8,9 @@ type AuthContextValue = { loading: boolean; /** Exchange a Google credential for a session, then load the user. */ login: (googleCredential: string) => Promise; - /** End the session and clear the user. */ + /** End the session and clear the user. The local session is cleared even if the server call fails. */ logout: () => Promise; - /** Re-fetch the current user (e.g. after a profile change). */ + /** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */ refresh: () => Promise; }; diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index b1ee12e..97c5701 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -1,19 +1,66 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import type { User } from "../utils/types.ts"; import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts"; import { AuthContext } from "./AuthContext.ts"; -import {registerUnauthorizedHandler} from "../utils/api/client.ts"; +import { + refreshSession, + registerSessionRefreshedHandler, + registerUnauthorizedHandler, +} from "../utils/api/client.ts"; + +/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */ +const REFRESH_MARGIN_MS = 60_000; +/** How soon to try again when a scheduled refresh fails because the network is down. */ +const RETRY_DELAY_MS = 30_000; /** * Owns the single source of truth for the authenticated user. Everything that * needs to know who is logged in — the layout, the top bar, protected pages — * reads it through the `useAuth` hook rather than fetching the user itself. + * + * It also keeps the session alive: the access token is refreshed shortly + * before it expires, rather than waiting for a request to fail. That matters + * for