From 756a80c5b9eca22edb9e8fd79d7a7842f596fad9 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Fri, 27 Mar 2026 07:23:34 -0400 Subject: [PATCH] chore(auth): add revoked access token checks Signed-off-by: Alan Brault --- build.gradle.kts | 5 ++ .../kotlinapi/controller/AuthController.kt | 12 +++- .../kotlinapi/controller/UsersController.kt | 2 +- .../kotlinapi/domain/model/RevokedToken.kt | 6 +- .../kotlinapi/domain/service/AuthService.kt | 5 +- .../domain/service/AuthServiceImpl.kt | 18 ++++- .../domain/service/TokenRevocationService.kt | 14 ++++ .../service/TokenRevocationServiceImpl.kt | 30 +++++++++ .../security/JwtAuthenticationFilter.kt | 67 ++++++++++++------- .../security/JwtTokenProvider.kt | 6 +- .../user/RevokedTokenRepository.kt | 9 +++ 11 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt diff --git a/build.gradle.kts b/build.gradle.kts index a9aa634..7d6d31e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -89,6 +89,11 @@ dependencies { // ════════════════════════════════════════════════════════════════════════════════ implementation("tech.mappie:mappie-api:2.2.21-2.1.1") + // ════════════════════════════════════════════════════════════════════════════════ + // UUID Generation + // ════════════════════════════════════════════════════════════════════════════════ + implementation("com.fasterxml.uuid:java-uuid-generator:5.2.0") + // ════════════════════════════════════════════════════════════════════════════════ // Testing // ════════════════════════════════════════════════════════════════════════════════ diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt index 406b5b6..c1a85b5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt @@ -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 { - 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() } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt index 3b740ad..4e32f61 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt @@ -39,7 +39,7 @@ class UsersController( val response = users - .mapNotNull { user -> + .map { user -> UserResponse( id = user.id ?: "", email = user.email, 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 index 3dd24fb..c3c089b 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RevokedToken.kt @@ -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, ) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt index b3a0328..9ea8054 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt @@ -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 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 0326e45..5971bda 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 @@ -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) } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt new file mode 100644 index 0000000..9cc8ee5 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt @@ -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 +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt new file mode 100644 index 0000000..8677c35 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt @@ -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) +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtAuthenticationFilter.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtAuthenticationFilter.kt index dcb89c3..94e7d55 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtAuthenticationFilter.kt @@ -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 { 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 = 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 1fc3dae..44450c6 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 @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt new file mode 100644 index 0000000..55a11cd --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt @@ -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 { + suspend fun existsByJti(jti: UUID): Boolean +}