From d7fb8ffb9129a2c7695e9f26f63915454cf7bd58 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 1 Oct 2026 21:56:02 +0100 Subject: [PATCH 01/25] add refresh token tables & entities --- .../ddf/vodsystem/entities/RefreshToken.java | 35 +++++++++++++++++++ .../ddf/vodsystem/entities/TokenFamily.java | 25 +++++++++++++ .../db/migrations/V2__refresh_tokens.sql | 31 ++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/main/java/com/ddf/vodsystem/entities/RefreshToken.java create mode 100644 src/main/java/com/ddf/vodsystem/entities/TokenFamily.java create mode 100644 src/main/resources/db/migrations/V2__refresh_tokens.sql diff --git a/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java new file mode 100644 index 0000000..4e9a32e --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java @@ -0,0 +1,35 @@ +package com.ddf.vodsystem.entities; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.Instant; + +@Entity +@Table(name = "refresh_tokens") +@Data +public class RefreshToken { + @Id + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "family_id", nullable = false) + private TokenFamily tokenFamily; + + @Column(name = "token_hash", unique = true, nullable = false) + private byte[] tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} diff --git a/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java new file mode 100644 index 0000000..407bd16 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java @@ -0,0 +1,25 @@ +package com.ddf.vodsystem.entities; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.Instant; + +@Entity +@Table(name = "token_families") +@Data +public class TokenFamily { + @Id + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; +} diff --git a/src/main/resources/db/migrations/V2__refresh_tokens.sql b/src/main/resources/db/migrations/V2__refresh_tokens.sql new file mode 100644 index 0000000..bb20b1d --- /dev/null +++ b/src/main/resources/db/migrations/V2__refresh_tokens.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS token_families +( + id BIGSERIAL PRIMARY KEY NOT NULl, + user_id BIGSERIAL NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_token_families_user_id + ON token_families (user_id); + +CREATE TABLE IF NOT EXISTS refresh_tokens +( + id BIGSERIAL PRIMARY KEY NOT NULL, + user_id BIGSERIAL NOT NULL, + family_id BIGSERIAL NOT NULL, + token_hash BYTEA NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + + CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32), + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (family_id) REFERENCES token_families(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id + ON refresh_tokens (family_id); -- 2.49.1 From 0c04bf4c97b60972190da39b09fa6a428fbfea51 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 1 Oct 2026 22:17:24 +0100 Subject: [PATCH 02/25] adjust schema slightly --- .../ddf/vodsystem/entities/RefreshToken.java | 12 +++++----- .../ddf/vodsystem/entities/TokenFamily.java | 6 +++++ .../db/migrations/V2__refresh_tokens.sql | 22 ++++++++----------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java index 4e9a32e..96d7e81 100644 --- a/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java +++ b/src/main/java/com/ddf/vodsystem/entities/RefreshToken.java @@ -2,6 +2,7 @@ package com.ddf.vodsystem.entities; import jakarta.persistence.*; import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @@ -11,19 +12,20 @@ import java.time.Instant; public class RefreshToken { @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; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "family_id", nullable = false) private TokenFamily tokenFamily; - @Column(name = "token_hash", unique = true, nullable = false) + @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; diff --git a/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java index 407bd16..ee2311b 100644 --- a/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java +++ b/src/main/java/com/ddf/vodsystem/entities/TokenFamily.java @@ -2,6 +2,7 @@ package com.ddf.vodsystem.entities; import jakarta.persistence.*; import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @@ -11,12 +12,17 @@ import java.time.Instant; 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; diff --git a/src/main/resources/db/migrations/V2__refresh_tokens.sql b/src/main/resources/db/migrations/V2__refresh_tokens.sql index bb20b1d..2fede26 100644 --- a/src/main/resources/db/migrations/V2__refresh_tokens.sql +++ b/src/main/resources/db/migrations/V2__refresh_tokens.sql @@ -1,11 +1,10 @@ CREATE TABLE IF NOT EXISTS token_families ( - id BIGSERIAL PRIMARY KEY NOT NULl, - user_id BIGSERIAL NOT NULL, + 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, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_token_families_user_id @@ -13,18 +12,15 @@ CREATE INDEX IF NOT EXISTS idx_token_families_user_id CREATE TABLE IF NOT EXISTS refresh_tokens ( - id BIGSERIAL PRIMARY KEY NOT NULL, - user_id BIGSERIAL NOT NULL, - family_id BIGSERIAL NOT NULL, - token_hash BYTEA NOT NULL, + 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), - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (family_id) REFERENCES token_families(id) ON DELETE CASCADE + CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32) ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id -- 2.49.1 From cc3765c284057cf414200545e87537a69b8ffae2 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 2 Oct 2026 23:07:10 +0100 Subject: [PATCH 03/25] add repos and services (wip) --- .../com/ddf/vodsystem/dto/TokenPackage.java | 7 +++ .../repositories/RefreshTokenRepository.java | 10 +++ .../repositories/TokenFamilyRepository.java | 9 +++ .../ddf/vodsystem/security/TokenService.java | 63 +++++++++++++++++++ .../ddf/vodsystem/services/UserService.java | 28 +++++++-- src/main/resources/application.properties | 5 ++ 6 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/dto/TokenPackage.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/security/TokenService.java diff --git a/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java b/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java new file mode 100644 index 0000000..315f466 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/TokenPackage.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.dto; + +public record TokenPackage ( + String refreshToken, + String accessToken +) { +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java new file mode 100644 index 0000000..eead9cf --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java new file mode 100644 index 0000000..9fbbf90 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java @@ -0,0 +1,9 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.TokenFamily; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TokenFamilyRepository extends JpaRepository { +} diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java new file mode 100644 index 0000000..0294e08 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -0,0 +1,63 @@ +package com.ddf.vodsystem.security; + +import com.ddf.vodsystem.entities.RefreshToken; +import com.ddf.vodsystem.entities.TokenFamily; +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.repositories.RefreshTokenRepository; +import com.ddf.vodsystem.repositories.TokenFamilyRepository; +import org.springframework.stereotype.Service; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; + +@Service +public class TokenService { + private final TokenFamilyRepository tokenFamilyRepository; + private final RefreshTokenRepository refreshTokenRepository; + + private static final SecureRandom RNG = new SecureRandom(); + + public TokenService ( + TokenFamilyRepository tokenFamilyRepository, + RefreshTokenRepository refreshTokenRepository + ) { + this.tokenFamilyRepository = tokenFamilyRepository; + this.refreshTokenRepository = refreshTokenRepository; + } + + public TokenFamily createTokenFamily(User user, long expiryTimeMs) { + Instant now = Instant.now(); + + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setUser(user); + tokenFamily.setCreatedAt(now); + tokenFamily.setExpiresAt(now.plusMillis(expiryTimeMs)); + + return tokenFamilyRepository.saveAndFlush(tokenFamily); + } + + public RefreshToken getRefreshToken( + byte[] token, + TokenFamily tokenFamily, + long expiryTimeMs + ) { + Instant now = Instant.now(); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setTokenHash(hashToken(token)); + refreshToken.setCreatedAt(now); + refreshToken.setExpiresAt(now.plusMillis(expiryTimeMs)); + return refreshTokenRepository.saveAndFlush(refreshToken); + } + + private static byte[] hashToken(byte[] token) { + try { + return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index f29d16c..acd40d1 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -1,9 +1,12 @@ package com.ddf.vodsystem.services; +import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.UserRepository; import com.ddf.vodsystem.security.JwtService; +import com.ddf.vodsystem.security.TokenService; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.JsonFactory; @@ -20,6 +23,7 @@ import java.security.SecureRandom; import java.time.Instant; import java.util.Collections; import java.util.HexFormat; +import java.util.MissingResourceException; import java.util.Optional; @Service @@ -29,10 +33,12 @@ public class UserService { 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) { + @Value("${google.client.id}") String googleClientId, + TokenService tokenService) { this.userRepository = userRepository; NetHttpTransport transport = new NetHttpTransport(); @@ -42,6 +48,7 @@ public class UserService { .setAudience(Collections.singletonList(googleClientId)) .build(); this.jwtService = jwtService; + this.tokenService = tokenService; } /** @@ -85,9 +92,20 @@ public class UserService { User googleUser = getGoogleUser(googleIdToken); User user = createOrUpdateUser(googleUser); + TokenFamily tokenFamily = tokenService.createTokenFamily(user, ) + String jwt = jwtService.generateToken(user.getId()); + + return jwtService.generateToken(user.getId()); } + public TokenPackage refresh(String refreshToken) { + if (refreshToken == null) { + throw new NotAuthenticated("Missing refresh token"); + } + + } + /** * Looks up a user by their stream key. * @@ -104,7 +122,7 @@ public class UserService { if (existingUser.isEmpty()) { user.setRole(0); user.setCreatedAt(Instant.now()); - user.setStreamKey(generateStreamKey()); + user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24))); return userRepository.saveAndFlush(user); } @@ -140,9 +158,9 @@ public class UserService { } } - 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; } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ff5b7a..1109b33 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -21,7 +21,12 @@ 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} +# 1 Hour jwt.expiration=3600000 +# 7 days +refresh-token.expiration=604800000 +# 90 days +token-family.expiration=7776000000 # Frontend frontend.url=${FRONTEND_URL} -- 2.49.1 From c533257e9884229a83576ce95db2ca46e7a1c0cc Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 3 Oct 2026 02:05:24 +0100 Subject: [PATCH 04/25] add refresh method & update login to use refresh tokens --- .../vodsystem/controllers/UserController.java | 13 ++-- .../repositories/RefreshTokenRepository.java | 7 +- .../repositories/TokenFamilyRepository.java | 1 + .../ddf/vodsystem/security/TokenService.java | 29 +++++++- .../ddf/vodsystem/services/UserService.java | 68 +++++++++++++++++-- 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 815e8bd..47baf7a 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -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; @@ -58,12 +59,12 @@ public class UserController { * @return {@code 200 OK} wrapping the issued JWT as a {@link Token} */ @PostMapping("/login") - public ResponseEntity> login(@RequestBody Token token, - HttpServletRequest request, - HttpServletResponse response) { - String jwt = userService.login(token.token()); + public ResponseEntity> 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) ); } diff --git a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java index eead9cf..4b925cd 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java @@ -2,9 +2,14 @@ package com.ddf.vodsystem.repositories; import com.ddf.vodsystem.entities.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.sql.Ref; +import java.util.Optional; + @Repository public interface RefreshTokenRepository extends JpaRepository { - + @Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1") + Optional findByHash(byte[] tokenHash); } diff --git a/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java index 9fbbf90..a0ec109 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/TokenFamilyRepository.java @@ -2,6 +2,7 @@ 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 diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 0294e08..30615ef 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -5,12 +5,15 @@ import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.repositories.RefreshTokenRepository; import com.ddf.vodsystem.repositories.TokenFamilyRepository; +import org.apache.commons.codec.binary.Hex; import org.springframework.stereotype.Service; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Instant; +import java.util.HexFormat; +import java.util.Optional; @Service public class TokenService { @@ -18,6 +21,7 @@ public class TokenService { private final RefreshTokenRepository refreshTokenRepository; private static final SecureRandom RNG = new SecureRandom(); + private final HexFormat hex = HexFormat.of(); public TokenService ( TokenFamilyRepository tokenFamilyRepository, @@ -38,7 +42,7 @@ public class TokenService { return tokenFamilyRepository.saveAndFlush(tokenFamily); } - public RefreshToken getRefreshToken( + public RefreshToken createRefreshToken( byte[] token, TokenFamily tokenFamily, long expiryTimeMs @@ -53,7 +57,28 @@ public class TokenService { return refreshTokenRepository.saveAndFlush(refreshToken); } - private static byte[] hashToken(byte[] token) { + public Optional getRefreshTokenByHash(byte[] refreshHash) { + return refreshTokenRepository.findByHash(refreshHash); + } + + public Optional getFamilyByRefreshToken(RefreshToken refreshToken) { + return tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); + } + + public TokenFamily revokeTokenFamily(TokenFamily tokenFamily) { + tokenFamily.setRevokedAt(Instant.now()); + return tokenFamilyRepository.saveAndFlush(tokenFamily); + } + + public String bytesToHex(byte[] bytes) { + return hex.formatHex(bytes); + } + + public byte[] hexToBytes(String hexS) { + return hex.parseHex(hexS); + } + + public byte[] hashToken(byte[] token) { try { return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes } catch (NoSuchAlgorithmException e) { diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index acd40d1..fc3354c 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.entities.RefreshToken; import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; @@ -11,6 +12,7 @@ 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.transaction.annotation.Transactional; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -20,10 +22,10 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.util.Collections; import java.util.HexFormat; -import java.util.MissingResourceException; import java.util.Optional; @Service @@ -32,7 +34,14 @@ public class UserService { private final UserRepository userRepository; private final JwtService jwtService; + @Value("token-family.expiration") + private long tokenFamilyExpirationMs; + + @Value("refresh-token.expiration") + private long refreshTokenExpirationMs; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int REFRESH_GRACE_PERIOD_SECONDS = 5; private final TokenService tokenService; public UserService(UserRepository userRepository, @@ -81,7 +90,8 @@ 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) { + @Transactional + public TokenPackage login(String idToken) { GoogleIdToken googleIdToken = getGoogleIdToken(idToken); String googleId = googleIdToken.getPayload().getSubject(); @@ -92,18 +102,62 @@ public class UserService { User googleUser = getGoogleUser(googleIdToken); User user = createOrUpdateUser(googleUser); - TokenFamily tokenFamily = tokenService.createTokenFamily(user, ) + byte[] rawRefresh = generateRandomBytes(32); + TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs); + tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs); + String jwt = jwtService.generateToken(user.getId()); - - - return jwtService.generateToken(user.getId()); + return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt); } + @Transactional(noRollbackFor = NotAuthenticated.class) public TokenPackage refresh(String refreshToken) { - if (refreshToken == null) { + if (refreshToken == null || refreshToken.isBlank()) { throw new NotAuthenticated("Missing refresh token"); } + // Check Refresh Token is real + byte[] rawToken = tokenService.hexToBytes(refreshToken); + RefreshToken token = tokenService.getRefreshTokenByHash(rawToken) + .orElseThrow(() -> new NotAuthenticated("No such refresh token")); + + // Find Token Family + Optional tokenFamily = tokenService.getFamilyByRefreshToken(token); + + if (tokenFamily.isEmpty()) { + throw new IllegalStateException("Token must be tied to a family"); + } + + if (tokenFamily.get().getRevokedAt() != null) { + throw new NotAuthenticated("Token family has been revoked"); + } + + Instant now = Instant.now(); + + // Reuse Check + if (token.getUsedAt() != null) { + if (Duration.between(token.getUsedAt(), now).getSeconds() > REFRESH_GRACE_PERIOD_SECONDS) { + tokenService.revokeTokenFamily(tokenFamily.get()); + } + throw new NotAuthenticated("Refresh token already used"); + } + + // Expiry Check + if (now.isAfter(token.getExpiresAt()) || now.isAfter(tokenFamily.get().getExpiresAt())) { + throw new NotAuthenticated("Refresh token or family expired"); + } + + // Rotate + token.setUsedAt(now); + byte[] newRawToken = generateRandomBytes(32); + tokenService.createRefreshToken( + newRawToken, + tokenFamily.get(), + refreshTokenExpirationMs + ); + + String jwt = jwtService.generateToken(tokenFamily.get().getUser().getId()); + return new TokenPackage(tokenService.bytesToHex(newRawToken), jwt); } /** -- 2.49.1 From 05361409b2c75b304afa4f8fd07b69f4afd6bb6f Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 3 Oct 2026 21:08:32 +0100 Subject: [PATCH 05/25] wip --- .../ddf/vodsystem/VodSystemApplication.java | 3 ++ .../vodsystem/controllers/UserController.java | 2 +- .../dto/properties/AuthProperties.java | 23 +++++++++++++++ .../vodsystem/security/GoogleAuthConfig.java | 28 +++++++++++++++++++ .../ddf/vodsystem/security/JwtService.java | 8 +++--- .../ddf/vodsystem/services/UserService.java | 27 ++++++------------ src/main/resources/application.properties | 8 +++--- .../vodsystem/services/UserServiceTest.java | 7 +++++ 8 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java create mode 100644 src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java create mode 100644 src/test/java/com/ddf/vodsystem/services/UserServiceTest.java diff --git a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java index ff02ad6..7f26e6c 100644 --- a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java +++ b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java @@ -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 { /** diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 47baf7a..d776076 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -20,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"; diff --git a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java new file mode 100644 index 0000000..ea4c3af --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java @@ -0,0 +1,23 @@ +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 +) { + public record Expiration ( + @NotNull Duration jwt, + @NotNull Duration refreshToken, + @NotNull Duration tokenFamily + ) {} +} diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java b/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java new file mode 100644 index 0000000..6edde2f --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/GoogleAuthConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/security/JwtService.java b/src/main/java/com/ddf/vodsystem/security/JwtService.java index ce8bfb5..df2a05e 100644 --- a/src/main/java/com/ddf/vodsystem/security/JwtService.java +++ b/src/main/java/com/ddf/vodsystem/security/JwtService.java @@ -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()); } /** diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index fc3354c..d53ab70 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.dto.TokenPackage; +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; @@ -9,11 +10,7 @@ import com.ddf.vodsystem.repositories.UserRepository; import com.ddf.vodsystem.security.JwtService; import com.ddf.vodsystem.security.TokenService; 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.transaction.annotation.Transactional; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; @@ -24,7 +21,6 @@ import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; -import java.util.Collections; import java.util.HexFormat; import java.util.Optional; @@ -34,11 +30,8 @@ public class UserService { private final UserRepository userRepository; private final JwtService jwtService; - @Value("token-family.expiration") - private long tokenFamilyExpirationMs; - - @Value("refresh-token.expiration") - private long refreshTokenExpirationMs; + private final long tokenFamilyExpirationMs; + private final long refreshTokenExpirationMs; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final int REFRESH_GRACE_PERIOD_SECONDS = 5; @@ -46,18 +39,16 @@ public class UserService { public UserService(UserRepository userRepository, JwtService jwtService, - @Value("${google.client.id}") String googleClientId, + GoogleIdTokenVerifier verifier, + AuthProperties props, 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.verifier = verifier; this.jwtService = jwtService; this.tokenService = tokenService; + + this.refreshTokenExpirationMs = props.expiration().refreshToken().toMillis(); + this.tokenFamilyExpirationMs = props.expiration().tokenFamily().toMillis(); } /** diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1109b33..380feb4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -20,13 +20,13 @@ spring.flyway.locations=classpath:db/migrations 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} +app.security.jwt-secret=${JWT_SECRET_KEY} # 1 Hour -jwt.expiration=3600000 +app.security.expiration.jwt=3600000 # 7 days -refresh-token.expiration=604800000 +app.security.expiration.refresh-token=604800000 # 90 days -token-family.expiration=7776000000 +app.security.expiration.token-family=7776000000 # Frontend frontend.url=${FRONTEND_URL} diff --git a/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java new file mode 100644 index 0000000..0b5eb3e --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.services; + +import static org.junit.jupiter.api.Assertions.*; + +class UserServiceTest { + +} \ No newline at end of file -- 2.49.1 From cbb8c2195f77705acae7d5454375a6661bf3e0c5 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 4 Oct 2026 14:54:23 +0100 Subject: [PATCH 06/25] extract Google logic into separate adapter --- .../com/ddf/vodsystem/dto/GoogleUser.java | 9 +++ .../dto/properties/AuthProperties.java | 10 ++- .../security/GoogleVerifierService.java | 41 +++++++++++ .../ddf/vodsystem/services/UserService.java | 68 ++++++------------- src/main/resources/application.properties | 3 + 5 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/dto/GoogleUser.java create mode 100644 src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java diff --git a/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java b/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java new file mode 100644 index 0000000..68beaa7 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/GoogleUser.java @@ -0,0 +1,9 @@ +package com.ddf.vodsystem.dto; + +public record GoogleUser( + String googleId, + String email, + String name, + String profilePictureUrl +) { +} diff --git a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java index ea4c3af..194b40f 100644 --- a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java +++ b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java @@ -15,9 +15,17 @@ public record AuthProperties ( @NotBlank @Size(min = 32) String jwtSecret, @Valid @NotNull Expiration expiration ) { + + /** + * + * @param jwt Duration object as to the length of the access token + * @param refreshToken Duration of refresh token + * @param tokenFamily Duration of token family + */ public record Expiration ( @NotNull Duration jwt, @NotNull Duration refreshToken, - @NotNull Duration tokenFamily + @NotNull Duration tokenFamily, + @NotNull Duration gracePeriod ) {} } diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java new file mode 100644 index 0000000..e3497db --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java @@ -0,0 +1,41 @@ +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()); + } + + String googleId = token.getPayload().getSubject(); + + if (googleId == null) { + throw new NotAuthenticated("Google ID does not exist"); + } + + return new GoogleUser( + googleId, + token.getPayload().getEmail(), + (String) token.getPayload().get("name"), + (String) token.getPayload().get("picture") + ); + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index d53ab70..6dcb332 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -1,5 +1,6 @@ package com.ddf.vodsystem.services; +import com.ddf.vodsystem.dto.GoogleUser; import com.ddf.vodsystem.dto.TokenPackage; import com.ddf.vodsystem.dto.properties.AuthProperties; import com.ddf.vodsystem.entities.RefreshToken; @@ -7,17 +8,14 @@ import com.ddf.vodsystem.entities.TokenFamily; 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.ddf.vodsystem.security.TokenService; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; 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.Duration; import java.time.Instant; @@ -26,7 +24,7 @@ import java.util.Optional; @Service public class UserService { - private final GoogleIdTokenVerifier verifier; + private final GoogleVerifierService googleVerifierService; private final UserRepository userRepository; private final JwtService jwtService; @@ -39,11 +37,11 @@ public class UserService { public UserService(UserRepository userRepository, JwtService jwtService, - GoogleIdTokenVerifier verifier, + GoogleVerifierService googleVerifierService, AuthProperties props, TokenService tokenService) { this.userRepository = userRepository; - this.verifier = verifier; + this.googleVerifierService = googleVerifierService; this.jwtService = jwtService; this.tokenService = tokenService; @@ -83,15 +81,8 @@ public class UserService { */ @Transactional public TokenPackage login(String idToken) { - GoogleIdToken googleIdToken = getGoogleIdToken(idToken); - String googleId = googleIdToken.getPayload().getSubject(); - - if (googleId == null) { - throw new NotAuthenticated("Invalid ID token"); - } - - User googleUser = getGoogleUser(googleIdToken); - User user = createOrUpdateUser(googleUser); + GoogleUser googleUser = googleVerifierService.verify(idToken); + User user = createOrUpdateGoogleUser(googleUser); byte[] rawRefresh = generateRandomBytes(32); TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs); @@ -161,46 +152,25 @@ public class UserService { return userRepository.findByStreamKey(streamKey); } - private User createOrUpdateUser(User user) { - Optional existingUser = userRepository.findByGoogleId(user.getGoogleId()); + private User createOrUpdateGoogleUser(GoogleUser googleUser) { + Optional 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(HexFormat.of().formatHex(generateRandomBytes(24))); - return userRepository.saveAndFlush(user); + } 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); - } - - 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()); - } + user.setEmail(googleUser.email()); + user.setName(googleUser.name()); + user.setProfilePictureUrl(googleUser.profilePictureUrl()); + return userRepository.saveAndFlush(user); } private byte[] generateRandomBytes(int length) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 380feb4..d881404 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -28,6 +28,9 @@ 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} -- 2.49.1 From 11115a435a8828a6373b3ce57a19737b45b864dd Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 5 Oct 2026 19:30:50 +0100 Subject: [PATCH 07/25] extract rotation logic to TokenService --- .../security/GoogleVerifierService.java | 8 + .../ddf/vodsystem/security/TokenService.java | 150 +++++++++++++----- .../ddf/vodsystem/services/UserService.java | 71 +-------- 3 files changed, 125 insertions(+), 104 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java index e3497db..e4ef817 100644 --- a/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java +++ b/src/main/java/com/ddf/vodsystem/security/GoogleVerifierService.java @@ -25,12 +25,20 @@ public class GoogleVerifierService { 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(), diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 30615ef..099d0d8 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -1,88 +1,158 @@ 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.apache.commons.codec.binary.Hex; 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.Duration; import java.time.Instant; import java.util.HexFormat; import java.util.Optional; @Service public class TokenService { + private static final SecureRandom RNG = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + private final TokenFamilyRepository tokenFamilyRepository; private final RefreshTokenRepository refreshTokenRepository; - private static final SecureRandom RNG = new SecureRandom(); - private final HexFormat hex = HexFormat.of(); + private final Duration tokenFamilyExpirationMs; + private final Duration refreshTokenExpirationMs; + private final Duration refreshGracePeriod; public TokenService ( TokenFamilyRepository tokenFamilyRepository, - RefreshTokenRepository refreshTokenRepository + RefreshTokenRepository refreshTokenRepository, + AuthProperties props ) { this.tokenFamilyRepository = tokenFamilyRepository; this.refreshTokenRepository = refreshTokenRepository; + + this.refreshTokenExpirationMs = props.expiration().refreshToken(); + this.tokenFamilyExpirationMs = props.expiration().tokenFamily(); + this.refreshGracePeriod = props.expiration().gracePeriod(); } - public TokenFamily createTokenFamily(User user, long expiryTimeMs) { + @Transactional + public String createSession(User user) { Instant now = Instant.now(); TokenFamily tokenFamily = new TokenFamily(); tokenFamily.setUser(user); tokenFamily.setCreatedAt(now); - tokenFamily.setExpiresAt(now.plusMillis(expiryTimeMs)); + tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpirationMs.toMillis())); + tokenFamilyRepository.save(tokenFamily); - return tokenFamilyRepository.saveAndFlush(tokenFamily); + byte[] token = generateRandomBytes(32); + + createRefreshToken( + token, + tokenFamily + ); + + return HEX.formatHex(token); } - public RefreshToken createRefreshToken( + @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(); + + // Check Token is real + RefreshToken refreshToken = refreshTokenRepository.findByHash(hash) + .orElseThrow(() -> new NotAuthenticated("No such refresh token")); + + // Find Token Family + Optional tokenFamily = tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); + + if (tokenFamily.isEmpty()) { + throw new IllegalStateException("Token must be tied to a family"); + } + + if (tokenFamily.get().getRevokedAt() != null) { + throw new NotAuthenticated("Token family has been revoked"); + } + + // Reuse Check + if (refreshToken.getUsedAt() != null) { + if (now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) { + tokenFamily.get().setRevokedAt(Instant.now()); + tokenFamilyRepository.save(tokenFamily.get()); + } + + throw new NotAuthenticated("Refresh token already being used"); + } + + // Expiry Check + if ( + now.isAfter(refreshToken.getExpiresAt()) || + now.isAfter(tokenFamily.get().getExpiresAt()) + ) { + throw new NotAuthenticated("Refresh token or family expired"); + } + + // Rotate + refreshToken.setUsedAt(now); + refreshTokenRepository.save(refreshToken); + + byte[] newRawToken = generateRandomBytes(32); + createRefreshToken( + newRawToken, + tokenFamily.get() + ); + + return new Rotation(tokenFamily.get().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, - long expiryTimeMs + TokenFamily tokenFamily ) { Instant now = Instant.now(); RefreshToken refreshToken = new RefreshToken(); refreshToken.setTokenFamily(tokenFamily); - refreshToken.setTokenHash(hashToken(token)); + refreshToken.setTokenHash(sha256(token)); refreshToken.setCreatedAt(now); - refreshToken.setExpiresAt(now.plusMillis(expiryTimeMs)); + refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpirationMs.toMillis())); return refreshTokenRepository.saveAndFlush(refreshToken); } - public Optional getRefreshTokenByHash(byte[] refreshHash) { - return refreshTokenRepository.findByHash(refreshHash); - } - - public Optional getFamilyByRefreshToken(RefreshToken refreshToken) { - return tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); - } - - public TokenFamily revokeTokenFamily(TokenFamily tokenFamily) { - tokenFamily.setRevokedAt(Instant.now()); - return tokenFamilyRepository.saveAndFlush(tokenFamily); - } - - public String bytesToHex(byte[] bytes) { - return hex.formatHex(bytes); - } - - public byte[] hexToBytes(String hexS) { - return hex.parseHex(hexS); - } - - public byte[] hashToken(byte[] token) { - try { - return MessageDigest.getInstance("SHA-256").digest(token); // 32 bytes - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } - } } diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 6dcb332..8fd4130 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -2,9 +2,6 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.dto.GoogleUser; import com.ddf.vodsystem.dto.TokenPackage; -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.UserRepository; @@ -17,7 +14,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.security.SecureRandom; -import java.time.Duration; import java.time.Instant; import java.util.HexFormat; import java.util.Optional; @@ -28,25 +24,17 @@ public class UserService { private final UserRepository userRepository; private final JwtService jwtService; - private final long tokenFamilyExpirationMs; - private final long refreshTokenExpirationMs; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - private static final int REFRESH_GRACE_PERIOD_SECONDS = 5; private final TokenService tokenService; public UserService(UserRepository userRepository, JwtService jwtService, GoogleVerifierService googleVerifierService, - AuthProperties props, TokenService tokenService) { this.userRepository = userRepository; this.googleVerifierService = googleVerifierService; this.jwtService = jwtService; this.tokenService = tokenService; - - this.refreshTokenExpirationMs = props.expiration().refreshToken().toMillis(); - this.tokenFamilyExpirationMs = props.expiration().tokenFamily().toMillis(); } /** @@ -84,62 +72,17 @@ public class UserService { GoogleUser googleUser = googleVerifierService.verify(idToken); User user = createOrUpdateGoogleUser(googleUser); - byte[] rawRefresh = generateRandomBytes(32); - TokenFamily tokenFamily = tokenService.createTokenFamily(user, tokenFamilyExpirationMs); - tokenService.createRefreshToken(tokenService.hashToken(rawRefresh), tokenFamily, refreshTokenExpirationMs); - + String refreshToken = tokenService.createSession(user); String jwt = jwtService.generateToken(user.getId()); - return new TokenPackage(tokenService.bytesToHex(rawRefresh), jwt); + + return new TokenPackage(refreshToken, jwt); } - @Transactional(noRollbackFor = NotAuthenticated.class) public TokenPackage refresh(String refreshToken) { - if (refreshToken == null || refreshToken.isBlank()) { - throw new NotAuthenticated("Missing refresh token"); - } + TokenService.Rotation rotation = tokenService.rotate(refreshToken); + String jwt = jwtService.generateToken(rotation.userId()); - // Check Refresh Token is real - byte[] rawToken = tokenService.hexToBytes(refreshToken); - RefreshToken token = tokenService.getRefreshTokenByHash(rawToken) - .orElseThrow(() -> new NotAuthenticated("No such refresh token")); - - // Find Token Family - Optional tokenFamily = tokenService.getFamilyByRefreshToken(token); - - if (tokenFamily.isEmpty()) { - throw new IllegalStateException("Token must be tied to a family"); - } - - if (tokenFamily.get().getRevokedAt() != null) { - throw new NotAuthenticated("Token family has been revoked"); - } - - Instant now = Instant.now(); - - // Reuse Check - if (token.getUsedAt() != null) { - if (Duration.between(token.getUsedAt(), now).getSeconds() > REFRESH_GRACE_PERIOD_SECONDS) { - tokenService.revokeTokenFamily(tokenFamily.get()); - } - throw new NotAuthenticated("Refresh token already used"); - } - - // Expiry Check - if (now.isAfter(token.getExpiresAt()) || now.isAfter(tokenFamily.get().getExpiresAt())) { - throw new NotAuthenticated("Refresh token or family expired"); - } - - // Rotate - token.setUsedAt(now); - byte[] newRawToken = generateRandomBytes(32); - tokenService.createRefreshToken( - newRawToken, - tokenFamily.get(), - refreshTokenExpirationMs - ); - - String jwt = jwtService.generateToken(tokenFamily.get().getUser().getId()); - return new TokenPackage(tokenService.bytesToHex(newRawToken), jwt); + return new TokenPackage(rotation.newRefreshToken(), jwt); } /** @@ -170,7 +113,7 @@ public class UserService { user.setEmail(googleUser.email()); user.setName(googleUser.name()); user.setProfilePictureUrl(googleUser.profilePictureUrl()); - return userRepository.saveAndFlush(user); + return userRepository.save(user); } private byte[] generateRandomBytes(int length) { -- 2.49.1 From 40fc80537bcd5b0c501af12b50c0620f23c2d7d9 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 7 Oct 2026 18:31:47 +0100 Subject: [PATCH 08/25] add lock to read by Hash --- .../repositories/RefreshTokenRepository.java | 4 +++- .../ddf/vodsystem/security/TokenService.java | 21 +++++++------------ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java index 4b925cd..c8542d6 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/RefreshTokenRepository.java @@ -1,15 +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.sql.Ref; import java.util.Optional; @Repository public interface RefreshTokenRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1") Optional findByHash(byte[] tokenHash); } diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 099d0d8..96721e3 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -16,7 +16,6 @@ import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; import java.util.HexFormat; -import java.util.Optional; @Service public class TokenService { @@ -83,21 +82,17 @@ public class TokenService { .orElseThrow(() -> new NotAuthenticated("No such refresh token")); // Find Token Family - Optional tokenFamily = tokenFamilyRepository.findById(refreshToken.getTokenFamily().getId()); + TokenFamily tokenFamily = refreshToken.getTokenFamily(); - if (tokenFamily.isEmpty()) { - throw new IllegalStateException("Token must be tied to a family"); - } - - if (tokenFamily.get().getRevokedAt() != null) { + if (tokenFamily.getRevokedAt() != null) { throw new NotAuthenticated("Token family has been revoked"); } // Reuse Check if (refreshToken.getUsedAt() != null) { if (now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) { - tokenFamily.get().setRevokedAt(Instant.now()); - tokenFamilyRepository.save(tokenFamily.get()); + tokenFamily.setRevokedAt(Instant.now()); + tokenFamilyRepository.save(tokenFamily); } throw new NotAuthenticated("Refresh token already being used"); @@ -106,7 +101,7 @@ public class TokenService { // Expiry Check if ( now.isAfter(refreshToken.getExpiresAt()) || - now.isAfter(tokenFamily.get().getExpiresAt()) + now.isAfter(tokenFamily.getExpiresAt()) ) { throw new NotAuthenticated("Refresh token or family expired"); } @@ -118,10 +113,10 @@ public class TokenService { byte[] newRawToken = generateRandomBytes(32); createRefreshToken( newRawToken, - tokenFamily.get() + tokenFamily ); - return new Rotation(tokenFamily.get().getUser().getId(), HEX.formatHex(newRawToken)); + return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); } public record Rotation(Long userId, String newRefreshToken) {} @@ -152,7 +147,7 @@ public class TokenService { refreshToken.setTokenHash(sha256(token)); refreshToken.setCreatedAt(now); refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpirationMs.toMillis())); - return refreshTokenRepository.saveAndFlush(refreshToken); + return refreshTokenRepository.save(refreshToken); } } -- 2.49.1 From 8166c62bb1b2071da6ebc5a3a5301cad3820367a Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 7 Oct 2026 18:45:27 +0100 Subject: [PATCH 09/25] fix reused token within grace still throwing --- .../ddf/vodsystem/security/TokenService.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 96721e3..b79eeb6 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -89,12 +89,9 @@ public class TokenService { } // Reuse Check - if (refreshToken.getUsedAt() != null) { - if (now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) { - tokenFamily.setRevokedAt(Instant.now()); - tokenFamilyRepository.save(tokenFamily); - } - + if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) { + tokenFamily.setRevokedAt(now); + tokenFamilyRepository.save(tokenFamily); throw new NotAuthenticated("Refresh token already being used"); } @@ -106,9 +103,11 @@ public class TokenService { throw new NotAuthenticated("Refresh token or family expired"); } - // Rotate - refreshToken.setUsedAt(now); - refreshTokenRepository.save(refreshToken); + // 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( -- 2.49.1 From e2a2a60075d5cdecb59895b24f33933f687afb8d Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 7 Oct 2026 19:03:47 +0100 Subject: [PATCH 10/25] add clock dependency injection --- .../ddf/vodsystem/configuration/TimeConfig.java | 15 +++++++++++++++ .../vodsystem/dto/properties/AuthProperties.java | 7 ++++--- .../com/ddf/vodsystem/security/TokenService.java | 13 +++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java diff --git a/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java b/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java new file mode 100644 index 0000000..5e40187 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/configuration/TimeConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java index 194b40f..da9fd31 100644 --- a/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java +++ b/src/main/java/com/ddf/vodsystem/dto/properties/AuthProperties.java @@ -18,9 +18,10 @@ public record AuthProperties ( /** * - * @param jwt Duration object as to the length of the access token - * @param refreshToken Duration of refresh token - * @param tokenFamily Duration of token family + * @param jwt + * @param refreshToken + * @param tokenFamily + * @param gracePeriod */ public record Expiration ( @NotNull Duration jwt, diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index b79eeb6..3c6abab 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -13,6 +13,7 @@ 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; @@ -22,6 +23,8 @@ 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; @@ -32,10 +35,12 @@ public class TokenService { public TokenService ( TokenFamilyRepository tokenFamilyRepository, RefreshTokenRepository refreshTokenRepository, - AuthProperties props + AuthProperties props, + Clock clock ) { this.tokenFamilyRepository = tokenFamilyRepository; this.refreshTokenRepository = refreshTokenRepository; + this.clock = clock; this.refreshTokenExpirationMs = props.expiration().refreshToken(); this.tokenFamilyExpirationMs = props.expiration().tokenFamily(); @@ -44,7 +49,7 @@ public class TokenService { @Transactional public String createSession(User user) { - Instant now = Instant.now(); + Instant now = Instant.now(clock); TokenFamily tokenFamily = new TokenFamily(); tokenFamily.setUser(user); @@ -75,7 +80,7 @@ public class TokenService { throw new NotAuthenticated("Malformed refresh token"); } - Instant now = Instant.now(); + Instant now = Instant.now(clock); // Check Token is real RefreshToken refreshToken = refreshTokenRepository.findByHash(hash) @@ -139,7 +144,7 @@ public class TokenService { byte[] token, TokenFamily tokenFamily ) { - Instant now = Instant.now(); + Instant now = Instant.now(clock); RefreshToken refreshToken = new RefreshToken(); refreshToken.setTokenFamily(tokenFamily); -- 2.49.1 From 4b5e6ed8f639053c612c9ee271383136abefa680 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 8 Oct 2026 19:51:44 +0100 Subject: [PATCH 11/25] add some createSession tests --- .../ddf/vodsystem/security/TokenService.java | 12 +- .../vodsystem/security/TokenServiceTest.java | 142 ++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 3c6abab..fbc68c3 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -28,8 +28,8 @@ public class TokenService { private final TokenFamilyRepository tokenFamilyRepository; private final RefreshTokenRepository refreshTokenRepository; - private final Duration tokenFamilyExpirationMs; - private final Duration refreshTokenExpirationMs; + private final Duration tokenFamilyExpiration; + private final Duration refreshTokenExpiration; private final Duration refreshGracePeriod; public TokenService ( @@ -42,8 +42,8 @@ public class TokenService { this.refreshTokenRepository = refreshTokenRepository; this.clock = clock; - this.refreshTokenExpirationMs = props.expiration().refreshToken(); - this.tokenFamilyExpirationMs = props.expiration().tokenFamily(); + this.refreshTokenExpiration = props.expiration().refreshToken(); + this.tokenFamilyExpiration = props.expiration().tokenFamily(); this.refreshGracePeriod = props.expiration().gracePeriod(); } @@ -54,7 +54,7 @@ public class TokenService { TokenFamily tokenFamily = new TokenFamily(); tokenFamily.setUser(user); tokenFamily.setCreatedAt(now); - tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpirationMs.toMillis())); + tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis())); tokenFamilyRepository.save(tokenFamily); byte[] token = generateRandomBytes(32); @@ -150,7 +150,7 @@ public class TokenService { refreshToken.setTokenFamily(tokenFamily); refreshToken.setTokenHash(sha256(token)); refreshToken.setCreatedAt(now); - refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpirationMs.toMillis())); + refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis())); return refreshTokenRepository.save(refreshToken); } diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java new file mode 100644 index 0000000..7cee129 --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -0,0 +1,142 @@ +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.repositories.RefreshTokenRepository; +import com.ddf.vodsystem.repositories.TokenFamilyRepository; +import org.junit.jupiter.api.BeforeEach; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; + +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 familyCaptor; + @Captor ArgumentCaptor tokenCaptor; + + private static final HexFormat HEX = HexFormat.of(); + + 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 + ); + } + + @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)); + } + +} \ No newline at end of file -- 2.49.1 From 4c7c2f0916112ddef6530cf2aae56e9a9e876b61 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 9 Oct 2026 17:44:30 +0100 Subject: [PATCH 12/25] add rotate tests --- .../vodsystem/security/TokenServiceTest.java | 200 ++++++++++++++---- 1 file changed, 159 insertions(+), 41 deletions(-) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 7cee129..22e9fff 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -4,9 +4,12 @@ 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; @@ -16,9 +19,11 @@ 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.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; class MutableClock extends Clock { private Instant now; @@ -46,6 +51,7 @@ class TokenServiceTest { @Captor ArgumentCaptor tokenCaptor; private static final HexFormat HEX = HexFormat.of(); + private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef"; AuthProperties authProperties; TokenService tokenService; @@ -61,6 +67,7 @@ class TokenServiceTest { @BeforeEach void setUp() { + authProperties = new AuthProperties( "a-secret", new AuthProperties.Expiration( @@ -81,62 +88,173 @@ class TokenServiceTest { ); } - @Test - void createSession_createsTokenFamily() { - User user = createUser(1L, "user", "user@gmail.com"); + @Nested + @DisplayName("createSession()") + class createSession { + @Test + void createSession_createsTokenFamily() { + User user = createUser(1L, "user", "user@gmail.com"); - tokenService.createSession(user); + tokenService.createSession(user); - verify(tokenFamilyRepository).save(familyCaptor.capture()); - verify(refreshTokenRepository).save(tokenCaptor.capture()); + verify(tokenFamilyRepository).save(familyCaptor.capture()); + verify(refreshTokenRepository).save(tokenCaptor.capture()); - TokenFamily tokenFamily = familyCaptor.getValue(); - RefreshToken refreshToken = tokenCaptor.getValue(); + 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()) - ); + 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)); + } } - @Test - void createSession_savesRefreshTokenCreatedAtAndExpires() { - User user = createUser(1L, "user", "user"); + // Rotate + @Nested + @DisplayName("rotate()") + class rotate { + // Input val + @Test + void nullToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(null) + ); + } - tokenService.createSession(user); + @Test + void blankToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate("") + ); + } - verify(refreshTokenRepository).save(tokenCaptor.capture()); - RefreshToken refreshToken = tokenCaptor.getValue(); + // Invalid cases + @Test + void unknownToken_throwsNotAuthenticated() { + when(refreshTokenRepository.findByHash(any())).thenReturn( + Optional.empty() + ); - assertEquals(refreshToken.getCreatedAt(), clock.instant()); - assertEquals( - refreshToken.getExpiresAt(), - clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis()) - ); - } + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(rawToken) + ); + } - @Test - void createSession_returnsHexTokenOf64Characters() { - User user = createUser(1L, "user", "user"); + @Test + void revokedFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setRevokedAt(Instant.now(clock)); - String token = tokenService.createSession(user); + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); - assertEquals(64, token.length()); - assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token); - } + clock.advance(Duration.ofMinutes(1)); - @Test - void createSession_doesNotStoreRawToken() { - User user = createUser(1L, "user", "user"); + when(refreshTokenRepository.findByHash(any())).thenReturn( + Optional.of(refreshToken) + ); - String token = tokenService.createSession(user); + assertThrows( + NotAuthenticated.class, + () -> tokenService.rotate(rawToken) + ); + } - verify(refreshTokenRepository).save(tokenCaptor.capture()); - RefreshToken refreshToken = tokenCaptor.getValue(); + // 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()); + } - assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token)); } } \ No newline at end of file -- 2.49.1 From 19a5a49cbb8c425a4879f905a184bfc3e7d56ec9 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 9 Oct 2026 17:44:30 +0100 Subject: [PATCH 13/25] add rotate tests --- .../vodsystem/security/TokenServiceTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 22e9fff..4045feb 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -255,6 +255,32 @@ class TokenServiceTest { 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()); + } + } } \ No newline at end of file -- 2.49.1 From 9af70c33d978c21f8677bc04097da313f227ff3f Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 11 Oct 2026 19:02:39 +0100 Subject: [PATCH 14/25] add doesNotRevokeFamily test --- .../vodsystem/security/TokenServiceTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 4045feb..418f11b 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -281,6 +281,29 @@ class TokenServiceTest { assertEquals(savedToken.getUsedAt(), clock.instant()); } + @Test + void unusedToken_doesNotRevokeFamily() { + 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")); + assertNull(newToken.getTokenFamily().getRevokedAt()); + } } } \ No newline at end of file -- 2.49.1 From 2bf369c2adaf0a7be6c60aec429f3093d61197f3 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 12 Oct 2026 21:59:25 +0100 Subject: [PATCH 15/25] add newTokenIsDifferent test --- .../vodsystem/security/TokenServiceTest.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 418f11b..a9e56ef 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -304,6 +304,24 @@ class TokenServiceTest { .orElseThrow(() -> new AssertionError("no unused token was saved")); assertNull(newToken.getTokenFamily().getRevokedAt()); } - } + @Test + void unusedToken_newTokenIsDifferentFromOldToken() { + 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.setTokenHash(rawToken.getBytes()); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + assertNotEquals(rawToken, rotation.newRefreshToken()); + } + } } \ No newline at end of file -- 2.49.1 From 47e78d0d26e46af5709a84d0a3d0c80e00ccbb9a Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 13 Oct 2026 19:26:33 +0100 Subject: [PATCH 16/25] add returnsNewToken for grace period check --- .../vodsystem/security/TokenServiceTest.java | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index a9e56ef..516f722 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -74,7 +74,7 @@ class TokenServiceTest { Duration.ofSeconds(10), Duration.ofSeconds(10), Duration.ofSeconds(10), - Duration.ofSeconds(10) + Duration.ofSeconds(5) ) ); @@ -323,5 +323,44 @@ class TokenServiceTest { TokenService.Rotation rotation = tokenService.rotate(rawToken); assertNotEquals(rawToken, rotation.newRefreshToken()); } + + // Grace period checks + @Test + void usedWithinGrace_returnsNewToken() { + Instant usedAt = Instant.now(clock); + + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setUser(createUser(1L, "user", "user")); + tokenFamily.setExpiresAt(usedAt.plusSeconds(10)); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenHash(rawToken.getBytes()); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(usedAt.plusSeconds(10)); + refreshToken.setUsedAt(usedAt); + + when(refreshTokenRepository.findByHash(any())) + .thenReturn(Optional.of(refreshToken)); + + // within grace: 3s since usedAt, grace is 5s, and nothing has expired yet + clock.advance(Duration.ofSeconds(3)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNotEquals(rawToken, rotation.newRefreshToken()); + assertNull(tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + + // grace is not extended by a replay, and only the new token is written + assertEquals(usedAt, refreshToken.getUsedAt()); + + verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture()); + RefreshToken newToken = tokenCaptor.getValue(); + assertNull(newToken.getUsedAt()); + assertSame(tokenFamily, newToken.getTokenFamily()); + assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash()); + } } } \ No newline at end of file -- 2.49.1 From 0bc6b3e8dbe3dc90be072718e40d9539ff848eca Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 14 Oct 2026 20:59:26 +0100 Subject: [PATCH 17/25] finish TokenServiceTest --- .../vodsystem/security/TokenServiceTest.java | 277 ++++++++++++++---- 1 file changed, 224 insertions(+), 53 deletions(-) diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 516f722..a1ab1d2 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -186,12 +186,9 @@ class TokenServiceTest { @Test void revokedFamily_throwsNotAuthenticated() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); + TokenFamily tokenFamily = activeFamily(); tokenFamily.setRevokedAt(Instant.now(clock)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); + RefreshToken refreshToken = tokenIn(tokenFamily); clock.advance(Duration.ofMinutes(1)); @@ -208,14 +205,8 @@ class TokenServiceTest { // 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)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -228,14 +219,8 @@ class TokenServiceTest { @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)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -257,14 +242,8 @@ class TokenServiceTest { @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)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -283,14 +262,8 @@ class TokenServiceTest { @Test void unusedToken_doesNotRevokeFamily() { - 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)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -307,15 +280,9 @@ class TokenServiceTest { @Test void unusedToken_newTokenIsDifferentFromOldToken() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); refreshToken.setTokenHash(rawToken.getBytes()); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -329,15 +296,9 @@ class TokenServiceTest { void usedWithinGrace_returnsNewToken() { Instant usedAt = Instant.now(clock); - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(usedAt.plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); refreshToken.setTokenHash(rawToken.getBytes()); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(usedAt.plusSeconds(10)); refreshToken.setUsedAt(usedAt); when(refreshTokenRepository.findByHash(any())) @@ -362,5 +323,215 @@ class TokenServiceTest { assertSame(tokenFamily, newToken.getTokenFamily()); assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash()); } + + @Test + void usedAtExactGraceBoundary_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(authProperties.expiration().gracePeriod()); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNull(tokenFamily.getRevokedAt()); + } + + // Reuse after grace period + @Test + void usedAfterGrace_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void usedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(tokenFamilyRepository).save(familyCaptor.capture()); + assertSame(tokenFamily, familyCaptor.getValue()); + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + } + + @Test + void usedAfterGrace_doesNotIssueNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void usedAfterGrace_subsequentRotateRejectedWithoutResaving() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + verify(tokenFamilyRepository, times(1)).save(any()); + } + + // Expiry + @Test + void expiredToken_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().minusSeconds(1)); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setExpiresAt(clock.instant().minusSeconds(1)); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredToken_doesNotSaveOrRevoke() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verify(tokenFamilyRepository, never()).save(any()); + assertNull(refreshToken.getUsedAt()); + assertNull(tokenFamily.getRevokedAt()); + } + + @Test + void tokenAtExactExpiry_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(10)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + } + + // Ordering + @Test + void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() { + TokenFamily tokenFamily = activeFamily(); + Instant revokedAt = clock.instant(); + tokenFamily.setRevokedAt(revokedAt); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(revokedAt, tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + } + + @Test + void revokedAndExpiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(clock.instant()); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + } + + @Test + void expiredAndUsedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository).save(tokenFamily); + } + + @Test + void expiredWithinGrace_doesNotRevokeFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(2)); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(3)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertNull(tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void unknownToken_doesNotTouchRepositoriesFurther() { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verifyNoInteractions(tokenFamilyRepository); + } + + private TokenFamily activeFamily() { + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setUser(createUser(1L, "user", "user")); + tokenFamily.setExpiresAt(clock.instant().plusSeconds(10)); + return tokenFamily; + } + + private RefreshToken tokenIn(TokenFamily tokenFamily) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(10)); + return refreshToken; + } } } \ No newline at end of file -- 2.49.1 From ed8e4881d643815d8d1b187ddd5ac49be2dc587c Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 20:42:23 +0100 Subject: [PATCH 18/25] implement logout functions and refresh endpoint --- .../vodsystem/controllers/UserController.java | 85 ++++++++++++------- .../ddf/vodsystem/security/TokenService.java | 22 ++++- .../ddf/vodsystem/services/UserService.java | 4 + 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index d776076..a8bd579 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -3,16 +3,18 @@ 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.dto.properties.AuthProperties; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.services.UserService; import jakarta.servlet.http.HttpServletResponse; 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.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Duration; import java.util.Optional; @RestController @@ -20,12 +22,18 @@ import java.util.Optional; public class UserController { private final UserService userService; - @Value("${app.security.expiration.jwt}") - private long jwtExpiration; + private final Duration jwtExpiration; + private final Duration refreshTokenExpiration; 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.jwtExpiration = props.expiration().jwt(); + this.refreshTokenExpiration = props.expiration().refreshToken(); } /** @@ -59,48 +67,67 @@ public class UserController { * @return {@code 200 OK} wrapping the issued JWT as a {@link Token} */ @PostMapping("/login") - public ResponseEntity> login(@RequestBody Token token, + public ResponseEntity> login(@RequestBody Token token, HttpServletRequest request, HttpServletResponse response) { TokenPackage tokenPackage = userService.login(token.token()); - - ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken()) - .httpOnly(true) - .maxAge(jwtExpiration / 1000) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); return ResponseEntity.ok( - new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage) + new APIResponse<>(SUCCESS, "Logged in successfully", null) + ); + } + + @PostMapping("/refresh") + public ResponseEntity> refresh( + @RequestBody Token token, + HttpServletResponse response, + HttpServletRequest request) { + TokenPackage tokenPackage = userService.refresh(token.token()); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Refreshed tokens successfully", null) ); } /** * Logs the user out by clearing the session cookie. * - * @param response the response the expired cookie is written to - * @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags * @return {@code 200 OK} confirming logout */ @PostMapping("/logout") - public ResponseEntity> logout(HttpServletResponse response, - HttpServletRequest request) { - ResponseCookie cookie = ResponseCookie.from("token", "") - .httpOnly(true) - .maxAge(0) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + public ResponseEntity> logout( + @CookieValue(name = "refreshToken", required = false) String refreshToken) { + userService.logout(refreshToken); return ResponseEntity.ok( new APIResponse<>(SUCCESS, "Logged out successfully", null) ); } + + private void addCookies( + HttpServletRequest request, + HttpServletResponse response, + String accessToken, + String refreshToken) { + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .maxAge(jwtExpiration.getSeconds()) + .sameSite("Lax") + .secure(request.isSecure()) + .path(ACCESS_COOKIE_PATH) + .build(); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .maxAge(refreshTokenExpiration.getSeconds()) + .sameSite("Strict") + .secure(request.isSecure()) + .path(REFRESH_COOKIE_PATH) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + } } diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index fbc68c3..222a134 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -17,6 +17,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.HexFormat; +import java.util.Optional; @Service public class TokenService { @@ -123,6 +124,26 @@ public class TokenService { return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); } + public void revoke(String hexToken) { + Instant now = Instant.now(clock); + + byte[] hash; + try { + hash = sha256(HEX.parseHex(hexToken)); + } catch (IllegalArgumentException e) { + throw new NotAuthenticated("Malformed refresh token"); + } + + Optional found = refreshTokenRepository.findByHash(sha256(hash)); + if (found.isEmpty()) return; + + RefreshToken refreshToken = found.get(); + TokenFamily family = refreshToken.getTokenFamily(); + + if (family.getRevokedAt() == null) family.setRevokedAt(now); + if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); + } + public record Rotation(Long userId, String newRefreshToken) {} private static byte[] sha256(byte[] in) { @@ -153,5 +174,4 @@ public class TokenService { refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis())); return refreshTokenRepository.save(refreshToken); } - } diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 8fd4130..ff91495 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -85,6 +85,10 @@ public class UserService { return new TokenPackage(rotation.newRefreshToken(), jwt); } + public void logout(String refreshToken) { + tokenService.revoke(refreshToken); + } + /** * Looks up a user by their stream key. * -- 2.49.1 From 40e6d6635c0b633e37730610855cda8ddf487c58 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 20:47:58 +0100 Subject: [PATCH 19/25] add user unit tests --- .../vodsystem/services/UserServiceTest.java | 290 +++++++++++++++++- 1 file changed, 289 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java index 0b5eb3e..e6249ce 100644 --- a/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java @@ -1,7 +1,295 @@ 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.ddf.vodsystem.security.TokenService; +import org.junit.jupiter.api.AfterEach; +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 org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +@ExtendWith(MockitoExtension.class) class UserServiceTest { + @Mock private GoogleVerifierService googleVerifierService; + @Mock private JwtService jwtService; + @Mock private UserRepository userRepository; + @Mock private TokenService tokenService; -} \ No newline at end of file + @Captor ArgumentCaptor userCaptor; + + private static final String ID_TOKEN = "google-id-token"; + private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; + private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe"; + private static final String JWT = "header.payload.signature"; + private static final String GOOGLE_ID = "google-id"; + + UserService userService; + + User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + return user; + } + + GoogleUser googleUser() { + return new GoogleUser( + GOOGLE_ID, + "user@example.com", + "My User", + "https://lol.com/1" + ); + } + + @BeforeEach + void setUp() { + userService = new UserService( + userRepository, + jwtService, + googleVerifierService, + tokenService + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("getUserById()") + class getUserById { + @Test + void existingId_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Optional result = userService.getUserById(1L); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void unknownId_returnsEmpty() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertTrue(userService.getUserById(1L).isEmpty()); + } + } + + @Nested + @DisplayName("getLoggedInUser()") + class getLoggedInUser { + @Test + void authenticatedUserPrincipal_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of()) + ); + + Optional result = userService.getLoggedInUser(); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void noAuthentication_returnsEmpty() { + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void unauthenticated_returnsEmpty() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void nonUserPrincipal_returnsEmpty() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("anonymous", null, List.of()) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + } + + @Nested + @DisplayName("login()") + class login { + @Test + void newUser_savesUserToRepo() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("user@example.com", saved.getUsername()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals(0, saved.getRole()); + assertNotNull(saved.getCreatedAt()); + } + + @Test + void newUser_generatesHexStreamKeyOf48Characters() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + String streamKey = userCaptor.getValue().getStreamKey(); + assertEquals(48, streamKey.length()); + assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey); + } + + @Test + void existingUser_updatesUserInfo() { + User existingUser = createUser(1L, "old-username", "old@example.com"); + existingUser.setGoogleId(GOOGLE_ID); + existingUser.setName("Old Name"); + existingUser.setProfilePictureUrl("https://lol.com/old"); + existingUser.setStreamKey("123"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertSame(existingUser, saved); + assertEquals(1L, saved.getId()); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals("old-username", saved.getUsername()); + assertEquals("123", saved.getStreamKey()); + } + + @Test + void validToken_returnsRefreshTokenAndJwt() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN); + when(jwtService.generateToken(any())).thenReturn(JWT); + + TokenPackage tokenPackage = userService.login(ID_TOKEN); + + assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void validToken_createsSessionForSavedUser() { + User savedUser = createUser(42L, "user@example.com", "user@example.com"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenReturn(savedUser); + + userService.login(ID_TOKEN); + + verify(tokenService).createSession(savedUser); + verify(jwtService).generateToken(42L); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(googleVerifierService.verify(ID_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid ID token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.login(ID_TOKEN) + ); + + verifyNoInteractions(userRepository, tokenService, jwtService); + } + } + + @Nested + @DisplayName("refresh()") + class refresh { + @Test + void validToken_returnsRotatedTokenAndJwt() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN)); + when(jwtService.generateToken(1L)).thenReturn(JWT); + + TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN); + + assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid refresh token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.refresh(REFRESH_TOKEN) + ); + + verifyNoInteractions(jwtService); + } + } + + @Nested + @DisplayName("logout()") + class logout { + @Test + void revokesRefreshToken() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService).revoke(REFRESH_TOKEN); + } + + @Test + void doesNotIssueTokens() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService, never()).createSession(any()); + verify(tokenService, never()).rotate(any()); + verifyNoInteractions(jwtService); + } + } +} -- 2.49.1 From 954a3b7f6e1b30c63a7ada98fe60bc1511e6df1b Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 20:51:25 +0100 Subject: [PATCH 20/25] update docstrings --- .../vodsystem/controllers/UserController.java | 31 ++++++++++--- .../ddf/vodsystem/security/TokenService.java | 45 +++++++++++++++++++ .../ddf/vodsystem/services/UserService.java | 23 ++++++++-- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index a8bd579..c0e4241 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -56,15 +56,16 @@ public class UserController { } /** - * Authenticates a user from a Google ID token and issues a session JWT. + * Authenticates a user from a Google ID token and starts a session. *

- * The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also - * returned in the response body. + * Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken} + * (path {@code /api/v1/users}). Neither token is returned in the response body. * * @param token the payload carrying the Google ID token to verify - * @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags - * @param response the response the session cookie is written to - * @return {@code 200 OK} wrapping the issued JWT as a {@link Token} + * @param request the incoming request, used to decide the cookies' {@code Secure} flag + * @param response the response the cookies are written to + * @return {@code 200 OK} with no data + * @throws NotAuthenticated if the Google ID token is rejected */ @PostMapping("/login") public ResponseEntity> login(@RequestBody Token token, @@ -78,6 +79,15 @@ public class UserController { ); } + /** + * Exchanges a refresh token for a new refresh token and JWT, replacing both cookies. + * + * @param token the payload carrying the current refresh token + * @param response the response the new cookies are written to + * @param request the incoming request, used to decide the cookies' {@code Secure} flag + * @return {@code 200 OK} with no data + * @throws NotAuthenticated if the refresh token is rejected + */ @PostMapping("/refresh") public ResponseEntity> refresh( @RequestBody Token token, @@ -92,8 +102,10 @@ public class UserController { } /** - * Logs the user out by clearing the session cookie. + * Logs the user out by revoking the session tied to the {@code refreshToken} cookie. + * The auth cookies are not cleared. * + * @param refreshToken the refresh token from the {@code refreshToken} cookie, if present * @return {@code 200 OK} confirming logout */ @PostMapping("/logout") @@ -106,6 +118,11 @@ public class UserController { ); } + /** + * Writes the {@code accessToken} cookie ({@code SameSite=Lax}, path {@code /}) and the + * {@code refreshToken} cookie ({@code SameSite=Strict}, path {@code /api/v1/users}), both HttpOnly + * and {@code Secure} when the request is. + */ private void addCookies( HttpServletRequest request, HttpServletResponse response, diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 222a134..1442ff6 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -19,6 +19,13 @@ import java.time.Instant; import java.util.HexFormat; import java.util.Optional; +/** + * Issues, rotates and revokes opaque refresh tokens. + *

+ * Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it. + * Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace + * period is treated as theft and revokes the whole family. + */ @Service public class TokenService { private static final SecureRandom RNG = new SecureRandom(); @@ -33,6 +40,12 @@ public class TokenService { private final Duration refreshTokenExpiration; private final Duration refreshGracePeriod; + /** + * @param tokenFamilyRepository stores token families (one per login session) + * @param refreshTokenRepository stores hashed refresh tokens + * @param props supplies token family lifetime, refresh token lifetime and reuse grace period + * @param clock time source for all timestamps and expiry checks + */ public TokenService ( TokenFamilyRepository tokenFamilyRepository, RefreshTokenRepository refreshTokenRepository, @@ -48,6 +61,12 @@ public class TokenService { this.refreshGracePeriod = props.expiration().gracePeriod(); } + /** + * Starts a new session for a user by creating a token family and its first refresh token. + * + * @param user the user the session belongs to + * @return the raw refresh token as a 64-character hex string; only its hash is persisted + */ @Transactional public String createSession(User user) { Instant now = Instant.now(clock); @@ -68,6 +87,19 @@ public class TokenService { return HEX.formatHex(token); } + /** + * Exchanges a refresh token for a new one in the same family. + *

+ * The presented token is marked used and a new token is issued. Presenting a used token again + * within the grace period returns another new token without extending the grace period. Presenting + * it after the grace period revokes the family; this revocation is committed even though the + * method throws. + * + * @param hexToken the raw refresh token as a hex string + * @return the owning user's ID and the new raw refresh token + * @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked + * or expired family, or is reused after the grace period + */ @Transactional(noRollbackFor = NotAuthenticated.class) public Rotation rotate(String hexToken) { if (hexToken == null || hexToken.isBlank()) { @@ -124,6 +156,13 @@ public class TokenService { return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); } + /** + * Revokes a refresh token and its whole token family, ending the session. Does nothing if the + * token is unknown, and leaves existing revocation timestamps unchanged. + * + * @param hexToken the raw refresh token as a hex string + * @throws NotAuthenticated if the token is not valid hex + */ public void revoke(String hexToken) { Instant now = Instant.now(clock); @@ -144,6 +183,12 @@ public class TokenService { if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); } + /** + * Result of a successful {@link #rotate(String)}. + * + * @param userId ID of the user who owns the token family + * @param newRefreshToken the newly issued raw refresh token as a hex string + */ public record Rotation(Long userId, String newRefreshToken) {} private static byte[] sha256(byte[] in) { diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index ff91495..801f775 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -61,11 +61,14 @@ public class UserService { } /** - * Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT. + * Verifies a Google ID token, creates or updates the matching user, and starts a new session. + *

+ * A new user gets a username equal to their email, the default role and a random stream key. + * Email, name and profile picture are refreshed from Google on every login. * * @param idToken the Google ID token to verify - * @return a signed JWT for the authenticated user - * @throws NotAuthenticated if the token is invalid or its subject is missing + * @return a new refresh token and a signed JWT access token for the user + * @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified */ @Transactional public TokenPackage login(String idToken) { @@ -78,6 +81,13 @@ public class UserService { return new TokenPackage(refreshToken, jwt); } + /** + * Rotates a refresh token and issues a new JWT access token for its owner. + * + * @param refreshToken the raw refresh token as a hex string + * @return the new refresh token and a signed JWT access token + * @throws NotAuthenticated if the refresh token is rejected; see {@link TokenService#rotate(String)} + */ public TokenPackage refresh(String refreshToken) { TokenService.Rotation rotation = tokenService.rotate(refreshToken); String jwt = jwtService.generateToken(rotation.userId()); @@ -85,6 +95,13 @@ public class UserService { return new TokenPackage(rotation.newRefreshToken(), jwt); } + /** + * Ends the session tied to a refresh token by revoking its token family. + * Already-issued JWT access tokens stay valid until they expire. + * + * @param refreshToken the raw refresh token as a hex string + * @throws NotAuthenticated if the token is not valid hex + */ public void logout(String refreshToken) { tokenService.revoke(refreshToken); } -- 2.49.1 From cf951afbae4902b21a1ca09247047329da0d742d Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 20:55:40 +0100 Subject: [PATCH 21/25] fix revokation bugs and add unit tests --- .../vodsystem/controllers/UserController.java | 49 +++++--- .../ddf/vodsystem/security/TokenService.java | 21 +++- .../ddf/vodsystem/services/UserService.java | 2 +- .../vodsystem/security/TokenServiceTest.java | 115 ++++++++++++++++-- 4 files changed, 149 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index c0e4241..eb1cb59 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -80,20 +80,20 @@ public class UserController { } /** - * Exchanges a refresh token for a new refresh token and JWT, replacing both cookies. + * Exchanges the {@code refreshToken} cookie 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 + * @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 new cookies are written to * @return {@code 200 OK} with no data - * @throws NotAuthenticated if the refresh token is rejected + * @throws NotAuthenticated if the refresh token is missing or rejected */ @PostMapping("/refresh") public ResponseEntity> refresh( - @RequestBody Token token, - HttpServletResponse response, - HttpServletRequest request) { - TokenPackage tokenPackage = userService.refresh(token.token()); + @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( @@ -102,35 +102,46 @@ public class UserController { } /** - * Logs the user out by revoking the session tied to the {@code refreshToken} cookie. - * The auth cookies are not cleared. + * 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 */ @PostMapping("/logout") public ResponseEntity> logout( - @CookieValue(name = "refreshToken", required = false) String refreshToken) { + @CookieValue(name = "refreshToken") String refreshToken, + HttpServletRequest request, + HttpServletResponse response) { userService.logout(refreshToken); + writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO); return ResponseEntity.ok( new APIResponse<>(SUCCESS, "Logged out successfully", null) ); } - /** - * Writes the {@code accessToken} cookie ({@code SameSite=Lax}, path {@code /}) and the - * {@code refreshToken} cookie ({@code SameSite=Strict}, path {@code /api/v1/users}), both HttpOnly - * and {@code Secure} when the request is. - */ private void addCookies( HttpServletRequest request, HttpServletResponse response, 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(jwtExpiration.getSeconds()) + .maxAge(accessTokenMaxAge) .sameSite("Lax") .secure(request.isSecure()) .path(ACCESS_COOKIE_PATH) @@ -138,7 +149,7 @@ public class UserController { ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) - .maxAge(refreshTokenExpiration.getSeconds()) + .maxAge(refreshTokenMaxAge) .sameSite("Strict") .secure(request.isSecure()) .path(REFRESH_COOKIE_PATH) diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index 1442ff6..259e966 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -158,13 +158,14 @@ public class TokenService { /** * 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. + * token is missing or unknown, and leaves existing revocation timestamps unchanged. * - * @param hexToken the raw refresh token as a hex string + * @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) { - Instant now = Instant.now(clock); + if (hexToken == null || hexToken.isBlank()) return; byte[] hash; try { @@ -173,14 +174,22 @@ public class TokenService { throw new NotAuthenticated("Malformed refresh token"); } - Optional found = refreshTokenRepository.findByHash(sha256(hash)); + Optional 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); - if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); + if (family.getRevokedAt() == null) { + family.setRevokedAt(now); + tokenFamilyRepository.save(family); + } + + if (refreshToken.getRevokedAt() == null) { + refreshToken.setRevokedAt(now); + refreshTokenRepository.save(refreshToken); + } } /** diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 801f775..968895f 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -99,7 +99,7 @@ public class UserService { * 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 + * @param refreshToken the raw refresh token as a hex string, may be {@code null} * @throws NotAuthenticated if the token is not valid hex */ public void logout(String refreshToken) { diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index a1ab1d2..68b62ee 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.security.MessageDigest; import java.time.*; import java.util.HexFormat; import java.util.Optional; @@ -518,20 +519,110 @@ class TokenServiceTest { verify(refreshTokenRepository, never()).save(any()); verifyNoInteractions(tokenFamilyRepository); } + } - private TokenFamily activeFamily() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(clock.instant().plusSeconds(10)); - return tokenFamily; + @Nested + @DisplayName("revoke()") + class revoke { + @Test + void knownToken_revokesTokenAndFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + tokenService.revoke(rawToken); + + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + assertEquals(clock.instant(), refreshToken.getRevokedAt()); } - private RefreshToken tokenIn(TokenFamily tokenFamily) { - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(clock.instant().plusSeconds(10)); - return refreshToken; + @Test + void knownToken_savesTokenAndFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + tokenService.revoke(rawToken); + + verify(tokenFamilyRepository).save(tokenFamily); + verify(refreshTokenRepository).save(refreshToken); + } + + @Test + void knownToken_looksUpBySingleSha256Hash() throws Exception { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + tokenService.revoke(rawToken); + + byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken)); + verify(refreshTokenRepository).findByHash(expectedHash); + } + + @Test + void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() { + Instant revokedAt = clock.instant(); + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(revokedAt); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setRevokedAt(revokedAt); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofMinutes(1)); + tokenService.revoke(rawToken); + + assertEquals(revokedAt, tokenFamily.getRevokedAt()); + assertEquals(revokedAt, refreshToken.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void unknownToken_doesNothing() { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + tokenService.revoke(rawToken); + + verify(refreshTokenRepository, never()).save(any()); + verifyNoInteractions(tokenFamilyRepository); + } + + @Test + void nullToken_doesNothing() { + tokenService.revoke(null); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); + } + + @Test + void blankToken_doesNothing() { + tokenService.revoke(""); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); + } + + @Test + void malformedToken_throwsNotAuthenticated() { + assertThrows( + NotAuthenticated.class, + () -> tokenService.revoke("not-hex") + ); + + verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository); } } -} \ No newline at end of file + + private TokenFamily activeFamily() { + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setUser(createUser(1L, "user", "user")); + tokenFamily.setExpiresAt(clock.instant().plusSeconds(10)); + return tokenFamily; + } + + private RefreshToken tokenIn(TokenFamily tokenFamily) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(10)); + return refreshToken; + } +} -- 2.49.1 From 1e6af005a9914e28f4bb0afafaeb88f4d8e2a270 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 21:10:22 +0100 Subject: [PATCH 22/25] implement on frontend --- frontend/src/auth/AuthContext.ts | 4 +- frontend/src/auth/AuthProvider.tsx | 97 ++++++++++++++++--- frontend/src/utils/api/client.ts | 65 +++++++++++-- frontend/src/utils/api/users.ts | 28 ++++-- .../configuration/SecurityConfig.java | 11 ++- .../vodsystem/controllers/UserController.java | 17 ++-- .../controllers/dto/SessionResponse.java | 11 +++ .../com/ddf/vodsystem/security/JwtFilter.java | 2 +- 8 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/SessionResponse.java diff --git a/frontend/src/auth/AuthContext.ts b/frontend/src/auth/AuthContext.ts index 9684f2e..1fa7b5d 100644 --- a/frontend/src/auth/AuthContext.ts +++ b/frontend/src/auth/AuthContext.ts @@ -8,9 +8,9 @@ type AuthContextValue = { loading: boolean; /** Exchange a Google credential for a session, then load the user. */ login: (googleCredential: string) => Promise; - /** End the session and clear the user. */ + /** End the session and clear the user. The local session is cleared even if the server call fails. */ logout: () => Promise; - /** Re-fetch the current user (e.g. after a profile change). */ + /** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */ refresh: () => Promise; }; diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index b1ee12e..97c5701 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -1,19 +1,66 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import type { User } from "../utils/types.ts"; import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts"; import { AuthContext } from "./AuthContext.ts"; -import {registerUnauthorizedHandler} from "../utils/api/client.ts"; +import { + refreshSession, + registerSessionRefreshedHandler, + registerUnauthorizedHandler, +} from "../utils/api/client.ts"; + +/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */ +const REFRESH_MARGIN_MS = 60_000; +/** How soon to try again when a scheduled refresh fails because the network is down. */ +const RETRY_DELAY_MS = 30_000; /** * Owns the single source of truth for the authenticated user. Everything that * needs to know who is logged in — the layout, the top bar, protected pages — * reads it through the `useAuth` hook rather than fetching the user itself. + * + * It also keeps the session alive: the access token is refreshed shortly + * before it expires, rather than waiting for a request to fail. That matters + * for

+ * 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)}. * -- 2.49.1