@@ -3,6 +3,7 @@ package io.visus.demos.kotlinapi
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
/** Spring Boot entry point for the Kotlin API application. */
|
||||
@SpringBootApplication
|
||||
class KotlinApiApplication
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package io.visus.demos.kotlinapi.api
|
||||
|
||||
/**
|
||||
* Marks a controller with an API version so [ApiVersionConfig] can prefix its routes (e.g., `/v1`).
|
||||
*
|
||||
* @property version The API version number used to build the route prefix.
|
||||
*/
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class ApiVersion(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.api
|
||||
|
||||
/** HTTP status codes as string constants for use in OpenAPI `@ApiResponse.responseCode`. */
|
||||
object HttpStatusCodes {
|
||||
const val CONTINUE = "100"
|
||||
const val SWITCHING_PROTOCOLS = "101"
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.visus.demos.kotlinapi.api.contracts.ComponentHealthResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.ComponentHealth
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
/** Maps [ComponentHealth] domain objects to [ComponentHealthResponse] API contracts. */
|
||||
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthResponse>() {
|
||||
override fun map(from: ComponentHealth): ComponentHealthResponse =
|
||||
mapping {
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.visus.demos.kotlinapi.api.contracts.HealthResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.HealthStatus
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
/** Maps [HealthStatus] domain objects to [HealthResponse] API contracts, delegating component mapping to [ComponentHealthDtoMapper]. */
|
||||
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
|
||||
override fun map(from: HealthStatus): HealthResponse =
|
||||
mapping {
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.reactive.config.PathMatchConfigurer
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer
|
||||
|
||||
/** Prefixes routes of controllers annotated with [@ApiVersion][ApiVersion] with `/v{version}`. */
|
||||
@Configuration
|
||||
class ApiVersionConfig : WebFluxConfigurer {
|
||||
override fun configurePathMatching(configurer: PathMatchConfigurer) {
|
||||
|
||||
@@ -3,6 +3,14 @@ package io.visus.demos.kotlinapi.config
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* Configuration properties bound from the `jwt.*` namespace.
|
||||
*
|
||||
* @property encryptionSecret Base64-encoded AES key used to encrypt/decrypt JWE tokens.
|
||||
* @property accessTokenExpiration Access token TTL in milliseconds (default: 1 hour).
|
||||
* @property refreshTokenExpiration Refresh token TTL in milliseconds (default: 30 days).
|
||||
* @property issuer Value placed in the JWT `iss` claim.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties("jwt")
|
||||
data class JwtProperties(
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.data.mongodb.config.EnableMongoAuditing
|
||||
import org.springframework.data.mongodb.core.convert.MongoCustomConversions
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Configures the reactive MongoDB client with strict timeouts and custom UUID converters. */
|
||||
@Configuration
|
||||
@EnableMongoAuditing
|
||||
class MongoConfig {
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springdoc.core.models.GroupedOpenApi
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/** Configures the OpenAPI (Swagger) spec with JWT bearer auth and a v1 route group. */
|
||||
@Configuration
|
||||
class OpenApiConfig {
|
||||
@Bean
|
||||
|
||||
@@ -20,6 +20,10 @@ import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
|
||||
|
||||
/**
|
||||
* WebFlux security configuration: stateless JWT auth, Argon2 password encoding,
|
||||
* permissive CORS, and open routes for Swagger UI, health, and auth endpoints.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
@EnableReactiveMethodSecurity
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/** Admin-only endpoints. All routes require `ROLE_ADMIN`. */
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@Tag(name = "Admin", description = "Endpoints for admin operations")
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
|
||||
/** Authentication endpoints: login, logout, and access-token refresh. */
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@Tag(name = "Authentication", description = "Endpoints for user authentication and authorization")
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/** Public health-check endpoint. Returns 503 when any component is DOWN or DEGRADED. */
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@PreAuthorize("permitAll()")
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.springframework.security.core.Authentication
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/** User management endpoints. List-all requires `ROLE_ADMIN`; `/me` is available to any authenticated user. */
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@Tag(name = "Users", description = "Endpoints for user management")
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.data.convert.WritingConverter
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
|
||||
/** Serializes a [UUID] to BSON Binary (subtype 4) for MongoDB storage. */
|
||||
@WritingConverter
|
||||
class UuidToBinaryConverter : Converter<UUID, Binary> {
|
||||
override fun convert(source: UUID): Binary {
|
||||
@@ -20,6 +21,7 @@ class UuidToBinaryConverter : Converter<UUID, Binary> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deserializes a BSON Binary (subtype 4) back to a [UUID]. */
|
||||
@ReadingConverter
|
||||
class BinaryToUuidConverter : Converter<Binary, UUID> {
|
||||
override fun convert(source: Binary): UUID {
|
||||
|
||||
@@ -2,6 +2,8 @@ package io.visus.demos.kotlinapi.domain
|
||||
|
||||
import io.visus.demos.kotlinapi.domain.model.ComponentHealth
|
||||
|
||||
/** Contract for infrastructure health probes collected by [HealthService]. */
|
||||
interface HealthIndicator {
|
||||
/** Performs the health probe and returns the component's current status. */
|
||||
suspend fun check(): ComponentHealth
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.domain.exception
|
||||
|
||||
/** Thrown when a JWT token is syntactically valid but its `exp` claim is in the past. */
|
||||
class ExpiredTokenException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -13,6 +13,12 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.bind.support.WebExchangeBindException
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
|
||||
/**
|
||||
* Translates domain and framework exceptions into structured [ErrorResponse] payloads.
|
||||
*
|
||||
* [UsernameNotFoundException] is mapped to 401 (not 404) to avoid leaking whether an
|
||||
* email address is registered.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.domain.exception
|
||||
|
||||
/** Thrown when a token cannot be parsed, has an invalid signature/encryption, or is missing required claims. */
|
||||
class InvalidTokenException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.domain.exception
|
||||
|
||||
/** Thrown when a security breach is detected, e.g. token reuse within an active refresh token family. */
|
||||
class SecurityException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.domain.exception
|
||||
|
||||
/** Thrown when a refresh token has already been consumed or explicitly revoked. */
|
||||
class TokenRevokedException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package io.visus.demos.kotlinapi.domain.model
|
||||
|
||||
/**
|
||||
* Health result for a single infrastructure component.
|
||||
*
|
||||
* @property name Identifier used as the key in the [HealthStatus.components] map.
|
||||
* @property message Optional human-readable detail (e.g., error message on DOWN).
|
||||
*/
|
||||
data class ComponentHealth(
|
||||
val name: String,
|
||||
val status: ComponentStatus,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.domain.model
|
||||
|
||||
/** Operational status of a single infrastructure component. */
|
||||
enum class ComponentStatus {
|
||||
UP,
|
||||
DOWN,
|
||||
|
||||
@@ -2,12 +2,17 @@ package io.visus.demos.kotlinapi.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/** Aggregated health state of the API at a point in time. */
|
||||
data class HealthStatus(
|
||||
val status: Status,
|
||||
val timestamp: Instant,
|
||||
val components: Map<String, ComponentHealth> = emptyMap(),
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Derives overall [Status] from a list of component results:
|
||||
* all UP → UP, all DOWN → DOWN, mixed → DEGRADED.
|
||||
*/
|
||||
fun fromComponents(components: List<ComponentHealth>): HealthStatus {
|
||||
val componentMap = components.associateBy { it.name }
|
||||
val status =
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package io.visus.demos.kotlinapi.domain.model
|
||||
|
||||
/**
|
||||
* Discriminator stored in the JWT `type` claim to prevent an access token from being
|
||||
* accepted where a refresh token is expected and vice versa.
|
||||
*/
|
||||
enum class JwtTokenType(
|
||||
val value: String,
|
||||
) {
|
||||
@@ -8,6 +12,7 @@ enum class JwtTokenType(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Returns `null` when [value] does not match a known type, rather than throwing. */
|
||||
fun fromValue(value: String?): JwtTokenType? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Persisted refresh token record used for session tracking and rotation.
|
||||
*
|
||||
* Tokens are stored by [tokenHash] (SHA-256) rather than the raw JWT so the database
|
||||
* holds no redeemable secret. The [familyId] groups all tokens issued within a single
|
||||
* login session; detecting a revoked token that still has active siblings in its family
|
||||
* signals token theft and triggers full family revocation.
|
||||
*
|
||||
* MongoDB TTL on [expiresAt] automatically purges expired documents.
|
||||
*/
|
||||
@Document(collection = "refresh_tokens")
|
||||
data class RefreshToken(
|
||||
@Id
|
||||
|
||||
@@ -7,6 +7,12 @@ import org.springframework.data.mongodb.core.mapping.Document
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Blocklist entry for a revoked access token identified by its JWT `jti` claim.
|
||||
*
|
||||
* MongoDB TTL on [expiresAt] purges entries once the original token would have
|
||||
* expired anyway, keeping the blocklist bounded.
|
||||
*/
|
||||
@Document(collection = "revoked_access_tokens")
|
||||
data class RevokedToken(
|
||||
@Id
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package io.visus.demos.kotlinapi.domain.model
|
||||
|
||||
/**
|
||||
* Overall API health status derived from all component statuses.
|
||||
*
|
||||
* [DEGRADED] applies when some — but not all — components are DOWN.
|
||||
*/
|
||||
enum class Status {
|
||||
UP,
|
||||
DOWN,
|
||||
|
||||
@@ -11,6 +11,12 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Persisted user entity that also implements [UserDetails] for Spring Security integration.
|
||||
*
|
||||
* The raw password is never stored — [passwordHash] holds the Argon2-encoded value.
|
||||
* All accounts are always active/enabled; there is no locking or expiry mechanism.
|
||||
*/
|
||||
@Document(collection = "users")
|
||||
data class User(
|
||||
@Id
|
||||
|
||||
@@ -3,19 +3,28 @@ package io.visus.demos.kotlinapi.domain.service
|
||||
import io.visus.demos.kotlinapi.api.contracts.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
|
||||
|
||||
/** Handles credential-based authentication, token lifecycle, and user registration. */
|
||||
interface AuthService {
|
||||
/** Authenticates credentials and issues a new access/refresh token pair. */
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
): AuthResponse
|
||||
|
||||
/**
|
||||
* Revokes the current access token by its `jti` and revokes all refresh tokens for [userId].
|
||||
*
|
||||
* @param accessToken Raw Bearer token string (not the `Bearer ` prefixed header value).
|
||||
*/
|
||||
suspend fun logout(
|
||||
userId: String,
|
||||
accessToken: String,
|
||||
)
|
||||
|
||||
/** Validates [refreshToken], rotates it, and returns a new access/refresh token pair. */
|
||||
suspend fun refreshToken(refreshToken: String): AuthResponse
|
||||
|
||||
/** Creates a new user account with `ROLE_USER`. Throws if the email is already taken. */
|
||||
suspend fun register(
|
||||
email: String,
|
||||
password: String,
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
/** [AuthService] implementation using JWT (JWE) tokens and Argon2 password hashing. */
|
||||
@Service
|
||||
class AuthServiceImpl(
|
||||
private val userRepository: UserRepository,
|
||||
|
||||
@@ -2,6 +2,8 @@ package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.domain.model.HealthStatus
|
||||
|
||||
/** Aggregates results from all registered [HealthIndicator]s into a single [HealthStatus]. */
|
||||
interface HealthService {
|
||||
/** Runs every [HealthIndicator] and returns the aggregated status. */
|
||||
suspend fun check(): HealthStatus
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.visus.demos.kotlinapi.domain.HealthIndicator
|
||||
import io.visus.demos.kotlinapi.domain.model.HealthStatus
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/** [HealthService] implementation that fans out to all Spring-managed [HealthIndicator] beans. */
|
||||
@Service
|
||||
class HealthServiceImpl(
|
||||
private val healthIndicators: List<HealthIndicator>,
|
||||
|
||||
@@ -3,22 +3,43 @@ package io.visus.demos.kotlinapi.domain.service
|
||||
import io.visus.demos.kotlinapi.domain.model.RefreshToken
|
||||
import java.util.UUID
|
||||
|
||||
/** Manages the refresh token lifecycle including creation, rotation, and revocation. */
|
||||
interface RefreshTokenService {
|
||||
/**
|
||||
* Generates and persists a new refresh token for [userId] under [familyId].
|
||||
*
|
||||
* Evicts the oldest active session when the per-user session cap is reached.
|
||||
*
|
||||
* @return The raw JWT string to return to the client.
|
||||
*/
|
||||
suspend fun createRefreshToken(
|
||||
userId: String,
|
||||
familyId: UUID,
|
||||
): String
|
||||
|
||||
/** Returns the number of non-revoked refresh tokens for [userId]. */
|
||||
suspend fun getActiveSessionCount(userId: String): Long
|
||||
|
||||
/** Returns all non-revoked refresh token records for a single user. */
|
||||
suspend fun getActiveSessions(userId: String): List<RefreshToken>
|
||||
|
||||
/** Returns all non-revoked refresh token records across multiple users. */
|
||||
suspend fun getActiveSessions(userIds: List<String>): List<RefreshToken>
|
||||
|
||||
/** Marks all refresh tokens for [userId] as revoked (used on logout). */
|
||||
suspend fun revokeAllUserTokens(userId: String)
|
||||
|
||||
/** Marks every token in [familyId] as revoked (used on theft detection). */
|
||||
suspend fun revokeTokenFamily(familyId: UUID)
|
||||
|
||||
/**
|
||||
* Validates [tokenString], marks it revoked, and returns the stored record.
|
||||
*
|
||||
* Throws [SecurityException] and revokes the entire token family when a previously
|
||||
* revoked token is presented but the family still has active tokens — a strong signal
|
||||
* of token theft. Throws [ExpiredTokenException] for expired tokens and
|
||||
* [TokenRevokedException] when the token was already legitimately consumed.
|
||||
*/
|
||||
suspend fun validateAndRotate(
|
||||
userId: String,
|
||||
tokenString: String,
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.Base64
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/** [RefreshTokenService] implementation with SHA-256 token hashing and family-based theft detection. */
|
||||
@Service
|
||||
class RefreshTokenServiceImpl(
|
||||
private val refreshTokenRepository: RefreshTokenRepository,
|
||||
|
||||
@@ -3,12 +3,20 @@ package io.visus.demos.kotlinapi.domain.service
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/** Maintains a blocklist of revoked access tokens keyed by their JWT `jti` claim. */
|
||||
interface TokenRevocationService {
|
||||
/**
|
||||
* Adds [jti] to the blocklist.
|
||||
*
|
||||
* @param expiresAt Propagated to MongoDB as the TTL field so the record is automatically
|
||||
* deleted when the original token would have expired, keeping the blocklist bounded.
|
||||
*/
|
||||
suspend fun revokeAccessToken(
|
||||
jti: UUID,
|
||||
userId: String,
|
||||
expiresAt: Date,
|
||||
)
|
||||
|
||||
/** Returns `true` if [jti] appears in the blocklist. */
|
||||
suspend fun isRevoked(jti: UUID): Boolean
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/** [TokenRevocationService] implementation backed by the [RevokedTokenRepository] MongoDB collection. */
|
||||
@Service
|
||||
class TokenRevocationServiceImpl(
|
||||
private val revokedTokenRepository: RevokedTokenRepository,
|
||||
|
||||
@@ -4,6 +4,8 @@ import io.visus.demos.kotlinapi.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
|
||||
|
||||
/** Extends [ReactiveUserDetailsService] with application-level user queries. */
|
||||
interface UserService : ReactiveUserDetailsService {
|
||||
/** Returns all users as a cold [Flow]. */
|
||||
suspend fun findAll(): Flow<User>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/** [UserService] implementation backed by [UserRepository]. Bridges coroutines to Project Reactor for Spring Security. */
|
||||
@Service
|
||||
class UserServiceImpl(
|
||||
private val userRepository: UserRepository,
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||||
import org.bson.Document
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/** [HealthIndicator] that pings MongoDB's `admin` database to verify connectivity. */
|
||||
@Service
|
||||
class MongoHealthService(
|
||||
private val mongoClient: MongoClient,
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/** Returns a JSON 403 Forbidden response when an authenticated user lacks sufficient authority. */
|
||||
@Component
|
||||
class CustomAccessDeniedHandler : ServerAccessDeniedHandler {
|
||||
override fun handle(
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/** Returns a JSON 401 Unauthorized response when a request reaches a protected route without credentials. */
|
||||
@Component
|
||||
class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint {
|
||||
override fun commence(
|
||||
|
||||
+7
@@ -14,6 +14,13 @@ import org.springframework.web.server.WebFilter
|
||||
import org.springframework.web.server.WebFilterChain
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/**
|
||||
* WebFlux filter that extracts the Bearer token from each request, checks the blocklist,
|
||||
* and populates the reactive security context when the token is valid.
|
||||
*
|
||||
* Requests with missing, invalid, or revoked tokens are passed through unauthenticated —
|
||||
* endpoint-level authorization rules determine whether they are ultimately rejected.
|
||||
*/
|
||||
@Component
|
||||
class JwtAuthenticationFilter(
|
||||
private val jwtTokenProvider: JwtTokenProvider,
|
||||
|
||||
@@ -17,6 +17,13 @@ import java.util.UUID
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Creates and parses JWE (encrypted) tokens using AES-256-GCM direct encryption.
|
||||
*
|
||||
* Tokens are encrypted — not merely signed — so claims are opaque to clients. Both
|
||||
* access and refresh tokens embed a `type` claim ([JwtTokenType]) to prevent cross-use.
|
||||
* Access tokens additionally carry a UUIDv7 `jti` used for blocklist lookup.
|
||||
*/
|
||||
@Component
|
||||
class JwtTokenProvider(
|
||||
private val jwtProperties: JwtProperties,
|
||||
@@ -28,6 +35,11 @@ class JwtTokenProvider(
|
||||
SecretKeySpec(bytes, "AES")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts [token] and extracts an arbitrary claim via [claimsResolver].
|
||||
*
|
||||
* Throws a JJWT exception for malformed, expired, or tampered tokens.
|
||||
*/
|
||||
fun <T> extractClaim(
|
||||
token: String,
|
||||
claimsResolver: (Claims) -> T,
|
||||
@@ -98,6 +110,7 @@ class JwtTokenProvider(
|
||||
.compact()
|
||||
}
|
||||
|
||||
/** Returns `true` only when username, [tokenType], expiry, and signature/encryption all check out. */
|
||||
fun isTokenValid(
|
||||
token: String,
|
||||
userDetails: UserDetails,
|
||||
@@ -117,6 +130,7 @@ class JwtTokenProvider(
|
||||
return expiration.before(Date())
|
||||
}
|
||||
|
||||
/** Returns `true` if [token] can be successfully decrypted and parsed; logs but does not rethrow on failure. */
|
||||
fun validateToken(token: String): Boolean {
|
||||
try {
|
||||
Jwts
|
||||
|
||||
+11
@@ -8,20 +8,31 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/** Reactive coroutine repository for [RefreshToken] documents, including bulk revocation support. */
|
||||
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
|
||||
/** Counts non-revoked tokens for [userId] (used to enforce the per-user session cap). */
|
||||
@Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true)
|
||||
suspend fun countActive(userId: ObjectId): Long
|
||||
|
||||
/** Returns all tokens belonging to [familyId], including revoked ones (needed for theft detection). */
|
||||
suspend fun findByFamilyId(familyId: UUID): List<RefreshToken>
|
||||
|
||||
/** Looks up a token by its SHA-256 hash; returns `null` if not found. */
|
||||
suspend fun findByTokenHash(tokenHash: String): RefreshToken?
|
||||
|
||||
/** Returns only non-revoked tokens for [userId]. */
|
||||
@Query("{ 'userId': ?0, 'isRevoked': false }")
|
||||
suspend fun findActive(userId: ObjectId): List<RefreshToken>
|
||||
|
||||
/** Returns all tokens (revoked or not) owned by any of the given [userIds]. */
|
||||
@Query($$"{ 'userId': { '$in': ?0 } }")
|
||||
suspend fun findByUserIds(userIds: List<ObjectId>): List<RefreshToken>
|
||||
|
||||
/**
|
||||
* Bulk-revokes all tokens for [userId] via a single `$set` update.
|
||||
*
|
||||
* @return The number of documents modified.
|
||||
*/
|
||||
@Query("{ 'userId': ?0 }")
|
||||
@Update($$"{ '$set': { 'isRevoked': true, 'revokedAt': ?1 } }")
|
||||
suspend fun revokeByUserId(
|
||||
|
||||
@@ -4,6 +4,8 @@ import io.visus.demos.kotlinapi.domain.model.RevokedToken
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import java.util.UUID
|
||||
|
||||
/** Reactive coroutine repository for the access token blocklist. */
|
||||
interface RevokedTokenRepository : CoroutineCrudRepository<RevokedToken, String> {
|
||||
/** Returns `true` if a [RevokedToken] with the given [jti] exists. */
|
||||
suspend fun existsByJti(jti: UUID): Boolean
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import io.visus.demos.kotlinapi.domain.model.User
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/** Reactive coroutine repository for [User] documents. */
|
||||
@Repository
|
||||
interface UserRepository : CoroutineCrudRepository<User, String> {
|
||||
/** Returns the user with [email], or `null` if none exists. */
|
||||
suspend fun findByEmail(email: String): User?
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/** Seeds a default `ROLE_USER` and `ROLE_ADMIN` account on startup if they don't already exist. */
|
||||
@Component
|
||||
class DataSeeder(
|
||||
private val userRepository: UserRepository,
|
||||
|
||||
Reference in New Issue
Block a user