15 Commits

Author SHA1 Message Date
19a5a49cbb add rotate tests
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
2026-10-10 18:41:45 +01:00
4c7c2f0916 add rotate tests
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
2026-10-09 17:44:30 +01:00
4b5e6ed8f6 add some createSession tests
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
2026-10-08 19:51:44 +01:00
e2a2a60075 add clock dependency injection
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
2026-10-07 19:03:47 +01:00
8166c62bb1 fix reused token within grace still throwing
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
2026-10-07 18:45:27 +01:00
40fc80537b add lock to read by Hash
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
2026-10-07 18:31:47 +01:00
11115a435a extract rotation logic to TokenService
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
2026-10-05 19:30:50 +01:00
cbb8c2195f extract Google logic into separate adapter
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-04 14:54:23 +01:00
05361409b2 wip
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
2026-10-03 21:08:32 +01:00
c533257e98 add refresh method & update login to use refresh tokens
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
2026-10-03 02:05:24 +01:00
cc3765c284 add repos and services (wip)
Some checks failed
CI / Maven unit tests (push) Failing after 12s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-02 23:07:10 +01:00
0c04bf4c97 adjust schema slightly
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
2026-10-01 22:17:24 +01:00
d7fb8ffb91 add refresh token tables & entities
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-01 21:56:02 +01:00
f1e28780f0 chore(db): remove flyway baseline migration property
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 1m33s
CI / Deploy to server (push) Successful in 9s
2026-09-29 19:36:22 +01:00
daa6c04f76 chore(db): add flyway migration tool (#48)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 4m1s
CI / Deploy to server (push) Successful in 24s
Reviewed-on: #48
2026-09-29 19:26:45 +01:00
21 changed files with 797 additions and 84 deletions

10
pom.xml
View File

@@ -90,6 +90,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Database Migration Tools -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -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 {
/**

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

@@ -2,6 +2,7 @@ package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService;
@@ -19,7 +20,7 @@ import java.util.Optional;
public class UserController {
private final UserService userService;
@Value("${jwt.expiration}")
@Value("${app.security.expiration.jwt}")
private long jwtExpiration;
private static final String SUCCESS = "success";
@@ -58,12 +59,12 @@ public class UserController {
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/
@PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
String jwt = userService.login(token.token());
public ResponseEntity<APIResponse<TokenPackage>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
TokenPackage tokenPackage = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt)
ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken())
.httpOnly(true)
.maxAge(jwtExpiration / 1000)
.sameSite(request.isSecure() ? "None" : "Lax")
@@ -74,7 +75,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage)
);
}

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,10 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.TokenFamily;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
}

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

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

View File

@@ -0,0 +1,157 @@
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.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;
@Service
public class TokenService {
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;
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();
}
@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);
}
@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));
}
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,40 @@
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.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 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;
}
/**
@@ -74,18 +67,22 @@ public class UserService {
* @return a signed JWT for the authenticated user
* @throws NotAuthenticated if the token is invalid or its subject is missing
*/
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());
User googleUser = getGoogleUser(googleIdToken);
User user = createOrUpdateUser(googleUser);
return new TokenPackage(refreshToken, jwt);
}
return jwtService.generateToken(user.getId());
public TokenPackage refresh(String refreshToken) {
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
String jwt = jwtService.generateToken(rotation.userId());
return new TokenPackage(rotation.newRefreshToken(), jwt);
}
/**
@@ -98,51 +95,30 @@ public class UserService {
return userRepository.findByStreamKey(streamKey);
}
private User createOrUpdateUser(User user) {
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
private User createOrUpdateGoogleUser(GoogleUser googleUser) {
Optional<User> 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;
}
}

View File

@@ -10,17 +10,26 @@ spring.datasource.url=${POSTGRES_URL}
spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:db/schema.sql
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migrations
## Security
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}
# 1 Hour
app.security.expiration.jwt=3600000
# 7 days
app.security.expiration.refresh-token=604800000
# 90 days
app.security.expiration.token-family=7776000000
# 5 seconds
app.security.expiration.grace-period=5000
# Frontend
frontend.url=${FRONTEND_URL}

View File

@@ -1,7 +1,4 @@
-- DROP TABLE IF EXISTS streams;
-- DROP TABLE IF EXISTS clips;
-- DROP TABLE IF EXISTS users;
-- Users Table
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
google_id VARCHAR(64),
@@ -14,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
stream_key VARCHAR(64) NOT NULL UNIQUE
);
-- Streams Table
CREATE TABLE IF NOT EXISTS streams (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
@@ -24,6 +23,7 @@ CREATE TABLE IF NOT EXISTS streams (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Clips Table
CREATE TABLE IF NOT EXISTS clips (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS clips (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VoDs Table
CREATE TABLE IF NOT EXISTS vods (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS vods (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Markers Table
CREATE TABLE IF NOT EXISTS markers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,

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

View File

@@ -0,0 +1,286 @@
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.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<TokenFamily> familyCaptor;
@Captor ArgumentCaptor<RefreshToken> 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(10)
)
);
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 = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setRevokedAt(Instant.now(clock));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(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 = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
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 = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
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 = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
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());
}
}
}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.services;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
}