1
0

chore(auth): add revoked access token checks

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-27 07:23:34 -04:00
parent e6cc8267d5
commit 756a80c5b9
11 changed files with 141 additions and 33 deletions
@@ -14,15 +14,18 @@ import io.visus.demos.kotlinapi.api.contracts.LoginRequest
import io.visus.demos.kotlinapi.api.contracts.RefreshTokenRequest
import io.visus.demos.kotlinapi.api.contracts.RegisterRequest
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException
import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.domain.service.AuthService
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
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
import org.springframework.web.server.ServerWebExchange
@RestController
@ApiVersion(version = 1)
@@ -83,8 +86,15 @@ class AuthController(
)
suspend fun logout(
@AuthenticationPrincipal user: User,
exchange: ServerWebExchange,
): ResponseEntity<Unit> {
authService.logout(user.id!!)
val authHeader =
exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
?: throw InvalidTokenException("Authorization header is missing")
val accessToken = authHeader.removePrefix("Bearer ")
authService.logout(user.id!!, accessToken)
return ResponseEntity.noContent().build()
}
@@ -39,7 +39,7 @@ class UsersController(
val response =
users
.mapNotNull { user ->
.map { user ->
UserResponse(
id = user.id ?: "",
email = user.email,
@@ -1,17 +1,19 @@
package io.visus.demos.kotlinapi.domain.model
import org.bson.types.ObjectId
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
import java.util.UUID
@Document(collection = "revoked_access_tokens")
data class RevokedToken(
@Id
val id: String? = null,
@Indexed(unique = true)
val jti: String,
val userId: String,
val jti: UUID,
val userId: ObjectId,
@Indexed(expireAfter = "0s")
val expiresAt: Date,
)
@@ -9,7 +9,10 @@ interface AuthService {
password: String,
): AuthResponse
suspend fun logout(userId: String)
suspend fun logout(
userId: String,
accessToken: String,
)
suspend fun refreshToken(refreshToken: String): AuthResponse
@@ -23,6 +23,7 @@ class AuthServiceImpl(
private val jwtTokenProvider: JwtTokenProvider,
private val jwtProperties: JwtProperties,
private val refreshTokenService: RefreshTokenService,
private val tokenRevocationService: TokenRevocationService,
) : AuthService {
override suspend fun login(
email: String,
@@ -39,7 +40,22 @@ class AuthServiceImpl(
return generateAuthResponse(user, newFamily = true)
}
override suspend fun logout(userId: String) {
override suspend fun logout(
userId: String,
accessToken: String,
) {
val jti =
jwtTokenProvider.extractJti(accessToken)
?: throw InvalidTokenException("Access token missing jti claim")
val expiresAt = jwtTokenProvider.extractExpiration(accessToken)
tokenRevocationService.revokeAccessToken(
jti = jti,
userId = userId,
expiresAt = expiresAt,
)
refreshTokenService.revokeAllUserTokens(userId)
}
@@ -0,0 +1,14 @@
package io.visus.demos.kotlinapi.domain.service
import java.util.Date
import java.util.UUID
interface TokenRevocationService {
suspend fun revokeAccessToken(
jti: UUID,
userId: String,
expiresAt: Date,
)
suspend fun isRevoked(jti: UUID): Boolean
}
@@ -0,0 +1,30 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.model.RevokedToken
import io.visus.demos.kotlinapi.infrastructure.user.RevokedTokenRepository
import org.bson.types.ObjectId
import org.springframework.stereotype.Service
import java.util.Date
import java.util.UUID
@Service
class TokenRevocationServiceImpl(
private val revokedTokenRepository: RevokedTokenRepository,
) : TokenRevocationService {
override suspend fun revokeAccessToken(
jti: UUID,
userId: String,
expiresAt: Date,
) {
val revokedToken =
RevokedToken(
jti = jti,
userId = ObjectId(userId),
expiresAt = expiresAt,
)
revokedTokenRepository.save(revokedToken)
}
override suspend fun isRevoked(jti: UUID): Boolean = revokedTokenRepository.existsByJti(jti)
}
@@ -1,7 +1,9 @@
package io.visus.demos.kotlinapi.infrastructure.security
import io.visus.demos.kotlinapi.domain.model.JwtTokenType
import io.visus.demos.kotlinapi.domain.service.TokenRevocationService
import io.visus.demos.kotlinapi.domain.service.UserService
import kotlinx.coroutines.reactor.mono
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
@@ -16,6 +18,7 @@ import reactor.core.publisher.Mono
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val userService: UserService,
private val tokenRevocationService: TokenRevocationService,
) : WebFilter {
private val logger = LoggerFactory.getLogger(JwtAuthenticationFilter::class.java)
@@ -29,33 +32,45 @@ class JwtAuthenticationFilter(
): Mono<Void> {
val jwt = extractJwtFromRequest(exchange)
return if (jwt != null && isValidAccessToken(jwt)) {
val username = jwtTokenProvider.extractUsername(jwt)
userService
.findByUsername(username)
.flatMap { userDetails ->
val authentication =
UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities,
)
logger.debug("Set authentication for user: {}", username)
// Set authentication in reactive security context
chain
.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))
}.onErrorResume { ex ->
logger.error("Could not set user authentication in security context", ex)
chain.filter(exchange)
}
} else {
// No JWT or invalid JWT, continue without authentication
chain.filter(exchange)
if (jwt == null || !isValidAccessToken(jwt)) {
return chain.filter(exchange)
}
val jti = jwtTokenProvider.extractJti(jwt)
return mono { jti != null && tokenRevocationService.isRevoked(jti) }
.flatMap { isRevoked ->
if (isRevoked) {
logger.debug("Token with jti {} is revoked", jti)
chain.filter(exchange)
} else {
val username = jwtTokenProvider.extractUsername(jwt)
userService
.findByUsername(username)
.flatMap { userDetails ->
val authentication =
UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities,
)
logger.debug("Set authentication for user: {}", username)
// Set authentication in reactive security context
chain
.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))
}.onErrorResume { ex ->
logger.error("Could not set user authentication in security context", ex)
chain.filter(exchange)
}
}
}.onErrorResume { ex ->
logger.error("Error during token revocation check", ex)
chain.filter(exchange)
}
}
private fun isValidAccessToken(token: String): Boolean =
@@ -15,6 +15,7 @@ import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.security.SignatureException
import java.util.Date
import java.util.UUID
import javax.crypto.SecretKey
@Component
@@ -45,7 +46,10 @@ class JwtTokenProvider(
fun extractExpiration(token: String): Date = extractClaim(token, Claims::getExpiration)
fun extractJti(token: String): String? = extractClaim(token, Claims::getId)
fun extractJti(token: String): UUID? {
val id = extractClaim(token, Claims::getId)
return UUID.fromString(id)
}
fun extractUsername(token: String): String = extractClaim(token, Claims::getSubject)
@@ -0,0 +1,9 @@
package io.visus.demos.kotlinapi.infrastructure.user
import io.visus.demos.kotlinapi.domain.model.RevokedToken
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import java.util.UUID
interface RevokedTokenRepository : CoroutineCrudRepository<RevokedToken, String> {
suspend fun existsByJti(jti: UUID): Boolean
}