add backend tokens
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-10-16 17:51:09 +01:00
parent f1e28780f0
commit 12852e034d
20 changed files with 741 additions and 117 deletions

View File

@@ -1,13 +1,16 @@
package com.ddf.vodsystem; package com.ddf.vodsystem;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync @EnableAsync
@EnableConfigurationProperties(AuthProperties.class)
public class VodSystemApplication { public class VodSystemApplication {
/** /**

View File

@@ -1,10 +1,12 @@
package com.ddf.vodsystem.configuration; package com.ddf.vodsystem.configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.web.SecurityFilterChain; 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.http.SessionCreationPolicy;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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} * 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 * @param http the HTTP security builder to configure
* @return the built {@link SecurityFilterChain} * @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/media/compress").permitAll() // compression available without authenticated
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs .requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
.requestMatchers("/api/v1/media/**").authenticated() .requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() .requestMatchers("/api/v1/users/me").authenticated()
.requestMatchers("/api/v1/users/login").permitAll() // 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() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated() .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) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
); );

View File

@@ -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();
}
}

View File

@@ -1,17 +1,21 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.SessionResponse;
import com.ddf.vodsystem.controllers.dto.Token; 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.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService; import com.ddf.vodsystem.services.UserService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest; 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.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Optional; import java.util.Optional;
@RestController @RestController
@@ -19,12 +23,18 @@ import java.util.Optional;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
@Value("${jwt.expiration}") private final Duration jwtExpiration;
private long jwtExpiration; private final Duration refreshTokenExpiration;
private static final String SUCCESS = "success"; 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.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.
* <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} wrapping the access token lifetime as a {@link SessionResponse}
* @throws NotAuthenticated if the Google ID token is rejected
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token, public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
String jwt = userService.login(token.token()); TokenPackage tokenPackage = userService.login(token.token());
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
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());
return ResponseEntity.ok( 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 refreshToken the refresh token from the {@code refreshToken} cookie, if present
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags * @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<APIResponse<SessionResponse>> 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 * @return {@code 200 OK} confirming logout
*/ */
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(
HttpServletRequest request) { @CookieValue(name = "refreshToken", required = false) String refreshToken,
ResponseCookie cookie = ResponseCookie.from("token", "") HttpServletRequest request,
.httpOnly(true) HttpServletResponse response) {
.maxAge(0) userService.logout(refreshToken);
.sameSite(request.isSecure() ? "None" : "Lax") writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO);
.secure(request.isSecure())
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged out successfully", null) 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());
}
} }

View File

@@ -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
) {}

View File

@@ -0,0 +1,9 @@
package com.ddf.vodsystem.dto;
public record GoogleUser(
String googleId,
String email,
String name,
String profilePictureUrl
) {
}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.dto;
public record TokenPackage (
String refreshToken,
String accessToken
) {
}

View File

@@ -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
) {}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
Optional<RefreshToken> findByHash(byte[] tokenHash);
}

View File

@@ -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<TokenFamily, Long> {
/**
* 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);
}

View File

@@ -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();
}
}

View File

@@ -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")
);
}
}

View File

@@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter {
Cookie[] cookies = request.getCookies(); Cookie[] cookies = request.getCookies();
if (cookies != null) { if (cookies != null) {
jwt = Arrays.stream(cookies) jwt = Arrays.stream(cookies)
.filter(cookie -> "token".equals(cookie.getName())) .filter(cookie -> "accessToken".equals(cookie.getName()))
.map(Cookie::getValue) .map(Cookie::getValue)
.findFirst() .findFirst()
.orElse(null); .orElse(null);

View File

@@ -7,6 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException; import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.DecodedJWT;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,10 +21,9 @@ public class JwtService {
private static final String USER_ID_CLAIM = "userId"; private static final String USER_ID_CLAIM = "userId";
private static final String ISSUER = "vodsystem"; private static final String ISSUER = "vodsystem";
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey, public JwtService(AuthProperties props) {
@Value("${jwt.expiration}") long jwtExpiration) { this.jwtExpiration = props.expiration().jwt().toMillis();
this.jwtExpiration = jwtExpiration; this.algorithm = Algorithm.HMAC256(props.jwtSecret());
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
} }
/** /**

View File

@@ -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.
* <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
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.
* <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)
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<RefreshToken> 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.
* <p>
* 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);
}
}

View File

@@ -1,47 +1,44 @@
package com.ddf.vodsystem.services; 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.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository; import com.ddf.vodsystem.repositories.UserRepository;
import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService; import com.ddf.vodsystem.security.JwtService;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.ddf.vodsystem.security.TokenService;
import com.google.api.client.http.javanet.NetHttpTransport; import org.slf4j.Logger;
import com.google.api.client.json.JsonFactory; import org.slf4j.LoggerFactory;
import com.google.api.client.json.gson.GsonFactory; import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.Collections;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UserService { 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 UserRepository userRepository;
private final JwtService jwtService; private final JwtService jwtService;
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final TokenService tokenService;
public UserService(UserRepository userRepository, public UserService(UserRepository userRepository,
JwtService jwtService, JwtService jwtService,
@Value("${google.client.id}") String googleClientId) { GoogleVerifierService googleVerifierService,
TokenService tokenService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.googleVerifierService = googleVerifierService;
NetHttpTransport transport = new NetHttpTransport();
JsonFactory jsonFactory = new GsonFactory();
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
.setAudience(Collections.singletonList(googleClientId))
.build();
this.jwtService = jwtService; 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.
* <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
*/ */
public String login(String idToken) { @Transactional
GoogleIdToken googleIdToken = getGoogleIdToken(idToken); public TokenPackage login(String idToken) {
String googleId = googleIdToken.getPayload().getSubject(); GoogleUser googleUser = googleVerifierService.verify(idToken);
User user = createOrUpdateGoogleUser(googleUser);
if (googleId == null) { String refreshToken = tokenService.createSession(user);
throw new NotAuthenticated("Invalid ID token"); String jwt = jwtService.generateToken(user.getId());
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
return new TokenPackage(refreshToken, jwt);
} }
User googleUser = getGoogleUser(googleIdToken); /**
User user = createOrUpdateUser(googleUser); * 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());
return jwtService.generateToken(user.getId()); 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()");
}
} }
/** /**
@@ -98,51 +129,30 @@ public class UserService {
return userRepository.findByStreamKey(streamKey); return userRepository.findByStreamKey(streamKey);
} }
private User createOrUpdateUser(User user) { private User createOrUpdateGoogleUser(GoogleUser googleUser) {
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId()); Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId());
User user;
if (existingUser.isEmpty()) { if (existingUser.isEmpty()) {
user = new User();
user.setGoogleId(googleUser.googleId());
user.setUsername(googleUser.email());
user.setRole(0); user.setRole(0);
user.setCreatedAt(Instant.now()); user.setCreatedAt(Instant.now());
user.setStreamKey(generateStreamKey()); user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
return userRepository.saveAndFlush(user); } else {
user = existingUser.get();
} }
User existing = existingUser.get(); user.setEmail(googleUser.email());
existing.setEmail(user.getEmail()); user.setName(googleUser.name());
existing.setName(user.getName()); user.setProfilePictureUrl(googleUser.profilePictureUrl());
existing.setProfilePictureUrl(user.getProfilePictureUrl()); return userRepository.save(user);
existing.setUsername(user.getUsername());
return userRepository.saveAndFlush(existing);
} }
private User getGoogleUser(GoogleIdToken idToken) { private byte[] generateRandomBytes(int length) {
String googleId = idToken.getPayload().getSubject(); byte[] bytes = new byte[length];
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];
SECURE_RANDOM.nextBytes(bytes); SECURE_RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes); return bytes;
} }
} }

View File

@@ -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-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email spring.security.oauth2.client.registration.google.scope=profile,email
jwt.secret.key=${JWT_SECRET_KEY} app.security.jwt-secret=${JWT_SECRET_KEY}
jwt.expiration=3600000
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
frontend.url=${FRONTEND_URL} frontend.url=${FRONTEND_URL}

View File

@@ -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);