From c010c6a38a0b273f19e6029cc758fdbdebd3b562 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Fri, 5 Jun 2026 07:52:53 -0400 Subject: [PATCH] chore: kdocs Signed-off-by: Alan Brault --- .../demos/kotlinapi/KotlinApiApplication.kt | 1 + .../visus/demos/kotlinapi/api/ApiVersion.kt | 5 +++++ .../demos/kotlinapi/api/HttpStatusCodes.kt | 1 + .../api/mappers/ComponentHealthDtoMapper.kt | 1 + .../api/mappers/HealthResponseMapper.kt | 1 + .../kotlinapi/config/ApiVersionConfig.kt | 1 + .../demos/kotlinapi/config/JwtProperties.kt | 8 +++++++ .../demos/kotlinapi/config/MongoConfig.kt | 1 + .../demos/kotlinapi/config/OpenApiConfig.kt | 1 + .../demos/kotlinapi/config/SecurityConfig.kt | 4 ++++ .../kotlinapi/controller/AdminController.kt | 1 + .../kotlinapi/controller/AuthController.kt | 1 + .../kotlinapi/controller/HealthController.kt | 1 + .../kotlinapi/controller/UsersController.kt | 1 + .../kotlinapi/converters/UuidConverters.kt | 2 ++ .../demos/kotlinapi/domain/HealthIndicator.kt | 2 ++ .../domain/exception/ExpiredTokenException.kt | 1 + .../exception/GlobalExceptionHandler.kt | 6 ++++++ .../domain/exception/InvalidTokenException.kt | 1 + .../domain/exception/SecurityException.kt | 1 + .../domain/exception/TokenRevokedException.kt | 1 + .../kotlinapi/domain/model/ComponentHealth.kt | 6 ++++++ .../kotlinapi/domain/model/ComponentStatus.kt | 1 + .../kotlinapi/domain/model/HealthStatus.kt | 5 +++++ .../kotlinapi/domain/model/JwtTokenType.kt | 5 +++++ .../kotlinapi/domain/model/RefreshToken.kt | 10 +++++++++ .../kotlinapi/domain/model/RevokedToken.kt | 6 ++++++ .../demos/kotlinapi/domain/model/Status.kt | 5 +++++ .../demos/kotlinapi/domain/model/User.kt | 6 ++++++ .../kotlinapi/domain/service/AuthService.kt | 9 ++++++++ .../domain/service/AuthServiceImpl.kt | 1 + .../kotlinapi/domain/service/HealthService.kt | 2 ++ .../domain/service/HealthServiceImpl.kt | 1 + .../domain/service/RefreshTokenService.kt | 21 +++++++++++++++++++ .../domain/service/RefreshTokenServiceImpl.kt | 1 + .../domain/service/TokenRevocationService.kt | 8 +++++++ .../service/TokenRevocationServiceImpl.kt | 1 + .../kotlinapi/domain/service/UserService.kt | 2 ++ .../domain/service/UserServiceImpl.kt | 1 + .../health/MongoHealthService.kt | 1 + .../security/CustomAccessDeniedHandler.kt | 1 + .../CustomAuthenticationEntryPoint.kt | 1 + .../security/JwtAuthenticationFilter.kt | 7 +++++++ .../security/JwtTokenProvider.kt | 14 +++++++++++++ .../user/RefreshTokenRepository.kt | 11 ++++++++++ .../user/RevokedTokenRepository.kt | 2 ++ .../infrastructure/user/UserRepository.kt | 2 ++ .../visus/demos/kotlinapi/seed/DataSeeder.kt | 1 + 48 files changed, 173 insertions(+) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/KotlinApiApplication.kt b/src/main/kotlin/io/visus/demos/kotlinapi/KotlinApiApplication.kt index 4a03d66..c56d944 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/KotlinApiApplication.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/KotlinApiApplication.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt index 6fe2518..0a14e33 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt @@ -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( diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt index 10d3b89..9a47b6e 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt @@ -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" diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt index c1a1c5f..0e3255a 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt @@ -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() { override fun map(from: ComponentHealth): ComponentHealthResponse = mapping { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt index 6795afc..3ba6785 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt @@ -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() { override fun map(from: HealthStatus): HealthResponse = mapping { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt index 7514e3f..ecd11b8 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt @@ -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) { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt index 24bf41f..61c8d81 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt @@ -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( diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt index 67eb0a1..068d36c 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt @@ -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 { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt index 257f354..49a98e5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt index c3b9717..4842deb 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AdminController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AdminController.kt index 75c9048..5ddaec0 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AdminController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AdminController.kt @@ -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") 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 4ebc1e6..10cb729 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt @@ -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") diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt index 5af3cd8..d652bda 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt @@ -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()") 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 4e32f61..4ebad64 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt @@ -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") diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt b/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt index ca612c4..7cb196e 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/converters/UuidConverters.kt @@ -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 { override fun convert(source: UUID): Binary { @@ -20,6 +21,7 @@ class UuidToBinaryConverter : Converter { } } +/** Deserializes a BSON Binary (subtype 4) back to a [UUID]. */ @ReadingConverter class BinaryToUuidConverter : Converter { override fun convert(source: Binary): UUID { diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt index 91ba3a5..d3f18bc 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt @@ -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 } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt index 69c9576..28b9332 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt index 7aba091..0221dba 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt index 73ad5a2..b8fcb97 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt index 88da7b6..8c22c88 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt index b607fbf..01fbf36 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentHealth.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentHealth.kt index 4648751..84a7cce 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentHealth.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentHealth.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentStatus.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentStatus.kt index 88ad645..805bf4a 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentStatus.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/ComponentStatus.kt @@ -1,5 +1,6 @@ package io.visus.demos.kotlinapi.domain.model +/** Operational status of a single infrastructure component. */ enum class ComponentStatus { UP, DOWN, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/HealthStatus.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/HealthStatus.kt index 62a803c..c639c29 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/HealthStatus.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/HealthStatus.kt @@ -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 = emptyMap(), ) { companion object { + /** + * Derives overall [Status] from a list of component results: + * all UP → UP, all DOWN → DOWN, mixed → DEGRADED. + */ fun fromComponents(components: List): HealthStatus { val componentMap = components.associateBy { it.name } val status = diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt index 9851e2d..e4e942d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt @@ -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 } } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt index 250891c..3704eab 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt @@ -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 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 c3c089b..243d209 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 @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/Status.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/Status.kt index 9a55b0b..48b09e2 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/Status.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/Status.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt index f5d4014..4307327 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt @@ -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 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 9ea8054..a807ef7 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 @@ -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, 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 5971bda..0a6d9b9 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 @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt index 3ffe63f..e170049 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt @@ -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 } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt index 4d82673..3341040 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt index 996c660..1545e99 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt @@ -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 + /** Returns all non-revoked refresh token records across multiple users. */ suspend fun getActiveSessions(userIds: List): List + /** 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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt index ba74679..960294d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt @@ -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, 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 index 9cc8ee5..02b81cb 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationService.kt @@ -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 } 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 index 8677c35..f9891a5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/TokenRevocationServiceImpl.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt index 8fb7485..92331d1 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt @@ -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 } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt index bccba3f..b7293d1 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt index 616878b..d2c2c45 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt @@ -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, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAccessDeniedHandler.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAccessDeniedHandler.kt index df70f53..b71d73b 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAccessDeniedHandler.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAccessDeniedHandler.kt @@ -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( diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAuthenticationEntryPoint.kt index 7e45532..25bb79d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAuthenticationEntryPoint.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/CustomAuthenticationEntryPoint.kt @@ -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( 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 94e7d55..9995ee4 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 @@ -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, 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 799edc4..8d1248a 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 @@ -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 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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt index 03614f4..8a7e993 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt @@ -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 { + /** 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 + /** 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 + /** Returns all tokens (revoked or not) owned by any of the given [userIds]. */ @Query($$"{ 'userId': { '$in': ?0 } }") suspend fun findByUserIds(userIds: List): List + /** + * 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( 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 index 55a11cd..9266e10 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RevokedTokenRepository.kt @@ -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 { + /** Returns `true` if a [RevokedToken] with the given [jti] exists. */ suspend fun existsByJti(jti: UUID): Boolean } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt index 5502b11..9b787eb 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt @@ -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 { + /** Returns the user with [email], or `null` if none exists. */ suspend fun findByEmail(email: String): User? } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt index 849a15d..24e6a0b 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt @@ -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,