@@ -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,
|
||||||
|
|||||||
+1
@@ -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(
|
||||||
|
|||||||
+1
@@ -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(
|
||||||
|
|||||||
+7
@@ -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
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user