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