update docstrings
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-10-15 20:51:25 +01:00
parent 40e6d6635c
commit 954a3b7f6e
3 changed files with 89 additions and 10 deletions

View File

@@ -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.
* <p> * <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also * Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
* returned in the response body. * (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 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 request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the session cookie is written to * @param response the response the cookies are written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token} * @return {@code 200 OK} with no data
* @throws NotAuthenticated if the Google ID token is rejected
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Void>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Void>> 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") @PostMapping("/refresh")
public ResponseEntity<APIResponse<Void>> refresh( public ResponseEntity<APIResponse<Void>> refresh(
@RequestBody Token token, @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 * @return {@code 200 OK} confirming logout
*/ */
@PostMapping("/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( private void addCookies(
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response, HttpServletResponse response,

View File

@@ -19,6 +19,13 @@ import java.time.Instant;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
/**
* Issues, rotates and revokes opaque refresh tokens.
* <p>
* 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 @Service
public class TokenService { public class TokenService {
private static final SecureRandom RNG = new SecureRandom(); private static final SecureRandom RNG = new SecureRandom();
@@ -33,6 +40,12 @@ public class TokenService {
private final Duration refreshTokenExpiration; private final Duration refreshTokenExpiration;
private final Duration refreshGracePeriod; 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 ( public TokenService (
TokenFamilyRepository tokenFamilyRepository, TokenFamilyRepository tokenFamilyRepository,
RefreshTokenRepository refreshTokenRepository, RefreshTokenRepository refreshTokenRepository,
@@ -48,6 +61,12 @@ public class TokenService {
this.refreshGracePeriod = props.expiration().gracePeriod(); 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 @Transactional
public String createSession(User user) { public String createSession(User user) {
Instant now = Instant.now(clock); Instant now = Instant.now(clock);
@@ -68,6 +87,19 @@ public class TokenService {
return HEX.formatHex(token); return HEX.formatHex(token);
} }
/**
* Exchanges a refresh token for a new one in the same family.
* <p>
* 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) @Transactional(noRollbackFor = NotAuthenticated.class)
public Rotation rotate(String hexToken) { public Rotation rotate(String hexToken) {
if (hexToken == null || hexToken.isBlank()) { if (hexToken == null || hexToken.isBlank()) {
@@ -124,6 +156,13 @@ public class TokenService {
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); 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) { public void revoke(String hexToken) {
Instant now = Instant.now(clock); Instant now = Instant.now(clock);
@@ -144,6 +183,12 @@ public class TokenService {
if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); 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) {} public record Rotation(Long userId, String newRefreshToken) {}
private static byte[] sha256(byte[] in) { private static byte[] sha256(byte[] in) {

View File

@@ -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.
* <p>
* 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 * @param idToken the Google ID token to verify
* @return a signed JWT for the authenticated user * @return a new refresh token and a signed JWT access token for the user
* @throws NotAuthenticated if the token is invalid or its subject is missing * @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified
*/ */
@Transactional @Transactional
public TokenPackage login(String idToken) { public TokenPackage login(String idToken) {
@@ -78,6 +81,13 @@ public class UserService {
return new TokenPackage(refreshToken, jwt); 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) { public TokenPackage refresh(String refreshToken) {
TokenService.Rotation rotation = tokenService.rotate(refreshToken); TokenService.Rotation rotation = tokenService.rotate(refreshToken);
String jwt = jwtService.generateToken(rotation.userId()); String jwt = jwtService.generateToken(rotation.userId());
@@ -85,6 +95,13 @@ public class UserService {
return new TokenPackage(rotation.newRefreshToken(), jwt); 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) { public void logout(String refreshToken) {
tokenService.revoke(refreshToken); tokenService.revoke(refreshToken);
} }