1
0

chore: kdocs

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-06-05 07:52:53 -04:00
parent 9389055b33
commit c010c6a38a
48 changed files with 173 additions and 0 deletions
@@ -3,6 +3,7 @@ package io.visus.demos.kotlinapi
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
/** Spring Boot entry point for the Kotlin API application. */
@SpringBootApplication @SpringBootApplication
class KotlinApiApplication class KotlinApiApplication
@@ -1,5 +1,10 @@
package io.visus.demos.kotlinapi.api 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) @Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
annotation class ApiVersion( annotation class ApiVersion(
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.api package io.visus.demos.kotlinapi.api
/** HTTP status codes as string constants for use in OpenAPI `@ApiResponse.responseCode`. */
object HttpStatusCodes { object HttpStatusCodes {
const val CONTINUE = "100" const val CONTINUE = "100"
const val SWITCHING_PROTOCOLS = "101" 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 io.visus.demos.kotlinapi.domain.model.ComponentHealth
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
/** Maps [ComponentHealth] domain objects to [ComponentHealthResponse] API contracts. */
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthResponse>() { object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthResponse>() {
override fun map(from: ComponentHealth): ComponentHealthResponse = override fun map(from: ComponentHealth): ComponentHealthResponse =
mapping { mapping {
@@ -4,6 +4,7 @@ import io.visus.demos.kotlinapi.api.contracts.HealthResponse
import io.visus.demos.kotlinapi.domain.model.HealthStatus import io.visus.demos.kotlinapi.domain.model.HealthStatus
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
/** Maps [HealthStatus] domain objects to [HealthResponse] API contracts, delegating component mapping to [ComponentHealthDtoMapper]. */
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() { object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
override fun map(from: HealthStatus): HealthResponse = override fun map(from: HealthStatus): HealthResponse =
mapping { 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.PathMatchConfigurer
import org.springframework.web.reactive.config.WebFluxConfigurer import org.springframework.web.reactive.config.WebFluxConfigurer
/** Prefixes routes of controllers annotated with [@ApiVersion][ApiVersion] with `/v{version}`. */
@Configuration @Configuration
class ApiVersionConfig : WebFluxConfigurer { class ApiVersionConfig : WebFluxConfigurer {
override fun configurePathMatching(configurer: PathMatchConfigurer) { 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.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component 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 @Component
@ConfigurationProperties("jwt") @ConfigurationProperties("jwt")
data class JwtProperties( data class JwtProperties(
@@ -13,6 +13,7 @@ import org.springframework.data.mongodb.config.EnableMongoAuditing
import org.springframework.data.mongodb.core.convert.MongoCustomConversions import org.springframework.data.mongodb.core.convert.MongoCustomConversions
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** Configures the reactive MongoDB client with strict timeouts and custom UUID converters. */
@Configuration @Configuration
@EnableMongoAuditing @EnableMongoAuditing
class MongoConfig { class MongoConfig {
@@ -9,6 +9,7 @@ import org.springdoc.core.models.GroupedOpenApi
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
/** Configures the OpenAPI (Swagger) spec with JWT bearer auth and a v1 route group. */
@Configuration @Configuration
class OpenApiConfig { class OpenApiConfig {
@Bean @Bean
@@ -20,6 +20,10 @@ import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource 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 @Configuration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@EnableReactiveMethodSecurity @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.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
/** Admin-only endpoints. All routes require `ROLE_ADMIN`. */
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@Tag(name = "Admin", description = "Endpoints for admin operations") @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.bind.annotation.RestController
import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.ServerWebExchange
/** Authentication endpoints: login, logout, and access-token refresh. */
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@Tag(name = "Authentication", description = "Endpoints for user authentication and authorization") @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.GetMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
/** Public health-check endpoint. Returns 503 when any component is DOWN or DEGRADED. */
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@PreAuthorize("permitAll()") @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.GetMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
/** User management endpoints. List-all requires `ROLE_ADMIN`; `/me` is available to any authenticated user. */
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@Tag(name = "Users", description = "Endpoints for user management") @Tag(name = "Users", description = "Endpoints for user management")
@@ -8,6 +8,7 @@ import org.springframework.data.convert.WritingConverter
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.UUID import java.util.UUID
/** Serializes a [UUID] to BSON Binary (subtype 4) for MongoDB storage. */
@WritingConverter @WritingConverter
class UuidToBinaryConverter : Converter<UUID, Binary> { class UuidToBinaryConverter : Converter<UUID, Binary> {
override fun convert(source: 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 @ReadingConverter
class BinaryToUuidConverter : Converter<Binary, UUID> { class BinaryToUuidConverter : Converter<Binary, UUID> {
override fun convert(source: 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 import io.visus.demos.kotlinapi.domain.model.ComponentHealth
/** Contract for infrastructure health probes collected by [HealthService]. */
interface HealthIndicator { interface HealthIndicator {
/** Performs the health probe and returns the component's current status. */
suspend fun check(): ComponentHealth suspend fun check(): ComponentHealth
} }
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.exception 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( class ExpiredTokenException(
message: String, message: String,
) : RuntimeException(message) ) : RuntimeException(message)
@@ -13,6 +13,12 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.bind.support.WebExchangeBindException import org.springframework.web.bind.support.WebExchangeBindException
import org.springframework.web.server.ServerWebExchange 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 @RestControllerAdvice
class GlobalExceptionHandler { class GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.exception 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( class InvalidTokenException(
message: String, message: String,
) : RuntimeException(message) ) : RuntimeException(message)
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.exception 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( class SecurityException(
message: String, message: String,
) : RuntimeException(message) ) : RuntimeException(message)
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.exception package io.visus.demos.kotlinapi.domain.exception
/** Thrown when a refresh token has already been consumed or explicitly revoked. */
class TokenRevokedException( class TokenRevokedException(
message: String, message: String,
) : RuntimeException(message) ) : RuntimeException(message)
@@ -1,5 +1,11 @@
package io.visus.demos.kotlinapi.domain.model 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( data class ComponentHealth(
val name: String, val name: String,
val status: ComponentStatus, val status: ComponentStatus,
@@ -1,5 +1,6 @@
package io.visus.demos.kotlinapi.domain.model package io.visus.demos.kotlinapi.domain.model
/** Operational status of a single infrastructure component. */
enum class ComponentStatus { enum class ComponentStatus {
UP, UP,
DOWN, DOWN,
@@ -2,12 +2,17 @@ package io.visus.demos.kotlinapi.domain.model
import java.time.Instant import java.time.Instant
/** Aggregated health state of the API at a point in time. */
data class HealthStatus( data class HealthStatus(
val status: Status, val status: Status,
val timestamp: Instant, val timestamp: Instant,
val components: Map<String, ComponentHealth> = emptyMap(), val components: Map<String, ComponentHealth> = emptyMap(),
) { ) {
companion object { 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 { fun fromComponents(components: List<ComponentHealth>): HealthStatus {
val componentMap = components.associateBy { it.name } val componentMap = components.associateBy { it.name }
val status = val status =
@@ -1,5 +1,9 @@
package io.visus.demos.kotlinapi.domain.model 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( enum class JwtTokenType(
val value: String, val value: String,
) { ) {
@@ -8,6 +12,7 @@ enum class JwtTokenType(
; ;
companion object { 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 } 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.Date
import java.util.UUID 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") @Document(collection = "refresh_tokens")
data class RefreshToken( data class RefreshToken(
@Id @Id
@@ -7,6 +7,12 @@ import org.springframework.data.mongodb.core.mapping.Document
import java.util.Date import java.util.Date
import java.util.UUID 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") @Document(collection = "revoked_access_tokens")
data class RevokedToken( data class RevokedToken(
@Id @Id
@@ -1,5 +1,10 @@
package io.visus.demos.kotlinapi.domain.model 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 { enum class Status {
UP, UP,
DOWN, DOWN,
@@ -11,6 +11,12 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import java.time.Instant 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") @Document(collection = "users")
data class User( data class User(
@Id @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.AuthResponse
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
/** Handles credential-based authentication, token lifecycle, and user registration. */
interface AuthService { interface AuthService {
/** Authenticates credentials and issues a new access/refresh token pair. */
suspend fun login( suspend fun login(
email: String, email: String,
password: String, password: String,
): AuthResponse ): 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( suspend fun logout(
userId: String, userId: String,
accessToken: String, accessToken: String,
) )
/** Validates [refreshToken], rotates it, and returns a new access/refresh token pair. */
suspend fun refreshToken(refreshToken: String): AuthResponse suspend fun refreshToken(refreshToken: String): AuthResponse
/** Creates a new user account with `ROLE_USER`. Throws if the email is already taken. */
suspend fun register( suspend fun register(
email: String, email: String,
password: String, password: String,
@@ -16,6 +16,7 @@ import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.UUID import java.util.UUID
/** [AuthService] implementation using JWT (JWE) tokens and Argon2 password hashing. */
@Service @Service
class AuthServiceImpl( class AuthServiceImpl(
private val userRepository: UserRepository, private val userRepository: UserRepository,
@@ -2,6 +2,8 @@ package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.model.HealthStatus import io.visus.demos.kotlinapi.domain.model.HealthStatus
/** Aggregates results from all registered [HealthIndicator]s into a single [HealthStatus]. */
interface HealthService { interface HealthService {
/** Runs every [HealthIndicator] and returns the aggregated status. */
suspend fun check(): HealthStatus suspend fun check(): HealthStatus
} }
@@ -4,6 +4,7 @@ import io.visus.demos.kotlinapi.domain.HealthIndicator
import io.visus.demos.kotlinapi.domain.model.HealthStatus import io.visus.demos.kotlinapi.domain.model.HealthStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
/** [HealthService] implementation that fans out to all Spring-managed [HealthIndicator] beans. */
@Service @Service
class HealthServiceImpl( class HealthServiceImpl(
private val healthIndicators: List<HealthIndicator>, 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 io.visus.demos.kotlinapi.domain.model.RefreshToken
import java.util.UUID import java.util.UUID
/** Manages the refresh token lifecycle including creation, rotation, and revocation. */
interface RefreshTokenService { 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( suspend fun createRefreshToken(
userId: String, userId: String,
familyId: UUID, familyId: UUID,
): String ): String
/** Returns the number of non-revoked refresh tokens for [userId]. */
suspend fun getActiveSessionCount(userId: String): Long suspend fun getActiveSessionCount(userId: String): Long
/** Returns all non-revoked refresh token records for a single user. */
suspend fun getActiveSessions(userId: String): List<RefreshToken> 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> suspend fun getActiveSessions(userIds: List<String>): List<RefreshToken>
/** Marks all refresh tokens for [userId] as revoked (used on logout). */
suspend fun revokeAllUserTokens(userId: String) suspend fun revokeAllUserTokens(userId: String)
/** Marks every token in [familyId] as revoked (used on theft detection). */
suspend fun revokeTokenFamily(familyId: UUID) 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( suspend fun validateAndRotate(
userId: String, userId: String,
tokenString: String, tokenString: String,
@@ -18,6 +18,7 @@ import java.util.Base64
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
/** [RefreshTokenService] implementation with SHA-256 token hashing and family-based theft detection. */
@Service @Service
class RefreshTokenServiceImpl( class RefreshTokenServiceImpl(
private val refreshTokenRepository: RefreshTokenRepository, private val refreshTokenRepository: RefreshTokenRepository,
@@ -3,12 +3,20 @@ package io.visus.demos.kotlinapi.domain.service
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
/** Maintains a blocklist of revoked access tokens keyed by their JWT `jti` claim. */
interface TokenRevocationService { 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( suspend fun revokeAccessToken(
jti: UUID, jti: UUID,
userId: String, userId: String,
expiresAt: Date, expiresAt: Date,
) )
/** Returns `true` if [jti] appears in the blocklist. */
suspend fun isRevoked(jti: UUID): Boolean suspend fun isRevoked(jti: UUID): Boolean
} }
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
/** [TokenRevocationService] implementation backed by the [RevokedTokenRepository] MongoDB collection. */
@Service @Service
class TokenRevocationServiceImpl( class TokenRevocationServiceImpl(
private val revokedTokenRepository: RevokedTokenRepository, private val revokedTokenRepository: RevokedTokenRepository,
@@ -4,6 +4,8 @@ import io.visus.demos.kotlinapi.domain.model.User
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.springframework.security.core.userdetails.ReactiveUserDetailsService import org.springframework.security.core.userdetails.ReactiveUserDetailsService
/** Extends [ReactiveUserDetailsService] with application-level user queries. */
interface UserService : ReactiveUserDetailsService { interface UserService : ReactiveUserDetailsService {
/** Returns all users as a cold [Flow]. */
suspend fun findAll(): Flow<User> suspend fun findAll(): Flow<User>
} }
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
/** [UserService] implementation backed by [UserRepository]. Bridges coroutines to Project Reactor for Spring Security. */
@Service @Service
class UserServiceImpl( class UserServiceImpl(
private val userRepository: UserRepository, private val userRepository: UserRepository,
@@ -8,6 +8,7 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull
import org.bson.Document import org.bson.Document
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
/** [HealthIndicator] that pings MongoDB's `admin` database to verify connectivity. */
@Service @Service
class MongoHealthService( class MongoHealthService(
private val mongoClient: MongoClient, private val mongoClient: MongoClient,
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
/** Returns a JSON 403 Forbidden response when an authenticated user lacks sufficient authority. */
@Component @Component
class CustomAccessDeniedHandler : ServerAccessDeniedHandler { class CustomAccessDeniedHandler : ServerAccessDeniedHandler {
override fun handle( override fun handle(
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
/** Returns a JSON 401 Unauthorized response when a request reaches a protected route without credentials. */
@Component @Component
class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint { class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint {
override fun commence( override fun commence(
@@ -14,6 +14,13 @@ import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono 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 @Component
class JwtAuthenticationFilter( class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenProvider: JwtTokenProvider,
@@ -17,6 +17,13 @@ import java.util.UUID
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec 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 @Component
class JwtTokenProvider( class JwtTokenProvider(
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
@@ -28,6 +35,11 @@ class JwtTokenProvider(
SecretKeySpec(bytes, "AES") 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( fun <T> extractClaim(
token: String, token: String,
claimsResolver: (Claims) -> T, claimsResolver: (Claims) -> T,
@@ -98,6 +110,7 @@ class JwtTokenProvider(
.compact() .compact()
} }
/** Returns `true` only when username, [tokenType], expiry, and signature/encryption all check out. */
fun isTokenValid( fun isTokenValid(
token: String, token: String,
userDetails: UserDetails, userDetails: UserDetails,
@@ -117,6 +130,7 @@ class JwtTokenProvider(
return expiration.before(Date()) 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 { fun validateToken(token: String): Boolean {
try { try {
Jwts Jwts
@@ -8,20 +8,31 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
/** Reactive coroutine repository for [RefreshToken] documents, including bulk revocation support. */
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> { 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) @Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true)
suspend fun countActive(userId: ObjectId): Long 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> 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? suspend fun findByTokenHash(tokenHash: String): RefreshToken?
/** Returns only non-revoked tokens for [userId]. */
@Query("{ 'userId': ?0, 'isRevoked': false }") @Query("{ 'userId': ?0, 'isRevoked': false }")
suspend fun findActive(userId: ObjectId): List<RefreshToken> suspend fun findActive(userId: ObjectId): List<RefreshToken>
/** Returns all tokens (revoked or not) owned by any of the given [userIds]. */
@Query($$"{ 'userId': { '$in': ?0 } }") @Query($$"{ 'userId': { '$in': ?0 } }")
suspend fun findByUserIds(userIds: List<ObjectId>): List<RefreshToken> 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 }") @Query("{ 'userId': ?0 }")
@Update($$"{ '$set': { 'isRevoked': true, 'revokedAt': ?1 } }") @Update($$"{ '$set': { 'isRevoked': true, 'revokedAt': ?1 } }")
suspend fun revokeByUserId( suspend fun revokeByUserId(
@@ -4,6 +4,8 @@ import io.visus.demos.kotlinapi.domain.model.RevokedToken
import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import java.util.UUID import java.util.UUID
/** Reactive coroutine repository for the access token blocklist. */
interface RevokedTokenRepository : CoroutineCrudRepository<RevokedToken, String> { interface RevokedTokenRepository : CoroutineCrudRepository<RevokedToken, String> {
/** Returns `true` if a [RevokedToken] with the given [jti] exists. */
suspend fun existsByJti(jti: UUID): Boolean 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.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
/** Reactive coroutine repository for [User] documents. */
@Repository @Repository
interface UserRepository : CoroutineCrudRepository<User, String> { interface UserRepository : CoroutineCrudRepository<User, String> {
/** Returns the user with [email], or `null` if none exists. */
suspend fun findByEmail(email: String): User? 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.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
/** Seeds a default `ROLE_USER` and `ROLE_ADMIN` account on startup if they don't already exist. */
@Component @Component
class DataSeeder( class DataSeeder(
private val userRepository: UserRepository, private val userRepository: UserRepository,