diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index a8bd579..c0e4241 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -56,15 +56,16 @@ 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} with no data + * @throws NotAuthenticated if the Google ID token is rejected */ @PostMapping("/login") public ResponseEntity> login(@RequestBody Token token, @@ -78,6 +79,15 @@ public class UserController { ); } + /** + * Exchanges a refresh token for a new refresh token and JWT, replacing both cookies. + * + * @param token the payload carrying the current refresh token + * @param response the response the new cookies are written to + * @param request the incoming request, used to decide the cookies' {@code Secure} flag + * @return {@code 200 OK} with no data + * @throws NotAuthenticated if the refresh token is rejected + */ @PostMapping("/refresh") public ResponseEntity> refresh( @RequestBody Token token, @@ -92,8 +102,10 @@ public class UserController { } /** - * Logs the user out by clearing the session cookie. + * Logs the user out by revoking the session tied to the {@code refreshToken} cookie. + * The auth cookies are not cleared. * + * @param refreshToken the refresh token from the {@code refreshToken} cookie, if present * @return {@code 200 OK} confirming logout */ @PostMapping("/logout") @@ -106,6 +118,11 @@ public class UserController { ); } + /** + * Writes the {@code accessToken} cookie ({@code SameSite=Lax}, path {@code /}) and the + * {@code refreshToken} cookie ({@code SameSite=Strict}, path {@code /api/v1/users}), both HttpOnly + * and {@code Secure} when the request is. + */ private void addCookies( HttpServletRequest request, HttpServletResponse response, diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 222a134..1442ff6 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -19,6 +19,13 @@ 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 SecureRandom RNG = new SecureRandom(); @@ -33,6 +40,12 @@ public class TokenService { 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, @@ -48,6 +61,12 @@ public class TokenService { 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); @@ -68,6 +87,19 @@ public class TokenService { 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()) { @@ -124,6 +156,13 @@ public class TokenService { 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 unknown, and leaves existing revocation timestamps unchanged. + * + * @param hexToken the raw refresh token as a hex string + * @throws NotAuthenticated if the token is not valid hex + */ public void revoke(String hexToken) { Instant now = Instant.now(clock); @@ -144,6 +183,12 @@ public class TokenService { if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); } + /** + * 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) { diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index ff91495..801f775 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -61,11 +61,14 @@ 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 */ @Transactional public TokenPackage login(String idToken) { @@ -78,6 +81,13 @@ public class UserService { 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()); @@ -85,6 +95,13 @@ public class UserService { 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. + * + * @param refreshToken the raw refresh token as a hex string + * @throws NotAuthenticated if the token is not valid hex + */ public void logout(String refreshToken) { tokenService.revoke(refreshToken); }