chore(auth): add revoked access token checks
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+41
-26
@@ -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 =
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user