From e6cc8267d59cfdf12b654c33591896307353b54f Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Thu, 26 Mar 2026 07:24:32 -0400 Subject: [PATCH] chore: migrate to UUIDv7 Signed-off-by: Alan Brault --- .../demos/kotlinapi/config/MongoConfig.kt | 12 +++++++ .../kotlinapi/converters/UuidConverters.kt | 33 +++++++++++++++++++ .../kotlinapi/domain/model/RefreshToken.kt | 3 +- .../kotlinapi/domain/model/RevokedToken.kt | 17 ++++++++++ .../domain/service/AuthServiceImpl.kt | 5 +-- .../domain/service/RefreshTokenService.kt | 5 +-- .../domain/service/RefreshTokenServiceImpl.kt | 4 +-- .../security/JwtTokenProvider.kt | 17 +++++++--- .../user/RefreshTokenRepository.kt | 3 +- 9 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt index 0207caa..c2b88d0 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt @@ -4,10 +4,13 @@ import com.mongodb.ConnectionString import com.mongodb.MongoClientSettings import com.mongodb.client.MongoClient import com.mongodb.client.MongoClients +import io.visus.demos.kotlinapi.converters.BinaryToUuidConverter +import io.visus.demos.kotlinapi.converters.UuidToBinaryConverter import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.mongodb.config.EnableMongoAuditing +import org.springframework.data.mongodb.core.convert.MongoCustomConversions import java.util.concurrent.TimeUnit @Configuration @@ -33,4 +36,13 @@ class MongoConfig { return MongoClients.create(settings) } + + @Bean + fun mongoCustomConversions(): MongoCustomConversions = + MongoCustomConversions( + listOf( + UuidToBinaryConverter(), + BinaryToUuidConverter(), + ), + ) } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt b/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt new file mode 100644 index 0000000..ca612c4 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt @@ -0,0 +1,33 @@ +package io.visus.demos.kotlinapi.converters + +import org.bson.BsonBinarySubType +import org.bson.types.Binary +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.data.convert.WritingConverter +import java.nio.ByteBuffer +import java.util.UUID + +@WritingConverter +class UuidToBinaryConverter : Converter { + override fun convert(source: UUID): Binary { + val buffer = ByteBuffer.wrap(ByteArray(16)) + + buffer.putLong(source.mostSignificantBits) + buffer.putLong(source.leastSignificantBits) + + return Binary(BsonBinarySubType.UUID_STANDARD, buffer.array()) + } +} + +@ReadingConverter +class BinaryToUuidConverter : Converter { + override fun convert(source: Binary): UUID { + val buffer = ByteBuffer.wrap(source.data) + + val high = buffer.long + val low = buffer.long + + return UUID(high, low) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt index 077a67e..250891c 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt @@ -8,6 +8,7 @@ import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.mongodb.core.mapping.Document import java.time.Instant import java.util.Date +import java.util.UUID @Document(collection = "refresh_tokens") data class RefreshToken( @@ -21,7 +22,7 @@ data class RefreshToken( val expiresAt: Date, var isRevoked: Boolean = false, @Indexed - val familyId: String, + val familyId: UUID, @CreatedDate val createdAt: Instant? = null, @LastModifiedDate diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt new file mode 100644 index 0000000..3dd24fb --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt @@ -0,0 +1,17 @@ +package io.visus.demos.kotlinapi.domain.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document +import java.util.Date + +@Document(collection = "revoked_access_tokens") +data class RevokedToken( + @Id + val id: String? = null, + @Indexed(unique = true) + val jti: String, + val userId: String, + @Indexed(expireAfter = "0s") + val expiresAt: Date, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt index f2fa3c3..0326e45 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt @@ -1,5 +1,6 @@ package io.visus.demos.kotlinapi.domain.service +import com.fasterxml.uuid.Generators import io.visus.demos.kotlinapi.api.contracts.AuthResponse import io.visus.demos.kotlinapi.api.contracts.RegisterResponse import io.visus.demos.kotlinapi.config.JwtProperties @@ -96,11 +97,11 @@ class AuthServiceImpl( private suspend fun generateAuthResponse( user: User, newFamily: Boolean = false, - existingFamilyId: String? = null, + existingFamilyId: UUID? = null, ): AuthResponse { val accessToken = jwtTokenProvider.generateAccessToken(user) - val familyId = if (newFamily) UUID.randomUUID().toString() else existingFamilyId!! + val familyId = if (newFamily) Generators.timeBasedEpochRandomGenerator().generate() else existingFamilyId!! val refreshToken = refreshTokenService.createRefreshToken( userId = user.id!!, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt index 2f768c1..996c660 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt @@ -1,11 +1,12 @@ package io.visus.demos.kotlinapi.domain.service import io.visus.demos.kotlinapi.domain.model.RefreshToken +import java.util.UUID interface RefreshTokenService { suspend fun createRefreshToken( userId: String, - familyId: String, + familyId: UUID, ): String suspend fun getActiveSessionCount(userId: String): Long @@ -16,7 +17,7 @@ interface RefreshTokenService { suspend fun revokeAllUserTokens(userId: String) - suspend fun revokeTokenFamily(familyId: String) + suspend fun revokeTokenFamily(familyId: UUID) suspend fun validateAndRotate( userId: String, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt index d4c3678..ba74679 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt @@ -31,7 +31,7 @@ class RefreshTokenServiceImpl( override suspend fun createRefreshToken( userId: String, - familyId: String, + familyId: UUID, ): String { val activeCount = refreshTokenRepository.countActive(ObjectId(userId)) @@ -88,7 +88,7 @@ class RefreshTokenServiceImpl( }, ) - override suspend fun revokeTokenFamily(familyId: String) { + override suspend fun revokeTokenFamily(familyId: UUID) { val tokens = refreshTokenRepository.findByFamilyId(familyId) tokens.forEach { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt index 8eca4ac..1fc3dae 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt @@ -1,5 +1,6 @@ package io.visus.demos.kotlinapi.infrastructure.security +import com.fasterxml.uuid.Generators import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts @@ -44,6 +45,8 @@ class JwtTokenProvider( fun extractExpiration(token: String): Date = extractClaim(token, Claims::getExpiration) + fun extractJti(token: String): String? = extractClaim(token, Claims::getId) + fun extractUsername(token: String): String = extractClaim(token, Claims::getSubject) fun extractTokenType(token: String): JwtTokenType? = @@ -61,7 +64,12 @@ class JwtTokenProvider( .subject(userDetails.username) .claim("type", JwtTokenType.ACCESS.value) .claim("authorities", userDetails.authorities.map { it.authority }) - .issuedAt(now) + .id( + Generators + .timeBasedEpochGenerator() + .generate() + .toString(), // UUIDv7 + ).issuedAt(now) .expiration(expiry) .issuer(jwtProperties.issuer) .signWith(secretKey, Jwts.SIG.HS512) @@ -77,9 +85,10 @@ class JwtTokenProvider( .subject(userDetails.username) .claim("type", JwtTokenType.REFRESH.value) .id( - java.util.UUID - .randomUUID() - .toString(), + Generators + .timeBasedEpochGenerator() + .generate() + .toString(), // UUIDv7 ).issuedAt(now) .expiration(expiry) .signWith(secretKey, Jwts.SIG.HS512) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt index f1daf02..03614f4 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt @@ -6,12 +6,13 @@ import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.Update import org.springframework.data.repository.kotlin.CoroutineCrudRepository import java.time.Instant +import java.util.UUID interface RefreshTokenRepository : CoroutineCrudRepository { @Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true) suspend fun countActive(userId: ObjectId): Long - suspend fun findByFamilyId(familyId: String): List + suspend fun findByFamilyId(familyId: UUID): List suspend fun findByTokenHash(tokenHash: String): RefreshToken?