update docstrings
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user