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
+5
View File
@@ -89,6 +89,11 @@ dependencies {
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
implementation("tech.mappie:mappie-api:2.2.21-2.1.1") implementation("tech.mappie:mappie-api:2.2.21-2.1.1")
// ════════════════════════════════════════════════════════════════════════════════
// UUID Generation
// ════════════════════════════════════════════════════════════════════════════════
implementation("com.fasterxml.uuid:java-uuid-generator:5.2.0")
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
// Testing // Testing
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
@@ -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.RefreshTokenRequest
import io.visus.demos.kotlinapi.api.contracts.RegisterRequest import io.visus.demos.kotlinapi.api.contracts.RegisterRequest
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse 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.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.HttpHeaders
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.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
import org.springframework.web.server.ServerWebExchange
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@@ -83,8 +86,15 @@ class AuthController(
) )
suspend fun logout( suspend fun logout(
@AuthenticationPrincipal user: User, @AuthenticationPrincipal user: User,
exchange: ServerWebExchange,
): ResponseEntity<Unit> { ): 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() return ResponseEntity.noContent().build()
} }
@@ -39,7 +39,7 @@ class UsersController(
val response = val response =
users users
.mapNotNull { user -> .map { user ->
UserResponse( UserResponse(
id = user.id ?: "", id = user.id ?: "",
email = user.email, email = user.email,
@@ -1,17 +1,19 @@
package io.visus.demos.kotlinapi.domain.model package io.visus.demos.kotlinapi.domain.model
import org.bson.types.ObjectId
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import java.util.Date import java.util.Date
import java.util.UUID
@Document(collection = "revoked_access_tokens") @Document(collection = "revoked_access_tokens")
data class RevokedToken( data class RevokedToken(
@Id @Id
val id: String? = null, val id: String? = null,
@Indexed(unique = true) @Indexed(unique = true)
val jti: String, val jti: UUID,
val userId: String, val userId: ObjectId,
@Indexed(expireAfter = "0s") @Indexed(expireAfter = "0s")
val expiresAt: Date, val expiresAt: Date,
) )
@@ -9,7 +9,10 @@ interface AuthService {
password: String, password: String,
): AuthResponse ): AuthResponse
suspend fun logout(userId: String) suspend fun logout(
userId: String,
accessToken: String,
)
suspend fun refreshToken(refreshToken: String): AuthResponse suspend fun refreshToken(refreshToken: String): AuthResponse
@@ -23,6 +23,7 @@ class AuthServiceImpl(
private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenProvider: JwtTokenProvider,
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
private val refreshTokenService: RefreshTokenService, private val refreshTokenService: RefreshTokenService,
private val tokenRevocationService: TokenRevocationService,
) : AuthService { ) : AuthService {
override suspend fun login( override suspend fun login(
email: String, email: String,
@@ -39,7 +40,22 @@ class AuthServiceImpl(
return generateAuthResponse(user, newFamily = true) 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) 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 package io.visus.demos.kotlinapi.infrastructure.security
import io.visus.demos.kotlinapi.domain.model.JwtTokenType 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 io.visus.demos.kotlinapi.domain.service.UserService
import kotlinx.coroutines.reactor.mono
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
@@ -16,6 +18,7 @@ import reactor.core.publisher.Mono
class JwtAuthenticationFilter( class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenProvider: JwtTokenProvider,
private val userService: UserService, private val userService: UserService,
private val tokenRevocationService: TokenRevocationService,
) : WebFilter { ) : WebFilter {
private val logger = LoggerFactory.getLogger(JwtAuthenticationFilter::class.java) private val logger = LoggerFactory.getLogger(JwtAuthenticationFilter::class.java)
@@ -29,33 +32,45 @@ class JwtAuthenticationFilter(
): Mono<Void> { ): Mono<Void> {
val jwt = extractJwtFromRequest(exchange) val jwt = extractJwtFromRequest(exchange)
return if (jwt != null && isValidAccessToken(jwt)) { if (jwt == null || !isValidAccessToken(jwt)) {
val username = jwtTokenProvider.extractUsername(jwt) return chain.filter(exchange)
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)
} }
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 = private fun isValidAccessToken(token: String): Boolean =
@@ -15,6 +15,7 @@ import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.security.SignatureException import java.security.SignatureException
import java.util.Date import java.util.Date
import java.util.UUID
import javax.crypto.SecretKey import javax.crypto.SecretKey
@Component @Component
@@ -45,7 +46,10 @@ class JwtTokenProvider(
fun extractExpiration(token: String): Date = extractClaim(token, Claims::getExpiration) 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) 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
}