chore(auth): refresh token and initial logout (broken) support
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -51,6 +51,7 @@ class SecurityConfig {
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/register",
|
||||
).permitAll()
|
||||
.pathMatchers("/api/v1/auth/logout").authenticated()
|
||||
.anyExchange()
|
||||
.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.responses.ApiResponse
|
||||
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.visus.demos.kotlinapi.api.ApiVersion
|
||||
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.RefreshTokenRequest
|
||||
import io.visus.demos.kotlinapi.domain.model.User
|
||||
import io.visus.demos.kotlinapi.domain.service.AuthService
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.MediaType
|
||||
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.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
@@ -39,16 +44,12 @@ class AuthController(
|
||||
ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid request payload",
|
||||
content = [Content(schema = Schema(implementation = AuthResponse::class))],
|
||||
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
|
||||
),
|
||||
ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Unauthorized, invalid email or password",
|
||||
content = [Content(schema = Schema(implementation = AuthResponse::class))],
|
||||
),
|
||||
ApiResponse(
|
||||
responseCode = "403",
|
||||
description = "Forbidden",
|
||||
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -58,4 +59,62 @@ class AuthController(
|
||||
authService.login(request.email, request.password).let {
|
||||
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
|
||||
|
||||
import org.bson.types.ObjectId
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
@@ -15,7 +16,7 @@ data class RefreshToken(
|
||||
@Indexed(unique = true)
|
||||
val tokenHash: String,
|
||||
@Indexed
|
||||
val userId: String,
|
||||
val userId: ObjectId,
|
||||
@Indexed(expireAfter = "30d")
|
||||
val expiresAt: Date,
|
||||
var isRevoked: Boolean = false,
|
||||
@@ -25,4 +26,5 @@ data class RefreshToken(
|
||||
val createdAt: Instant? = null,
|
||||
@LastModifiedDate
|
||||
val updatedAt: Instant? = null,
|
||||
var revokedAt: Instant? = null,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ interface AuthService {
|
||||
password: String,
|
||||
): AuthResponse
|
||||
|
||||
suspend fun logout(userId: String)
|
||||
|
||||
suspend fun refreshToken(refreshToken: String): AuthResponse
|
||||
|
||||
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.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.infrastructure.security.JwtTokenProvider
|
||||
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.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class AuthServiceImpl(
|
||||
@@ -16,6 +20,7 @@ class AuthServiceImpl(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val jwtTokenProvider: JwtTokenProvider,
|
||||
private val jwtProperties: JwtProperties,
|
||||
private val refreshTokenService: RefreshTokenService,
|
||||
) : AuthService {
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
@@ -29,11 +34,37 @@ class AuthServiceImpl(
|
||||
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 {
|
||||
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(
|
||||
@@ -43,9 +74,19 @@ class AuthServiceImpl(
|
||||
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 refreshToken = jwtTokenProvider.generateRefreshToken(user)
|
||||
|
||||
val familyId = if (newFamily) UUID.randomUUID().toString() else existingFamilyId!!
|
||||
val refreshToken =
|
||||
refreshTokenService.createRefreshToken(
|
||||
userId = user.id!!,
|
||||
familyId = familyId,
|
||||
)
|
||||
|
||||
return AuthResponse(
|
||||
accessToken = accessToken,
|
||||
|
||||
@@ -3,14 +3,17 @@ package io.visus.demos.kotlinapi.domain.service
|
||||
import io.visus.demos.kotlinapi.domain.model.RefreshToken
|
||||
|
||||
interface RefreshTokenService {
|
||||
fun createRefreshToken(
|
||||
suspend fun createRefreshToken(
|
||||
userId: String,
|
||||
familyId: 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)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -76,7 +76,11 @@ class JwtTokenProvider(
|
||||
.builder()
|
||||
.subject(userDetails.username)
|
||||
.claim("type", JwtTokenType.REFRESH.value)
|
||||
.issuedAt(now)
|
||||
.id(
|
||||
java.util.UUID
|
||||
.randomUUID()
|
||||
.toString(),
|
||||
).issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.signWith(secretKey, Jwts.SIG.HS512)
|
||||
.compact()
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
|
||||
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 findByFamilyId(familyId: String): List<RefreshToken>
|
||||
|
||||
Reference in New Issue
Block a user