1
0

refactor: implement partial JWT authentication; migrate to reactive spring/mongodb

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-15 11:37:15 -04:00
parent 9f5c48fffc
commit 240964ea1d
34 changed files with 642 additions and 72 deletions
+23
View File
@@ -0,0 +1,23 @@
# ════════════════════════════════════════════════════════════════════════════════
# Environment Configuration Template
# ════════════════════════════════════════════════════════════════════════════════
# Copy this file to .env and fill in your actual values
# .env is gitignored and will not be committed to version control
# ════════════════════════════════════════════════════════════════════════════════
# Database Configuration
# ════════════════════════════════════════════════════════════════════════════════
# MongoDB connection string
# Default: mongodb://localhost:27017/kotlin-api
MONGODB_URI=mongodb://localhost:27017/kotlin-api
# ════════════════════════════════════════════════════════════════════════════════
# JWT Security Configuration
# ════════════════════════════════════════════════════════════════════════════════
# REQUIRED: Generate a secure base64-encoded secret for JWT token signing
# MUST be base64-encoded (your code uses Decoders.BASE64.decode)
#
# Generate a secure base64-encoded secret (single line, no newlines):
# openssl rand -base64 64 | tr -d '\n'
#
JWT_SECRET=your-base64-encoded-secret-here
+3
View File
@@ -38,3 +38,6 @@ out/
### Kotlin ### ### Kotlin ###
.kotlin .kotlin
### Environment Variables ###
.env
+78 -13
View File
@@ -3,8 +3,9 @@ plugins {
kotlin("plugin.spring") version "2.2.21" kotlin("plugin.spring") version "2.2.21"
id("org.springframework.boot") version "4.1.0-M2" id("org.springframework.boot") version "4.1.0-M2"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
id("org.jlleitschuh.gradle.ktlint") version "12.1.0" id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("tech.mappie.plugin") version "2.2.21-2.1.1" id("tech.mappie.plugin") version "2.2.21-2.1.1"
id("com.github.ben-manes.versions") version "0.53.0"
} }
group = "io.visus.demos" group = "io.visus.demos"
@@ -22,7 +23,7 @@ repositories {
} }
ktlint { ktlint {
version.set("1.4.1") version.set("1.8.0")
android.set(false) android.set(false)
ignoreFailures.set(false) ignoreFailures.set(false)
reporters { reporters {
@@ -35,37 +36,101 @@ ktlint {
} }
dependencies { dependencies {
// ════════════════════════════════════════════════════════════════════════════════
// Environment Configuration (.env file support)
// ════════════════════════════════════════════════════════════════════════════════
developmentOnly("me.paulschwarz:springboot4-dotenv:5.1.0")
// ════════════════════════════════════════════════════════════════════════════════
// Core Spring Boot & Kotlin
// ════════════════════════════════════════════════════════════════════════════════
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.10.2")
// ════════════════════════════════════════════════════════════════════════════════
// JSON Processing (Jackson)
// ════════════════════════════════════════════════════════════════════════════════
// Spring Boot 4.x uses Jackson 3.x (tools.jackson.core)
// Third-party libs still use Jackson 2.x (see resolutionStrategy below)
implementation("tools.jackson.core:jackson-core:3.1.0") implementation("tools.jackson.core:jackson-core:3.1.0")
implementation("tools.jackson.core:jackson-databind:3.1.0") implementation("tools.jackson.core:jackson-databind:3.1.0")
implementation("tools.jackson.module:jackson-module-kotlin:3.1.0") implementation("tools.jackson.module:jackson-module-kotlin:3.1.0")
implementation("org.springframework.boot:spring-boot-starter-mongodb") // ════════════════════════════════════════════════════════════════════════════════
implementation("org.springframework.boot:spring-boot-starter-restclient") // Data Persistence (MongoDB)
// ════════════════════════════════════════════════════════════════════════════════
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
// ════════════════════════════════════════════════════════════════════════════════
// Security & Authentication
// ════════════════════════════════════════════════════════════════════════════════
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.password4j:password4j:1.8.2") implementation("com.password4j:password4j:1.8.4")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect") // JWT Token Management
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
// ════════════════════════════════════════════════════════════════════════════════
// Validation
// ════════════════════════════════════════════════════════════════════════════════
implementation("org.hibernate.validator:hibernate-validator:9.1.0.Final")
// ════════════════════════════════════════════════════════════════════════════════
// API Documentation (OpenAPI/Swagger)
// ════════════════════════════════════════════════════════════════════════════════
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.2")
// ════════════════════════════════════════════════════════════════════════════════
// Mapping & Utilities
// ════════════════════════════════════════════════════════════════════════════════
implementation("tech.mappie:mappie-api:2.2.21-2.1.1") implementation("tech.mappie:mappie-api:2.2.21-2.1.1")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb") // ════════════════════════════════════════════════════════════════════════════════
// Testing
// ════════════════════════════════════════════════════════════════════════════════
// Core Spring Boot testing support (includes JUnit 5, AssertJ, Mockito, JSONPath, etc.)
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Spring Boot domain-specific test modules
testImplementation("org.springframework.boot:spring-boot-starter-mongodb-test") testImplementation("org.springframework.boot:spring-boot-starter-mongodb-test")
testImplementation("org.springframework.boot:spring-boot-starter-restclient-test")
testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-webflux-test")
// Kotlin testing support
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
// Kotlin-native mocking (more idiomatic than Mockito for Kotlin)
testImplementation("io.mockk:mockk:1.14.9")
testImplementation("com.ninja-squad:springmockk:5.0.1")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
// ════════════════════════════════════════════════════════════════════════════════
// Dependency Resolution Strategy
// ════════════════════════════════════════════════════════════════════════════════
// Force Jackson version alignment to prevent conflicts between:
// - Spring Boot 4.x (uses Jackson 3.x / tools.jackson.core)
// - Third-party libs still on Jackson 2.x (com.fasterxml.jackson):
// * springdoc-openapi:3.0.2 → brings Jackson 2.19.2
// * jjwt-jackson:0.12.6 → brings Jackson 2.12.7.1
configurations.all { configurations.all {
resolutionStrategy.eachDependency { resolutionStrategy.eachDependency {
// Ensure Jackson 3.x is at latest stable version
if (requested.group == "tools.jackson.core") { if (requested.group == "tools.jackson.core") {
useVersion("3.1.0") useVersion("3.1.0")
} }
// Upgrade legacy Jackson 2.x dependencies for security and compatibility
if (requested.group == "com.fasterxml.jackson.core") { if (requested.group == "com.fasterxml.jackson.core") {
if (requested.name == "jackson-annotations") { if (requested.name == "jackson-annotations") {
useVersion("2.21") useVersion("2.21") // Annotations compatibility layer
} else { } else {
useVersion("2.21.1") useVersion("2.21.1") // Core Jackson 2.x libraries
} }
} }
} }
@@ -0,0 +1,15 @@
package io.visus.demos.kotlinapi.api.dto
import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "Authentication response containing tokens and user information")
data class AuthResponse(
@Schema(description = "JWT access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val accessToken: String,
@Schema(description = "JWT refresh token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val refreshToken: String,
@Schema(description = "Token type", example = "Bearer")
val tokenType: String = "Bearer",
@Schema(description = "Access token expiration time in seconds", example = "3600")
val expiresIn: Long,
)
@@ -0,0 +1,16 @@
package io.visus.demos.kotlinapi.api.dto
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
@Schema(description = "Error response")
data class ErrorResponse(
@Schema(description = "HTTP status code", example = "403")
val status: Int,
@Schema(description = "Error message", example = "Forbidden: You don't have permission to access this resource")
val message: String,
@Schema(description = "Timestamp of the error occurrence", example = "2024-01-01T12:00:00Z")
val timestamp: Instant = Instant.now(),
@Schema(description = "Request path", example = "/api/v1/users/me")
val path: String? = null,
)
@@ -0,0 +1,16 @@
package io.visus.demos.kotlinapi.api.dto
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
@Schema(description = "Login request")
data class LoginRequest(
@field:Email(message = "Invalid email format")
@field:NotBlank(message = "Email is required")
@Schema(description = "User email address", example = "user@example.com")
val email: String,
@field:NotBlank(message = "Password is required")
@Schema(description = "User password", example = "P@ssw0rd!")
val password: String,
)
@@ -0,0 +1,11 @@
package io.visus.demos.kotlinapi.api.dto
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
@Schema(description = "Refresh token request")
data class RefreshTokenRequest(
@field:NotBlank(message = "Refresh token is required")
@Schema(description = "JWT refresh token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val refreshToken: String,
)
@@ -0,0 +1,13 @@
package io.visus.demos.kotlinapi.api.dto
import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "User information")
data class UserDto(
@Schema(description = "Unique identifier of the user", example = "507f1f77bcf86cd799439011")
val id: String,
@Schema(description = "User email address", example = "user@example.com")
val email: String,
@Schema(description = "User role", example = "ROLE_USER")
val role: String,
)
@@ -5,7 +5,8 @@ import io.visus.demos.kotlinapi.domain.model.HealthStatus
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() { object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
override fun map(from: HealthStatus): HealthResponse = mapping { override fun map(from: HealthStatus): HealthResponse =
mapping {
HealthResponse::status fromProperty HealthStatus::status transform { it.name } HealthResponse::status fromProperty HealthStatus::status transform { it.name }
HealthResponse::components fromProperty HealthStatus::components transform { HealthResponse::components fromProperty HealthStatus::components transform {
it.mapValues { (_, value) -> ComponentHealthDtoMapper.map(value) } it.mapValues { (_, value) -> ComponentHealthDtoMapper.map(value) }
@@ -4,15 +4,15 @@ import io.visus.demos.kotlinapi.api.ApiVersion
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.AnnotatedElementUtils import org.springframework.core.annotation.AnnotatedElementUtils
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer import org.springframework.web.reactive.config.PathMatchConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.reactive.config.WebFluxConfigurer
@Configuration @Configuration
class ApiVersionConfig : WebMvcConfigurer { class ApiVersionConfig : WebFluxConfigurer {
override fun configurePathMatch(configurer: PathMatchConfigurer) { override fun configurePathMatching(configurer: PathMatchConfigurer) {
configurer.addPathPrefix("/api/v1") { controller -> configurer.addPathPrefix("/api/v1") { controller ->
val apiVersion = AnnotatedElementUtils.findMergedAnnotation(controller, ApiVersion::class.java) val apiVersion = AnnotatedElementUtils.findMergedAnnotation(controller, ApiVersion::class.java)
controller.isAnnotationPresent(RestController::class.java) && apiVersion?.version == 1 AnnotatedElementUtils.hasAnnotation(controller, RestController::class.java) && apiVersion?.version == 1
} }
} }
} }
@@ -0,0 +1,13 @@
package io.visus.demos.kotlinapi.config
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component
@Component
@ConfigurationProperties("jwt")
data class JwtProperties(
var secret: String = "",
var accessTokenExpiration: Long = 3600000, // 1 hour
var refreshTokenExpiration: Long = 2592000000, // 30 days
var issuer: String = "kotlin-api",
)
@@ -2,19 +2,19 @@ package io.visus.demos.kotlinapi.config
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@Configuration @Configuration
@EnableWebSecurity @EnableWebFluxSecurity
@EnableMethodSecurity @EnableReactiveMethodSecurity
class SecurityConfig { class SecurityConfig {
@Bean @Bean
fun corsConfigurationSource(): CorsConfigurationSource { fun corsConfigurationSource(): CorsConfigurationSource {
@@ -35,20 +35,23 @@ class SecurityConfig {
fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder() fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder()
@Bean @Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
http http
.csrf { it.disable() } .csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) } .cors { it.configurationSource(corsConfigurationSource()) }
.authorizeHttpRequests { authorize -> .authorizeExchange { authorize ->
authorize authorize
.requestMatchers( .pathMatchers(
"/swagger-ui.html", "/swagger-ui.html",
"/swagger-ui/**", "/swagger-ui/**",
"/v3/api-docs/**", "/v3/api-docs/**",
"/api-docs/**", "/api-docs/**",
"/api/v1/health", "/api/v1/health",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/register",
).permitAll() ).permitAll()
.anyRequest() .anyExchange()
.authenticated() .authenticated()
} }
@@ -0,0 +1,61 @@
package io.visus.demos.kotlinapi.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.dto.AuthResponse
import io.visus.demos.kotlinapi.api.dto.LoginRequest
import io.visus.demos.kotlinapi.domain.service.AuthService
import jakarta.validation.Valid
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
@ApiVersion(version = 1)
@Tag(name = "Authentication", description = "Endpoints for user authentication and authorization")
class AuthController(
private val authService: AuthService,
) {
@PostMapping("/auth/login", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Login",
description = "Authenticate user with email and password, returns JWT tokens",
security = [],
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Successful login, returns access and refresh tokens",
content = [Content(schema = Schema(implementation = AuthResponse::class))],
),
ApiResponse(
responseCode = "400",
description = "Invalid request payload",
content = [Content(schema = Schema(implementation = AuthResponse::class))],
),
ApiResponse(
responseCode = "401",
description = "Unauthorized, invalid email or password",
content = [Content(schema = Schema(implementation = AuthResponse::class))],
),
ApiResponse(
responseCode = "403",
description = "Forbidden",
),
],
)
suspend fun login(
@Valid @RequestBody request: LoginRequest,
): ResponseEntity<AuthResponse> =
authService.login(request.email, request.password).let {
ResponseEntity.ok(it)
}
}
@@ -4,8 +4,6 @@ import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.security.SecurityRequirements
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.HttpStatusCodes import io.visus.demos.kotlinapi.api.HttpStatusCodes
@@ -23,7 +21,6 @@ import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@ApiVersion(version = 1) @ApiVersion(version = 1)
@PreAuthorize("permitAll()") @PreAuthorize("permitAll()")
@SecurityRequirements(SecurityRequirement(name = "bearerAuth"))
@Tag(name = "Health", description = "Health check endpoints") @Tag(name = "Health", description = "Health check endpoints")
class HealthController( class HealthController(
private val healthCheckService: HealthService, private val healthCheckService: HealthService,
@@ -32,6 +29,7 @@ class HealthController(
@Operation( @Operation(
summary = "Health check", summary = "Health check",
description = "Returns the health status of the API", description = "Returns the health status of the API",
security = [],
responses = [ responses = [
ApiResponse( ApiResponse(
responseCode = HttpStatusCodes.OK, responseCode = HttpStatusCodes.OK,
@@ -55,7 +53,7 @@ class HealthController(
), ),
], ],
) )
fun health(): ResponseEntity<HealthResponse> { suspend fun health(): ResponseEntity<HealthResponse> {
val result = healthCheckService.check() val result = healthCheckService.check()
val response = HealthResponseMapper.map(result) val response = HealthResponseMapper.map(result)
@@ -3,5 +3,5 @@ package io.visus.demos.kotlinapi.domain
import io.visus.demos.kotlinapi.domain.model.ComponentHealth import io.visus.demos.kotlinapi.domain.model.ComponentHealth
interface HealthIndicator { interface HealthIndicator {
fun check(): ComponentHealth suspend fun check(): ComponentHealth
} }
@@ -0,0 +1,5 @@
package io.visus.demos.kotlinapi.domain.exception
class ExpiredTokenException(
message: String,
) : RuntimeException(message)
@@ -0,0 +1,5 @@
package io.visus.demos.kotlinapi.domain.exception
class InvalidTokenException(
message: String,
) : RuntimeException(message)
@@ -0,0 +1,5 @@
package io.visus.demos.kotlinapi.domain.exception
class SecurityException(
message: String,
) : RuntimeException(message)
@@ -0,0 +1,5 @@
package io.visus.demos.kotlinapi.domain.exception
class TokenRevokedException(
message: String,
) : RuntimeException(message)
@@ -0,0 +1,13 @@
package io.visus.demos.kotlinapi.domain.model
enum class JwtTokenType(
val value: String,
) {
ACCESS("access"),
REFRESH("refresh"),
;
companion object {
fun fromValue(value: String?): JwtTokenType? = entries.find { it.value == value }
}
}
@@ -0,0 +1,28 @@
package io.visus.demos.kotlinapi.domain.model
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
import java.util.Date
@Document(collection = "refresh_tokens")
data class RefreshToken(
@Id
val id: String? = null,
@Indexed(unique = true)
val tokenHash: String,
@Indexed
val userId: String,
@Indexed(expireAfter = "30d")
val expiresAt: Date,
var isRevoked: Boolean = false,
@Indexed
val familyId: String,
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedDate
val updatedAt: Instant? = null,
)
@@ -0,0 +1,17 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.api.dto.AuthResponse
interface AuthService {
suspend fun login(
email: String,
password: String,
): AuthResponse
suspend fun refreshToken(refreshToken: String): AuthResponse
suspend fun register(
email: String,
password: String,
): AuthResponse
}
@@ -0,0 +1,57 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.api.dto.AuthResponse
import io.visus.demos.kotlinapi.config.JwtProperties
import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
@Service
class AuthServiceImpl(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtTokenProvider: JwtTokenProvider,
private val jwtProperties: JwtProperties,
) : AuthService {
override suspend fun login(
email: String,
password: String,
): AuthResponse {
val user =
userRepository.findByEmail(email)
?: throw UsernameNotFoundException("User not found with email: $email")
if (!passwordEncoder.matches(password, user.password)) {
throw BadCredentialsException("Invalid password for email: $email")
}
return generateAuthResponse(user)
}
override suspend fun refreshToken(refreshToken: String): AuthResponse {
TODO("Not yet implemented")
}
override suspend fun register(
email: String,
password: String,
): AuthResponse {
TODO("Not yet implemented")
}
private fun generateAuthResponse(user: User): AuthResponse {
val accessToken = jwtTokenProvider.generateAccessToken(user)
val refreshToken = jwtTokenProvider.generateRefreshToken(user)
return AuthResponse(
accessToken = accessToken,
refreshToken = refreshToken,
tokenType = "Bearer",
expiresIn = jwtProperties.accessTokenExpiration / 1000,
)
}
}
@@ -1,15 +1,7 @@
package io.visus.demos.kotlinapi.domain.service package io.visus.demos.kotlinapi.domain.service
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
@Service interface HealthService {
class HealthService( suspend fun check(): HealthStatus
private val healthIndicators: List<HealthIndicator>,
) {
fun check(): HealthStatus {
val componentHealths = healthIndicators.map { it.check() }
return HealthStatus.fromComponents(componentHealths)
}
} }
@@ -0,0 +1,15 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.HealthIndicator
import io.visus.demos.kotlinapi.domain.model.HealthStatus
import org.springframework.stereotype.Service
@Service
class HealthServiceImpl(
private val healthIndicators: List<HealthIndicator>,
) : HealthService {
override suspend fun check(): HealthStatus {
val componentHealths = healthIndicators.map { it.check() }
return HealthStatus.fromComponents(componentHealths)
}
}
@@ -0,0 +1,16 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.model.RefreshToken
interface RefreshTokenService {
fun createRefreshToken(
userId: String,
familyId: String,
): String
fun validateAndRotate(tokenString: String): RefreshToken
fun revokeAllUserTokens(userId: String)
fun revokeTokenFamily(familyId: String)
}
@@ -1,19 +1,5 @@
package io.visus.demos.kotlinapi.domain.service package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service interface UserService : UserDetailsService
class UserService(
private val userRepository: UserRepository,
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
if (username.isBlank()) throw UsernameNotFoundException("Username must not be null or empty")
return userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found with email: $username")
}
}
@@ -0,0 +1,21 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import kotlinx.coroutines.runBlocking
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class UserServiceImpl(
private val userRepository: UserRepository,
) : UserService {
override fun loadUserByUsername(username: String): UserDetails {
if (username.isBlank()) throw UsernameNotFoundException("Username must not be null or empty")
return runBlocking {
userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found with email: $username")
}
}
}
@@ -4,6 +4,8 @@ import com.mongodb.client.MongoClient
import io.visus.demos.kotlinapi.domain.HealthIndicator import io.visus.demos.kotlinapi.domain.HealthIndicator
import io.visus.demos.kotlinapi.domain.model.ComponentHealth import io.visus.demos.kotlinapi.domain.model.ComponentHealth
import io.visus.demos.kotlinapi.domain.model.ComponentStatus import io.visus.demos.kotlinapi.domain.model.ComponentStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.bson.Document import org.bson.Document
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -11,10 +13,13 @@ import org.springframework.stereotype.Service
class MongoHealthService( class MongoHealthService(
private val mongoClient: MongoClient, private val mongoClient: MongoClient,
) : HealthIndicator { ) : HealthIndicator {
override fun check(): ComponentHealth = override suspend fun check(): ComponentHealth =
try { try {
val command = Document("ping", 1) val command = Document("ping", 1)
withContext(Dispatchers.IO) {
mongoClient.getDatabase("admin").runCommand(command) mongoClient.getDatabase("admin").runCommand(command)
}
ComponentHealth( ComponentHealth(
name = "mongodb", name = "mongodb",
@@ -0,0 +1,127 @@
package io.visus.demos.kotlinapi.infrastructure.security
import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.MalformedJwtException
import io.jsonwebtoken.UnsupportedJwtException
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import io.visus.demos.kotlinapi.config.JwtProperties
import io.visus.demos.kotlinapi.domain.model.JwtTokenType
import org.slf4j.LoggerFactory
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.security.SignatureException
import java.util.Date
import javax.crypto.SecretKey
@Component
class JwtTokenProvider(
private val jwtProperties: JwtProperties,
) {
private val logger = LoggerFactory.getLogger(JwtTokenProvider::class.java)
private val secretKey: SecretKey by lazy {
val bytes = Decoders.BASE64.decode(jwtProperties.secret)
Keys.hmacShaKeyFor(bytes)
}
fun <T> extractClaim(
token: String,
claimsResolver: (Claims) -> T,
): T {
val claims =
Jwts
.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.payload
return claimsResolver(claims)
}
fun extractExpiration(token: String): Date = extractClaim(token, Claims::getExpiration)
fun extractUsername(token: String): String = extractClaim(token, Claims::getSubject)
fun extractTokenType(token: String): JwtTokenType? =
extractClaim(token) { claims ->
val typeValue = claims["type"] as? String
JwtTokenType.fromValue(typeValue)
}
fun generateAccessToken(userDetails: UserDetails): String {
val now = Date()
val expiry = Date(now.time + jwtProperties.accessTokenExpiration)
return Jwts
.builder()
.subject(userDetails.username)
.claim("type", JwtTokenType.ACCESS.value)
.claim("authorities", userDetails.authorities.map { it.authority })
.issuedAt(now)
.expiration(expiry)
.issuer(jwtProperties.issuer)
.signWith(secretKey, Jwts.SIG.HS512)
.compact()
}
fun generateRefreshToken(userDetails: UserDetails): String {
val now = Date()
val expiry = Date(now.time + jwtProperties.refreshTokenExpiration)
return Jwts
.builder()
.subject(userDetails.username)
.claim("type", JwtTokenType.REFRESH.value)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey, Jwts.SIG.HS512)
.compact()
}
fun isTokenValid(
token: String,
userDetails: UserDetails,
tokenType: JwtTokenType,
): Boolean {
val username = extractUsername(token)
val type = extractTokenType(token)
return username == userDetails.username &&
type == tokenType &&
!isTokenExpired(token) &&
validateToken(token)
}
fun isTokenExpired(token: String): Boolean {
val expiration = extractExpiration(token)
return expiration.before(Date())
}
private fun validateToken(token: String): Boolean {
try {
Jwts
.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
return true
} catch (ex: SignatureException) {
logger.error("Invalid JWT signature: {}", ex.message)
} catch (ex: MalformedJwtException) {
logger.error("Invalid JWT token: {}", ex.message)
} catch (ex: ExpiredJwtException) {
logger.error("Expired JWT token: {}", ex.message)
} catch (ex: UnsupportedJwtException) {
logger.error("Unsupported JWT token: {}", ex.message)
} catch (ex: IllegalArgumentException) {
logger.error("JWT claims string is empty: {}", ex.message)
}
return false
}
}
@@ -0,0 +1,28 @@
package io.visus.demos.kotlinapi.infrastructure.user
import io.visus.demos.kotlinapi.domain.model.RefreshToken
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.Update
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
suspend fun deleteByUserId(userId: String): Long
@Query("{ 'userId': ?0, 'isRevoked': false }")
suspend fun countActive(userId: String): Long
suspend fun findByFamilyId(familyId: String): List<RefreshToken>
suspend fun findByTokenHash(tokenHash: String): RefreshToken?
@Query("{ 'userId': ?0, 'isRevoked': false }")
suspend fun findActive(userId: String): List<RefreshToken>
@Query("{ 'familyId': ?0 }")
@Update($$"{ '$set': { 'isRevoked': true } }")
suspend fun revokeByFamilyId(familyId: String): Long
@Query("{ 'tokenHash': ?0 }")
@Update($$"{ '$set': { 'isRevoked': true } }")
suspend fun revokeByTokenHash(tokenHash: String): Long
}
@@ -1,10 +1,10 @@
package io.visus.demos.kotlinapi.infrastructure.user package io.visus.demos.kotlinapi.infrastructure.user
import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.domain.model.User
import org.springframework.data.mongodb.repository.MongoRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface UserRepository : MongoRepository<User, String> { interface UserRepository : CoroutineCrudRepository<User, String> {
fun findByEmail(email: String): User? suspend fun findByEmail(email: String): User?
} }
@@ -2,6 +2,7 @@ package io.visus.demos.kotlinapi.seed
import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import kotlinx.coroutines.runBlocking
import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner import org.springframework.boot.ApplicationRunner
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
@@ -30,10 +31,10 @@ class DataSeeder(
email: String, email: String,
rawPassword: String, rawPassword: String,
role: String, role: String,
) { ) = runBlocking {
if (userRepository.findByEmail(email) != null) { if (userRepository.findByEmail(email) != null) {
println("User '$email' already exists — skipping.") println("User '$email' already exists — skipping.")
return return@runBlocking
} }
val hashedPassword = val hashedPassword =
+6
View File
@@ -7,6 +7,12 @@ spring:
mongodb: mongodb:
uri: ${MONGODB_URI:mongodb://localhost:27017/kotlin-api} uri: ${MONGODB_URI:mongodb://localhost:27017/kotlin-api}
jwt:
secret: ${JWT_SECRET}
access-token-expiration: 3600000 # 1 hour in milliseconds
refresh-token-expiration: 2592000000 # 30 days in milliseconds
issuer: kotlin-api
springdoc: springdoc:
api-docs: api-docs:
path: /api-docs path: /api-docs