Compare commits
1 Commits
19a5a49cbb
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
| 13869c577b |
10
pom.xml
10
pom.xml
@@ -90,16 +90,6 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Database Migration Tools -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package com.ddf.vodsystem;
|
package com.ddf.vodsystem;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableConfigurationProperties(AuthProperties.class)
|
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.controllers.dto.StreamKey;
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
import com.ddf.vodsystem.controllers.dto.Token;
|
import com.ddf.vodsystem.controllers.dto.Token;
|
||||||
import com.ddf.vodsystem.dto.TokenPackage;
|
import com.ddf.vodsystem.entities.Stream;
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.UserService;
|
import com.ddf.vodsystem.services.UserService;
|
||||||
@@ -20,7 +21,7 @@ import java.util.Optional;
|
|||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@Value("${app.security.expiration.jwt}")
|
@Value("${jwt.expiration}")
|
||||||
private long jwtExpiration;
|
private long jwtExpiration;
|
||||||
private static final String SUCCESS = "success";
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
@@ -59,12 +60,12 @@ public class UserController {
|
|||||||
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<TokenPackage>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
TokenPackage tokenPackage = userService.login(token.token());
|
String jwt = userService.login(token.token());
|
||||||
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken())
|
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(jwtExpiration / 1000)
|
.maxAge(jwtExpiration / 1000)
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
.sameSite(request.isSecure() ? "None" : "Lax")
|
||||||
@@ -75,7 +76,7 @@ public class UserController {
|
|||||||
response.addHeader("Set-Cookie", cookie.toString());
|
response.addHeader("Set-Cookie", cookie.toString());
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage)
|
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +104,12 @@ public class UserController {
|
|||||||
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/me/stream-key")
|
||||||
|
public ResponseEntity<APIResponse<StreamKey>> regenerateStreamKey() {
|
||||||
|
String streamKey = userService.regenerateStreamKey();
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Stream key successfully", new StreamKey(streamKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record StreamKey (String streamKey) {
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
public record GoogleUser(
|
|
||||||
String googleId,
|
|
||||||
String email,
|
|
||||||
String name,
|
|
||||||
String profilePictureUrl
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
public record TokenPackage (
|
|
||||||
String refreshToken,
|
|
||||||
String accessToken
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto.properties;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
@Validated
|
|
||||||
@ConfigurationProperties(prefix = "app.security")
|
|
||||||
public record AuthProperties (
|
|
||||||
@NotBlank @Size(min = 32) String jwtSecret,
|
|
||||||
@Valid @NotNull Expiration expiration
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param jwt
|
|
||||||
* @param refreshToken
|
|
||||||
* @param tokenFamily
|
|
||||||
* @param gracePeriod
|
|
||||||
*/
|
|
||||||
public record Expiration (
|
|
||||||
@NotNull Duration jwt,
|
|
||||||
@NotNull Duration refreshToken,
|
|
||||||
@NotNull Duration tokenFamily,
|
|
||||||
@NotNull Duration gracePeriod
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.ddf.vodsystem.entities;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "refresh_tokens")
|
|
||||||
@Data
|
|
||||||
public class RefreshToken {
|
|
||||||
@Id
|
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "family_id", nullable = false)
|
|
||||||
private TokenFamily tokenFamily;
|
|
||||||
|
|
||||||
@Column(name = "token_hash", unique = true, nullable = false, length = 32)
|
|
||||||
private byte[] tokenHash;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
@CreationTimestamp
|
|
||||||
private Instant createdAt;
|
|
||||||
|
|
||||||
@Column(name = "expires_at", nullable = false)
|
|
||||||
private Instant expiresAt;
|
|
||||||
|
|
||||||
@Column(name = "used_at")
|
|
||||||
private Instant usedAt;
|
|
||||||
|
|
||||||
@Column(name = "revoked_at")
|
|
||||||
private Instant revokedAt;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.ddf.vodsystem.entities;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Data;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "token_families")
|
|
||||||
@Data
|
|
||||||
public class TokenFamily {
|
|
||||||
@Id
|
|
||||||
@Column(name = "id", nullable = false, unique = true)
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "user_id", nullable = false)
|
|
||||||
private User user;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
@CreationTimestamp
|
|
||||||
private Instant createdAt;
|
|
||||||
|
|
||||||
@Column(name = "expires_at", nullable = false)
|
|
||||||
private Instant expiresAt;
|
|
||||||
|
|
||||||
@Column(name = "revoked_at")
|
|
||||||
private Instant revokedAt;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.ddf.vodsystem.repositories;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.entities.RefreshToken;
|
|
||||||
import jakarta.persistence.LockModeType;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
|
||||||
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
|
|
||||||
Optional<RefreshToken> findByHash(byte[] tokenHash);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.ddf.vodsystem.repositories;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.entities.TokenFamily;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.ddf.vodsystem.security;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.GoogleUser;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class GoogleVerifierService {
|
|
||||||
private final GoogleIdTokenVerifier verifier;
|
|
||||||
|
|
||||||
public GoogleVerifierService(GoogleIdTokenVerifier verifier) {
|
|
||||||
this.verifier = verifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public GoogleUser verify(String idToken) {
|
|
||||||
GoogleIdToken token;
|
|
||||||
try {
|
|
||||||
token = verifier.verify(idToken);
|
|
||||||
} catch (GeneralSecurityException | IOException e) {
|
|
||||||
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token == null) {
|
|
||||||
throw new NotAuthenticated("Invalid ID token");
|
|
||||||
}
|
|
||||||
|
|
||||||
String googleId = token.getPayload().getSubject();
|
|
||||||
|
|
||||||
if (googleId == null) {
|
|
||||||
throw new NotAuthenticated("Google ID does not exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
|
|
||||||
throw new NotAuthenticated("Email not verified");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new GoogleUser(
|
|
||||||
googleId,
|
|
||||||
token.getPayload().getEmail(),
|
|
||||||
(String) token.getPayload().get("name"),
|
|
||||||
(String) token.getPayload().get("picture")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import com.auth0.jwt.algorithms.Algorithm;
|
|||||||
import com.auth0.jwt.exceptions.JWTDecodeException;
|
import com.auth0.jwt.exceptions.JWTDecodeException;
|
||||||
import com.auth0.jwt.exceptions.TokenExpiredException;
|
import com.auth0.jwt.exceptions.TokenExpiredException;
|
||||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||||
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -21,9 +20,10 @@ public class JwtService {
|
|||||||
private static final String USER_ID_CLAIM = "userId";
|
private static final String USER_ID_CLAIM = "userId";
|
||||||
private static final String ISSUER = "vodsystem";
|
private static final String ISSUER = "vodsystem";
|
||||||
|
|
||||||
public JwtService(AuthProperties props) {
|
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
|
||||||
this.jwtExpiration = props.expiration().jwt().toMillis();
|
@Value("${jwt.expiration}") long jwtExpiration) {
|
||||||
this.algorithm = Algorithm.HMAC256(props.jwtSecret());
|
this.jwtExpiration = jwtExpiration;
|
||||||
|
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package com.ddf.vodsystem.security;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
|
||||||
import com.ddf.vodsystem.entities.RefreshToken;
|
|
||||||
import com.ddf.vodsystem.entities.TokenFamily;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
|
||||||
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
|
|
||||||
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class TokenService {
|
|
||||||
private static final SecureRandom RNG = new SecureRandom();
|
|
||||||
private static final HexFormat HEX = HexFormat.of();
|
|
||||||
|
|
||||||
private final Clock clock;
|
|
||||||
|
|
||||||
private final TokenFamilyRepository tokenFamilyRepository;
|
|
||||||
private final RefreshTokenRepository refreshTokenRepository;
|
|
||||||
|
|
||||||
private final Duration tokenFamilyExpiration;
|
|
||||||
private final Duration refreshTokenExpiration;
|
|
||||||
private final Duration refreshGracePeriod;
|
|
||||||
|
|
||||||
public TokenService (
|
|
||||||
TokenFamilyRepository tokenFamilyRepository,
|
|
||||||
RefreshTokenRepository refreshTokenRepository,
|
|
||||||
AuthProperties props,
|
|
||||||
Clock clock
|
|
||||||
) {
|
|
||||||
this.tokenFamilyRepository = tokenFamilyRepository;
|
|
||||||
this.refreshTokenRepository = refreshTokenRepository;
|
|
||||||
this.clock = clock;
|
|
||||||
|
|
||||||
this.refreshTokenExpiration = props.expiration().refreshToken();
|
|
||||||
this.tokenFamilyExpiration = props.expiration().tokenFamily();
|
|
||||||
this.refreshGracePeriod = props.expiration().gracePeriod();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public String createSession(User user) {
|
|
||||||
Instant now = Instant.now(clock);
|
|
||||||
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
|
||||||
tokenFamily.setUser(user);
|
|
||||||
tokenFamily.setCreatedAt(now);
|
|
||||||
tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
|
|
||||||
tokenFamilyRepository.save(tokenFamily);
|
|
||||||
|
|
||||||
byte[] token = generateRandomBytes(32);
|
|
||||||
|
|
||||||
createRefreshToken(
|
|
||||||
token,
|
|
||||||
tokenFamily
|
|
||||||
);
|
|
||||||
|
|
||||||
return HEX.formatHex(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(noRollbackFor = NotAuthenticated.class)
|
|
||||||
public Rotation rotate(String hexToken) {
|
|
||||||
if (hexToken == null || hexToken.isBlank()) {
|
|
||||||
throw new NotAuthenticated("Missing refresh token");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] hash;
|
|
||||||
try {
|
|
||||||
hash = sha256(HEX.parseHex(hexToken));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new NotAuthenticated("Malformed refresh token");
|
|
||||||
}
|
|
||||||
|
|
||||||
Instant now = Instant.now(clock);
|
|
||||||
|
|
||||||
// Check Token is real
|
|
||||||
RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
|
|
||||||
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
|
|
||||||
|
|
||||||
// Find Token Family
|
|
||||||
TokenFamily tokenFamily = refreshToken.getTokenFamily();
|
|
||||||
|
|
||||||
if (tokenFamily.getRevokedAt() != null) {
|
|
||||||
throw new NotAuthenticated("Token family has been revoked");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reuse Check
|
|
||||||
if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
|
|
||||||
tokenFamily.setRevokedAt(now);
|
|
||||||
tokenFamilyRepository.save(tokenFamily);
|
|
||||||
throw new NotAuthenticated("Refresh token already being used");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiry Check
|
|
||||||
if (
|
|
||||||
now.isAfter(refreshToken.getExpiresAt()) ||
|
|
||||||
now.isAfter(tokenFamily.getExpiresAt())
|
|
||||||
) {
|
|
||||||
throw new NotAuthenticated("Refresh token or family expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
|
|
||||||
if (refreshToken.getUsedAt() == null) {
|
|
||||||
refreshToken.setUsedAt(now);
|
|
||||||
refreshTokenRepository.save(refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] newRawToken = generateRandomBytes(32);
|
|
||||||
createRefreshToken(
|
|
||||||
newRawToken,
|
|
||||||
tokenFamily
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
public record Rotation(Long userId, String newRefreshToken) {}
|
|
||||||
|
|
||||||
private static byte[] sha256(byte[] in) {
|
|
||||||
try {
|
|
||||||
return MessageDigest.getInstance("SHA-256").digest(in);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] generateRandomBytes(int length) {
|
|
||||||
byte[] bytes = new byte[length];
|
|
||||||
RNG.nextBytes(bytes);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private RefreshToken createRefreshToken(
|
|
||||||
byte[] token,
|
|
||||||
TokenFamily tokenFamily
|
|
||||||
) {
|
|
||||||
Instant now = Instant.now(clock);
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setTokenHash(sha256(token));
|
|
||||||
refreshToken.setCreatedAt(now);
|
|
||||||
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
|
|
||||||
return refreshTokenRepository.save(refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -78,8 +78,8 @@ public class MarkerService {
|
|||||||
* @return the persisted {@link Marker}
|
* @return the persisted {@link Marker}
|
||||||
* @throws NotStreaming if the user has no active stream to mark
|
* @throws NotStreaming if the user has no active stream to mark
|
||||||
*/
|
*/
|
||||||
public Marker create(String message) {
|
public Marker create(User user, String message) {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream(user);
|
||||||
|
|
||||||
if (stream.isEmpty()) {
|
if (stream.isEmpty()) {
|
||||||
throw new NotStreaming("User must be streaming to mark");
|
throw new NotStreaming("User must be streaming to mark");
|
||||||
|
|||||||
@@ -90,19 +90,14 @@ public class StreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the current user's active (not-yet-ended) stream, if any.
|
* Returns the user's active (not-yet-ended) stream, if any.
|
||||||
*
|
*
|
||||||
|
* @param user the User object
|
||||||
* @return the active {@link Stream}, or empty if the user is not currently streaming
|
* @return the active {@link Stream}, or empty if the user is not currently streaming
|
||||||
* @throws NotAuthenticated if no user session is present
|
* @throws NotAuthenticated if no user session is present
|
||||||
*/
|
*/
|
||||||
public Optional<Stream> getActiveStream() {
|
public Optional<Stream> getActiveStream(User user) {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
return streamRepository.findByUserAndEndDateIsNull(user);
|
||||||
|
|
||||||
if (user.isEmpty()) {
|
|
||||||
throw new NotAuthenticated("Log in to see user streams");
|
|
||||||
}
|
|
||||||
|
|
||||||
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,40 +1,47 @@
|
|||||||
package com.ddf.vodsystem.services;
|
package com.ddf.vodsystem.services;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.GoogleUser;
|
|
||||||
import com.ddf.vodsystem.dto.TokenPackage;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.repositories.UserRepository;
|
import com.ddf.vodsystem.repositories.UserRepository;
|
||||||
import com.ddf.vodsystem.security.GoogleVerifierService;
|
|
||||||
import com.ddf.vodsystem.security.JwtService;
|
import com.ddf.vodsystem.security.JwtService;
|
||||||
import com.ddf.vodsystem.security.TokenService;
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||||
|
import com.google.api.client.json.JsonFactory;
|
||||||
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
private final GoogleVerifierService googleVerifierService;
|
private final GoogleIdTokenVerifier verifier;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
|
||||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
private final TokenService tokenService;
|
|
||||||
|
|
||||||
public UserService(UserRepository userRepository,
|
public UserService(UserRepository userRepository,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
GoogleVerifierService googleVerifierService,
|
@Value("${google.client.id}") String googleClientId) {
|
||||||
TokenService tokenService) {
|
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.googleVerifierService = googleVerifierService;
|
|
||||||
|
NetHttpTransport transport = new NetHttpTransport();
|
||||||
|
JsonFactory jsonFactory = new GsonFactory();
|
||||||
|
|
||||||
|
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
|
||||||
|
.setAudience(Collections.singletonList(googleClientId))
|
||||||
|
.build();
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.tokenService = tokenService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,22 +74,18 @@ public class UserService {
|
|||||||
* @return a signed JWT for the authenticated user
|
* @return a signed JWT for the authenticated user
|
||||||
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
||||||
*/
|
*/
|
||||||
@Transactional
|
public String login(String idToken) {
|
||||||
public TokenPackage login(String idToken) {
|
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
||||||
GoogleUser googleUser = googleVerifierService.verify(idToken);
|
String googleId = googleIdToken.getPayload().getSubject();
|
||||||
User user = createOrUpdateGoogleUser(googleUser);
|
|
||||||
|
|
||||||
String refreshToken = tokenService.createSession(user);
|
if (googleId == null) {
|
||||||
String jwt = jwtService.generateToken(user.getId());
|
throw new NotAuthenticated("Invalid ID token");
|
||||||
|
|
||||||
return new TokenPackage(refreshToken, jwt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public TokenPackage refresh(String refreshToken) {
|
User googleUser = getGoogleUser(googleIdToken);
|
||||||
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
|
User user = createOrUpdateUser(googleUser);
|
||||||
String jwt = jwtService.generateToken(rotation.userId());
|
|
||||||
|
|
||||||
return new TokenPackage(rotation.newRefreshToken(), jwt);
|
return jwtService.generateToken(user.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,30 +98,57 @@ public class UserService {
|
|||||||
return userRepository.findByStreamKey(streamKey);
|
return userRepository.findByStreamKey(streamKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createOrUpdateGoogleUser(GoogleUser googleUser) {
|
public String regenerateStreamKey(User user) {
|
||||||
Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId());
|
user.setStreamKey(generateStreamKey());
|
||||||
User user;
|
userRepository.saveAndFlush(user);
|
||||||
|
return user.getStreamKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private User createOrUpdateUser(User user) {
|
||||||
|
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
||||||
|
|
||||||
if (existingUser.isEmpty()) {
|
if (existingUser.isEmpty()) {
|
||||||
user = new User();
|
|
||||||
user.setGoogleId(googleUser.googleId());
|
|
||||||
user.setUsername(googleUser.email());
|
|
||||||
user.setRole(0);
|
user.setRole(0);
|
||||||
user.setCreatedAt(Instant.now());
|
user.setCreatedAt(Instant.now());
|
||||||
user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
|
user.setStreamKey(generateStreamKey());
|
||||||
} else {
|
return userRepository.saveAndFlush(user);
|
||||||
user = existingUser.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setEmail(googleUser.email());
|
User existing = existingUser.get();
|
||||||
user.setName(googleUser.name());
|
existing.setEmail(user.getEmail());
|
||||||
user.setProfilePictureUrl(googleUser.profilePictureUrl());
|
existing.setName(user.getName());
|
||||||
return userRepository.save(user);
|
existing.setProfilePictureUrl(user.getProfilePictureUrl());
|
||||||
|
existing.setUsername(user.getUsername());
|
||||||
|
return userRepository.saveAndFlush(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] generateRandomBytes(int length) {
|
private User getGoogleUser(GoogleIdToken idToken) {
|
||||||
byte[] bytes = new byte[length];
|
String googleId = idToken.getPayload().getSubject();
|
||||||
|
String email = idToken.getPayload().getEmail();
|
||||||
|
String name = (String) idToken.getPayload().get("name");
|
||||||
|
String profilePictureUrl = (String) idToken.getPayload().get("picture");
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setGoogleId(googleId);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setName(name);
|
||||||
|
user.setUsername(email);
|
||||||
|
user.setProfilePictureUrl(profilePictureUrl);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GoogleIdToken getGoogleIdToken(String idToken) {
|
||||||
|
try {
|
||||||
|
return verifier.verify(idToken);
|
||||||
|
} catch (GeneralSecurityException | IOException e) {
|
||||||
|
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateStreamKey() {
|
||||||
|
byte[] bytes = new byte[24];
|
||||||
SECURE_RANDOM.nextBytes(bytes);
|
SECURE_RANDOM.nextBytes(bytes);
|
||||||
return bytes;
|
return HexFormat.of().formatHex(bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,26 +10,17 @@ spring.datasource.url=${POSTGRES_URL}
|
|||||||
spring.datasource.username=${POSTGRES_USER}
|
spring.datasource.username=${POSTGRES_USER}
|
||||||
spring.datasource.password=${POSTGRES_PASSWORD}
|
spring.datasource.password=${POSTGRES_PASSWORD}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
spring.jpa.show-sql=false
|
spring.jpa.show-sql=false
|
||||||
|
spring.sql.init.mode=always
|
||||||
spring.flyway.enabled=true
|
spring.sql.init.schema-locations=classpath:db/schema.sql
|
||||||
spring.flyway.locations=classpath:db/migrations
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
||||||
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
||||||
spring.security.oauth2.client.registration.google.scope=profile,email
|
spring.security.oauth2.client.registration.google.scope=profile,email
|
||||||
app.security.jwt-secret=${JWT_SECRET_KEY}
|
jwt.secret.key=${JWT_SECRET_KEY}
|
||||||
# 1 Hour
|
jwt.expiration=3600000
|
||||||
app.security.expiration.jwt=3600000
|
|
||||||
# 7 days
|
|
||||||
app.security.expiration.refresh-token=604800000
|
|
||||||
# 90 days
|
|
||||||
app.security.expiration.token-family=7776000000
|
|
||||||
|
|
||||||
# 5 seconds
|
|
||||||
app.security.expiration.grace-period=5000
|
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
frontend.url=${FRONTEND_URL}
|
frontend.url=${FRONTEND_URL}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS token_families
|
|
||||||
(
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_token_families_user_id
|
|
||||||
ON token_families (user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens
|
|
||||||
(
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
family_id BIGINT NOT NULL REFERENCES token_families (id) ON DELETE CASCADE,
|
|
||||||
token_hash BYTEA NOT NULL UNIQUE, -- unique automatically creates a B-tree index
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id
|
|
||||||
ON refresh_tokens (family_id);
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
-- Users Table
|
-- DROP TABLE IF EXISTS streams;
|
||||||
|
-- DROP TABLE IF EXISTS clips;
|
||||||
|
-- DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
google_id VARCHAR(64),
|
google_id VARCHAR(64),
|
||||||
@@ -11,8 +14,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
stream_key VARCHAR(64) NOT NULL UNIQUE
|
stream_key VARCHAR(64) NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
-- Streams Table
|
|
||||||
CREATE TABLE IF NOT EXISTS streams (
|
CREATE TABLE IF NOT EXISTS streams (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT,
|
user_id BIGINT,
|
||||||
@@ -23,7 +24,6 @@ CREATE TABLE IF NOT EXISTS streams (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Clips Table
|
|
||||||
CREATE TABLE IF NOT EXISTS clips (
|
CREATE TABLE IF NOT EXISTS clips (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
@@ -40,7 +40,6 @@ CREATE TABLE IF NOT EXISTS clips (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- VoDs Table
|
|
||||||
CREATE TABLE IF NOT EXISTS vods (
|
CREATE TABLE IF NOT EXISTS vods (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
@@ -57,7 +56,6 @@ CREATE TABLE IF NOT EXISTS vods (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Markers Table
|
|
||||||
CREATE TABLE IF NOT EXISTS markers (
|
CREATE TABLE IF NOT EXISTS markers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package com.ddf.vodsystem.security;
|
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
|
||||||
import com.ddf.vodsystem.entities.RefreshToken;
|
|
||||||
import com.ddf.vodsystem.entities.TokenFamily;
|
|
||||||
import com.ddf.vodsystem.entities.User;
|
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
|
||||||
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
|
|
||||||
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.Captor;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import java.time.*;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class MutableClock extends Clock {
|
|
||||||
private Instant now;
|
|
||||||
|
|
||||||
MutableClock(Instant start) {
|
|
||||||
this.now = start;
|
|
||||||
}
|
|
||||||
|
|
||||||
void advance(Duration d) {
|
|
||||||
now = now.plus(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public Instant instant() { return now; }
|
|
||||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
|
||||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class TokenServiceTest {
|
|
||||||
@Mock private TokenFamilyRepository tokenFamilyRepository;
|
|
||||||
@Mock private RefreshTokenRepository refreshTokenRepository;
|
|
||||||
|
|
||||||
@Captor ArgumentCaptor<TokenFamily> familyCaptor;
|
|
||||||
@Captor ArgumentCaptor<RefreshToken> tokenCaptor;
|
|
||||||
|
|
||||||
private static final HexFormat HEX = HexFormat.of();
|
|
||||||
private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef";
|
|
||||||
|
|
||||||
AuthProperties authProperties;
|
|
||||||
TokenService tokenService;
|
|
||||||
MutableClock clock;
|
|
||||||
|
|
||||||
User createUser(Long id, String username, String email) {
|
|
||||||
User user = new User();
|
|
||||||
user.setId(id);
|
|
||||||
user.setUsername(username);
|
|
||||||
user.setEmail(email);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
|
|
||||||
authProperties = new AuthProperties(
|
|
||||||
"a-secret",
|
|
||||||
new AuthProperties.Expiration(
|
|
||||||
Duration.ofSeconds(10),
|
|
||||||
Duration.ofSeconds(10),
|
|
||||||
Duration.ofSeconds(10),
|
|
||||||
Duration.ofSeconds(10)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
|
|
||||||
|
|
||||||
tokenService = new TokenService(
|
|
||||||
tokenFamilyRepository,
|
|
||||||
refreshTokenRepository,
|
|
||||||
authProperties,
|
|
||||||
clock
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("createSession()")
|
|
||||||
class createSession {
|
|
||||||
@Test
|
|
||||||
void createSession_createsTokenFamily() {
|
|
||||||
User user = createUser(1L, "user", "user@gmail.com");
|
|
||||||
|
|
||||||
tokenService.createSession(user);
|
|
||||||
|
|
||||||
verify(tokenFamilyRepository).save(familyCaptor.capture());
|
|
||||||
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
|
||||||
|
|
||||||
TokenFamily tokenFamily = familyCaptor.getValue();
|
|
||||||
RefreshToken refreshToken = tokenCaptor.getValue();
|
|
||||||
|
|
||||||
assertSame(tokenFamily.getUser(), user);
|
|
||||||
assertSame(refreshToken.getTokenFamily(), tokenFamily);
|
|
||||||
assertEquals(
|
|
||||||
tokenFamily.getExpiresAt(),
|
|
||||||
clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createSession_savesRefreshTokenCreatedAtAndExpires() {
|
|
||||||
User user = createUser(1L, "user", "user");
|
|
||||||
|
|
||||||
tokenService.createSession(user);
|
|
||||||
|
|
||||||
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
|
||||||
RefreshToken refreshToken = tokenCaptor.getValue();
|
|
||||||
|
|
||||||
assertEquals(refreshToken.getCreatedAt(), clock.instant());
|
|
||||||
assertEquals(
|
|
||||||
refreshToken.getExpiresAt(),
|
|
||||||
clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createSession_returnsHexTokenOf64Characters() {
|
|
||||||
User user = createUser(1L, "user", "user");
|
|
||||||
|
|
||||||
String token = tokenService.createSession(user);
|
|
||||||
|
|
||||||
assertEquals(64, token.length());
|
|
||||||
assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createSession_doesNotStoreRawToken() {
|
|
||||||
User user = createUser(1L, "user", "user");
|
|
||||||
|
|
||||||
String token = tokenService.createSession(user);
|
|
||||||
|
|
||||||
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
|
||||||
RefreshToken refreshToken = tokenCaptor.getValue();
|
|
||||||
|
|
||||||
assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate
|
|
||||||
@Nested
|
|
||||||
@DisplayName("rotate()")
|
|
||||||
class rotate {
|
|
||||||
// Input val
|
|
||||||
@Test
|
|
||||||
void nullToken_throwsNotAuthenticated() {
|
|
||||||
assertThrows(
|
|
||||||
NotAuthenticated.class,
|
|
||||||
() -> tokenService.rotate(null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void blankToken_throwsNotAuthenticated() {
|
|
||||||
assertThrows(
|
|
||||||
NotAuthenticated.class,
|
|
||||||
() -> tokenService.rotate("")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid cases
|
|
||||||
@Test
|
|
||||||
void unknownToken_throwsNotAuthenticated() {
|
|
||||||
when(refreshTokenRepository.findByHash(any())).thenReturn(
|
|
||||||
Optional.empty()
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThrows(
|
|
||||||
NotAuthenticated.class,
|
|
||||||
() -> tokenService.rotate(rawToken)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void revokedFamily_throwsNotAuthenticated() {
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
|
||||||
tokenFamily.setId(1L);
|
|
||||||
tokenFamily.setRevokedAt(Instant.now(clock));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
|
|
||||||
clock.advance(Duration.ofMinutes(1));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any())).thenReturn(
|
|
||||||
Optional.of(refreshToken)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThrows(
|
|
||||||
NotAuthenticated.class,
|
|
||||||
() -> tokenService.rotate(rawToken)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Happy Path
|
|
||||||
@Test
|
|
||||||
void unusedToken_returnsUserIdAndRefreshToken() {
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
|
||||||
tokenFamily.setId(1L);
|
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
|
||||||
.thenReturn(Optional.of(refreshToken));
|
|
||||||
|
|
||||||
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
|
||||||
|
|
||||||
assertEquals(1L, rotation.userId());
|
|
||||||
assertNotEquals(rawToken, rotation.newRefreshToken());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unsedToken_savesRefreshToken() {
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
|
||||||
tokenFamily.setId(1L);
|
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
|
||||||
.thenReturn(Optional.of(refreshToken));
|
|
||||||
|
|
||||||
tokenService.rotate(rawToken);
|
|
||||||
|
|
||||||
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
|
|
||||||
|
|
||||||
RefreshToken newToken = tokenCaptor.getAllValues().stream()
|
|
||||||
.filter(t -> t.getUsedAt() == null)
|
|
||||||
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
|
|
||||||
.orElseThrow(() -> new AssertionError("no unused token was saved"));
|
|
||||||
|
|
||||||
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
|
||||||
assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner
|
|
||||||
assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one
|
|
||||||
assertNull(newToken.getUsedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void ununsedToken_savesOldTokenWithUsedAt() {
|
|
||||||
TokenFamily tokenFamily = new TokenFamily();
|
|
||||||
tokenFamily.setId(1L);
|
|
||||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
|
||||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
RefreshToken refreshToken = new RefreshToken();
|
|
||||||
refreshToken.setTokenFamily(tokenFamily);
|
|
||||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
|
||||||
|
|
||||||
when(refreshTokenRepository.findByHash(any()))
|
|
||||||
.thenReturn(Optional.of(refreshToken));
|
|
||||||
|
|
||||||
tokenService.rotate(rawToken);
|
|
||||||
|
|
||||||
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
|
|
||||||
|
|
||||||
RefreshToken savedToken = tokenCaptor.getAllValues().stream()
|
|
||||||
.filter(t -> t.getUsedAt() != null)
|
|
||||||
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
|
|
||||||
.orElseThrow(() -> new AssertionError("used_at field not set in any token"));
|
|
||||||
|
|
||||||
assertEquals(savedToken.getUsedAt(), clock.instant());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.services;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class UserServiceTest {
|
|
||||||
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user