refactor: implement partial JWT authentication; migrate to reactive spring/mongodb
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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
|
||||||
@@ -38,3 +38,6 @@ out/
|
|||||||
|
|
||||||
### Kotlin ###
|
### Kotlin ###
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### Environment Variables ###
|
||||||
|
.env
|
||||||
|
|||||||
+78
-13
@@ -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,10 +5,11 @@ 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 =
|
||||||
HealthResponse::status fromProperty HealthStatus::status transform { it.name }
|
mapping {
|
||||||
HealthResponse::components fromProperty HealthStatus::components transform {
|
HealthResponse::status fromProperty HealthStatus::status transform { it.name }
|
||||||
it.mapValues { (_, value) -> ComponentHealthDtoMapper.map(value) }
|
HealthResponse::components fromProperty HealthStatus::components transform {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -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)
|
||||||
mongoClient.getDatabase("admin").runCommand(command)
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user