1
0

chore(auth): refresh token and initial logout (broken) support

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-21 08:46:54 -04:00
parent 240964ea1d
commit 435298c013
9 changed files with 279 additions and 17 deletions
@@ -51,6 +51,7 @@ class SecurityConfig {
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
"/api/v1/auth/register", "/api/v1/auth/register",
).permitAll() ).permitAll()
.pathMatchers("/api/v1/auth/logout").authenticated()
.anyExchange() .anyExchange()
.authenticated() .authenticated()
} }
@@ -5,14 +5,19 @@ import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.dto.AuthResponse import io.visus.demos.kotlinapi.api.dto.AuthResponse
import io.visus.demos.kotlinapi.api.dto.ErrorResponse
import io.visus.demos.kotlinapi.api.dto.LoginRequest import io.visus.demos.kotlinapi.api.dto.LoginRequest
import io.visus.demos.kotlinapi.api.dto.RefreshTokenRequest
import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.domain.service.AuthService import io.visus.demos.kotlinapi.domain.service.AuthService
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -39,16 +44,12 @@ class AuthController(
ApiResponse( ApiResponse(
responseCode = "400", responseCode = "400",
description = "Invalid request payload", description = "Invalid request payload",
content = [Content(schema = Schema(implementation = AuthResponse::class))], content = [Content(schema = Schema(implementation = ErrorResponse::class))],
), ),
ApiResponse( ApiResponse(
responseCode = "401", responseCode = "401",
description = "Unauthorized, invalid email or password", description = "Unauthorized, invalid email or password",
content = [Content(schema = Schema(implementation = AuthResponse::class))], content = [Content(schema = Schema(implementation = ErrorResponse::class))],
),
ApiResponse(
responseCode = "403",
description = "Forbidden",
), ),
], ],
) )
@@ -58,4 +59,62 @@ class AuthController(
authService.login(request.email, request.password).let { authService.login(request.email, request.password).let {
ResponseEntity.ok(it) ResponseEntity.ok(it)
} }
@PostMapping("/auth/logout", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Logout",
description = "Revokes all refresh tokens for the authenticated user",
security = [SecurityRequirement(name = "bearerAuth")],
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "204",
description = "Successfully logged out",
),
ApiResponse(
responseCode = "401",
description = "Not authenticated",
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
),
],
)
suspend fun logout(
@AuthenticationPrincipal user: User,
): ResponseEntity<Unit> {
authService.logout(user.id!!)
return ResponseEntity.noContent().build()
}
@PostMapping("/auth/refresh", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Refresh access token",
description = "Get a new access token using a valid refresh token",
security = [],
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Successfully refreshed token",
content = [Content(schema = Schema(implementation = AuthResponse::class))],
),
ApiResponse(
responseCode = "400",
description = "Invalid request payload",
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
),
ApiResponse(
responseCode = "401",
description = "Invalid or expired refresh token",
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
),
],
)
suspend fun refreshToken(
@Valid @RequestBody request: RefreshTokenRequest,
): ResponseEntity<AuthResponse> {
val response = authService.refreshToken(request.refreshToken)
return ResponseEntity.ok(response)
}
} }
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.model package io.visus.demos.kotlinapi.domain.model
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
@@ -15,7 +16,7 @@ data class RefreshToken(
@Indexed(unique = true) @Indexed(unique = true)
val tokenHash: String, val tokenHash: String,
@Indexed @Indexed
val userId: String, val userId: ObjectId,
@Indexed(expireAfter = "30d") @Indexed(expireAfter = "30d")
val expiresAt: Date, val expiresAt: Date,
var isRevoked: Boolean = false, var isRevoked: Boolean = false,
@@ -25,4 +26,5 @@ data class RefreshToken(
val createdAt: Instant? = null, val createdAt: Instant? = null,
@LastModifiedDate @LastModifiedDate
val updatedAt: Instant? = null, val updatedAt: Instant? = null,
var revokedAt: Instant? = null,
) )
@@ -8,6 +8,8 @@ interface AuthService {
password: String, password: String,
): AuthResponse ): AuthResponse
suspend fun logout(userId: String)
suspend fun refreshToken(refreshToken: String): AuthResponse suspend fun refreshToken(refreshToken: String): AuthResponse
suspend fun register( suspend fun register(
@@ -2,6 +2,9 @@ package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.api.dto.AuthResponse import io.visus.demos.kotlinapi.api.dto.AuthResponse
import io.visus.demos.kotlinapi.config.JwtProperties import io.visus.demos.kotlinapi.config.JwtProperties
import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException
import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException
import io.visus.demos.kotlinapi.domain.exception.TokenRevokedException
import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
@@ -9,6 +12,7 @@ import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.UUID
@Service @Service
class AuthServiceImpl( class AuthServiceImpl(
@@ -16,6 +20,7 @@ class AuthServiceImpl(
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenProvider: JwtTokenProvider,
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
private val refreshTokenService: RefreshTokenService,
) : AuthService { ) : AuthService {
override suspend fun login( override suspend fun login(
email: String, email: String,
@@ -29,11 +34,37 @@ class AuthServiceImpl(
throw BadCredentialsException("Invalid password for email: $email") throw BadCredentialsException("Invalid password for email: $email")
} }
return generateAuthResponse(user) return generateAuthResponse(user, newFamily = true)
}
override suspend fun logout(userId: String) {
refreshTokenService.revokeAllUserTokens(userId)
} }
override suspend fun refreshToken(refreshToken: String): AuthResponse { override suspend fun refreshToken(refreshToken: String): AuthResponse {
TODO("Not yet implemented") try {
val email = jwtTokenProvider.extractUsername(refreshToken)
val user =
userRepository.findByEmail(email)
?: throw UsernameNotFoundException("User not found with email: $email")
val validatedToken =
refreshTokenService.validateAndRotate(
userId = user.id!!,
tokenString = refreshToken,
)
return generateAuthResponse(user, newFamily = false, existingFamilyId = validatedToken.familyId)
} catch (e: SecurityException) {
throw e
} catch (e: ExpiredTokenException) {
throw e
} catch (_: TokenRevokedException) {
throw InvalidTokenException("Token has been revoked")
} catch (e: Exception) {
throw InvalidTokenException("Invalid refresh token: {$e.message}")
}
} }
override suspend fun register( override suspend fun register(
@@ -43,9 +74,19 @@ class AuthServiceImpl(
TODO("Not yet implemented") TODO("Not yet implemented")
} }
private fun generateAuthResponse(user: User): AuthResponse { private suspend fun generateAuthResponse(
user: User,
newFamily: Boolean = false,
existingFamilyId: String? = null,
): AuthResponse {
val accessToken = jwtTokenProvider.generateAccessToken(user) val accessToken = jwtTokenProvider.generateAccessToken(user)
val refreshToken = jwtTokenProvider.generateRefreshToken(user)
val familyId = if (newFamily) UUID.randomUUID().toString() else existingFamilyId!!
val refreshToken =
refreshTokenService.createRefreshToken(
userId = user.id!!,
familyId = familyId,
)
return AuthResponse( return AuthResponse(
accessToken = accessToken, accessToken = accessToken,
@@ -3,14 +3,17 @@ package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.model.RefreshToken import io.visus.demos.kotlinapi.domain.model.RefreshToken
interface RefreshTokenService { interface RefreshTokenService {
fun createRefreshToken( suspend fun createRefreshToken(
userId: String, userId: String,
familyId: String, familyId: String,
): String ): String
fun validateAndRotate(tokenString: String): RefreshToken suspend fun validateAndRotate(
userId: String,
tokenString: String,
): RefreshToken
fun revokeAllUserTokens(userId: String) suspend fun revokeAllUserTokens(userId: String)
fun revokeTokenFamily(familyId: String) suspend fun revokeTokenFamily(familyId: String)
} }
@@ -0,0 +1,150 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.config.JwtProperties
import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException
import io.visus.demos.kotlinapi.domain.exception.TokenRevokedException
import io.visus.demos.kotlinapi.domain.model.JwtTokenType
import io.visus.demos.kotlinapi.domain.model.RefreshToken
import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider
import io.visus.demos.kotlinapi.infrastructure.user.RefreshTokenRepository
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import kotlinx.coroutines.flow.collect
import org.bson.types.ObjectId
import org.springframework.stereotype.Service
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
@Service
class RefreshTokenServiceImpl(
private val refreshTokenRepository: RefreshTokenRepository,
private val jwtTokenProvider: JwtTokenProvider,
private val userRepository: UserRepository,
private val jwtProperties: JwtProperties,
) : RefreshTokenService {
companion object {
private const val MAX_SESSIONS_PER_USER = 5
}
override suspend fun createRefreshToken(
userId: String,
familyId: String,
): String {
val activeCount = refreshTokenRepository.countActive(userId)
if (activeCount >= MAX_SESSIONS_PER_USER) {
val sessions =
refreshTokenRepository
.findActive(userId)
.sortedBy { it.createdAt }
val oldest = sessions.first()
oldest.isRevoked = true
oldest.revokedAt = Instant.now()
refreshTokenRepository.save(oldest)
}
val user =
userRepository.findById(userId)
?: throw IllegalArgumentException("User not found: $userId")
val jwtToken = jwtTokenProvider.generateRefreshToken(user)
val tokenHash = hashToken(jwtToken)
val expiresAt =
Date.from(
Instant.now().plusMillis(jwtProperties.refreshTokenExpiration),
)
val refreshToken =
RefreshToken(
tokenHash = tokenHash,
userId = ObjectId(userId),
expiresAt = expiresAt,
familyId = familyId,
)
refreshTokenRepository.save(refreshToken)
return jwtToken
}
override suspend fun validateAndRotate(
userId: String,
tokenString: String,
): RefreshToken {
val user =
userRepository.findById(userId)
?: throw IllegalArgumentException("User not found: $userId")
if (!jwtTokenProvider.isTokenValid(tokenString, user, JwtTokenType.REFRESH)) {
throw IllegalArgumentException("Invalid JWT signature or expired")
}
val tokenHash = hashToken(tokenString)
val storedToken =
refreshTokenRepository.findByTokenHash(tokenHash)
?: throw IllegalArgumentException("Refresh token not found")
if (storedToken.isRevoked) {
val familyTokens = refreshTokenRepository.findByFamilyId(storedToken.familyId)
val hasActiveTokens =
familyTokens.any {
!it.isRevoked
}
if (hasActiveTokens) {
revokeTokenFamily(storedToken.familyId)
throw SecurityException("Token reuse detected. All sessions revoked.")
}
throw TokenRevokedException("Token already used")
}
if (jwtTokenProvider.isTokenExpired(tokenString)) {
throw ExpiredTokenException("Token expired")
}
storedToken.isRevoked = true
storedToken.revokedAt = Instant.now()
refreshTokenRepository.save(storedToken)
return storedToken
}
override suspend fun revokeAllUserTokens(userId: String) {
val tokens = refreshTokenRepository.findActive(userId)
tokens.forEach {
it.isRevoked = true
it.revokedAt = Instant.now()
}
refreshTokenRepository.saveAll(tokens).collect()
}
override suspend fun revokeTokenFamily(familyId: String) {
val tokens = refreshTokenRepository.findByFamilyId(familyId)
tokens.forEach {
it.isRevoked = true
it.revokedAt = Instant.now()
}
refreshTokenRepository.saveAll(tokens).collect()
}
private fun hashToken(token: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(token.toByteArray(StandardCharsets.UTF_8))
return Base64.getEncoder().encodeToString(bytes)
}
}
@@ -76,7 +76,11 @@ class JwtTokenProvider(
.builder() .builder()
.subject(userDetails.username) .subject(userDetails.username)
.claim("type", JwtTokenType.REFRESH.value) .claim("type", JwtTokenType.REFRESH.value)
.issuedAt(now) .id(
java.util.UUID
.randomUUID()
.toString(),
).issuedAt(now)
.expiration(expiry) .expiration(expiry)
.signWith(secretKey, Jwts.SIG.HS512) .signWith(secretKey, Jwts.SIG.HS512)
.compact() .compact()
@@ -8,7 +8,7 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> { interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
suspend fun deleteByUserId(userId: String): Long suspend fun deleteByUserId(userId: String): Long
@Query("{ 'userId': ?0, 'isRevoked': false }") @Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true)
suspend fun countActive(userId: String): Long suspend fun countActive(userId: String): Long
suspend fun findByFamilyId(familyId: String): List<RefreshToken> suspend fun findByFamilyId(familyId: String): List<RefreshToken>