diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f50583 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 5a979af..9940179 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Environment Variables ### +.env diff --git a/build.gradle.kts b/build.gradle.kts index fe8afcc..a9aa634 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,8 +3,9 @@ plugins { kotlin("plugin.spring") version "2.2.21" id("org.springframework.boot") version "4.1.0-M2" 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("com.github.ben-manes.versions") version "0.53.0" } group = "io.visus.demos" @@ -22,7 +23,7 @@ repositories { } ktlint { - version.set("1.4.1") + version.set("1.8.0") android.set(false) ignoreFailures.set(false) reporters { @@ -35,37 +36,101 @@ ktlint { } 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-databind: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("com.password4j:password4j:1.8.2") - implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.password4j:password4j:1.8.4") + + // 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("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-restclient-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.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") } +// ════════════════════════════════════════════════════════════════════════════════ +// 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 { resolutionStrategy.eachDependency { + // Ensure Jackson 3.x is at latest stable version if (requested.group == "tools.jackson.core") { useVersion("3.1.0") } + // Upgrade legacy Jackson 2.x dependencies for security and compatibility if (requested.group == "com.fasterxml.jackson.core") { if (requested.name == "jackson-annotations") { - useVersion("2.21") + useVersion("2.21") // Annotations compatibility layer } else { - useVersion("2.21.1") + useVersion("2.21.1") // Core Jackson 2.x libraries } } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt new file mode 100644 index 0000000..b56c11d --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt new file mode 100644 index 0000000..fa88e9d --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt new file mode 100644 index 0000000..939ffa8 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt new file mode 100644 index 0000000..4728bfc --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt new file mode 100644 index 0000000..780c828 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt index 54125f2..105b839 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt @@ -5,10 +5,11 @@ import io.visus.demos.kotlinapi.domain.model.HealthStatus import tech.mappie.api.ObjectMappie object HealthResponseMapper : ObjectMappie() { - override fun map(from: HealthStatus): HealthResponse = mapping { - HealthResponse::status fromProperty HealthStatus::status transform { it.name } - HealthResponse::components fromProperty HealthStatus::components transform { - it.mapValues { (_, value) -> ComponentHealthDtoMapper.map(value) } + override fun map(from: HealthStatus): HealthResponse = + mapping { + HealthResponse::status fromProperty HealthStatus::status transform { it.name } + HealthResponse::components fromProperty HealthStatus::components transform { + it.mapValues { (_, value) -> ComponentHealthDtoMapper.map(value) } + } } - } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt index b286235..f4646c1 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt @@ -4,15 +4,15 @@ import io.visus.demos.kotlinapi.api.ApiVersion import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.AnnotatedElementUtils import org.springframework.web.bind.annotation.RestController -import org.springframework.web.servlet.config.annotation.PathMatchConfigurer -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.reactive.config.PathMatchConfigurer +import org.springframework.web.reactive.config.WebFluxConfigurer @Configuration -class ApiVersionConfig : WebMvcConfigurer { - override fun configurePathMatch(configurer: PathMatchConfigurer) { +class ApiVersionConfig : WebFluxConfigurer { + override fun configurePathMatching(configurer: PathMatchConfigurer) { configurer.addPathPrefix("/api/v1") { controller -> 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 } } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt new file mode 100644 index 0000000..c00a34e --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/JwtProperties.kt @@ -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", +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt index d0553ac..6ae38d5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt @@ -2,19 +2,19 @@ package io.visus.demos.kotlinapi.config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.crypto.password.PasswordEncoder 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.CorsConfigurationSource -import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.cors.reactive.CorsConfigurationSource +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource @Configuration -@EnableWebSecurity -@EnableMethodSecurity +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity class SecurityConfig { @Bean fun corsConfigurationSource(): CorsConfigurationSource { @@ -35,20 +35,23 @@ class SecurityConfig { fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder() @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { http .csrf { it.disable() } .cors { it.configurationSource(corsConfigurationSource()) } - .authorizeHttpRequests { authorize -> + .authorizeExchange { authorize -> authorize - .requestMatchers( + .pathMatchers( "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/api/v1/health", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/register", ).permitAll() - .anyRequest() + .anyExchange() .authenticated() } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt new file mode 100644 index 0000000..9d14730 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt @@ -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 = + authService.login(request.email, request.password).let { + ResponseEntity.ok(it) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt index 2021c25..5140585 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt @@ -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.Schema 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.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.HttpStatusCodes @@ -23,7 +21,6 @@ import org.springframework.web.bind.annotation.RestController @RestController @ApiVersion(version = 1) @PreAuthorize("permitAll()") -@SecurityRequirements(SecurityRequirement(name = "bearerAuth")) @Tag(name = "Health", description = "Health check endpoints") class HealthController( private val healthCheckService: HealthService, @@ -32,6 +29,7 @@ class HealthController( @Operation( summary = "Health check", description = "Returns the health status of the API", + security = [], responses = [ ApiResponse( responseCode = HttpStatusCodes.OK, @@ -55,7 +53,7 @@ class HealthController( ), ], ) - fun health(): ResponseEntity { + suspend fun health(): ResponseEntity { val result = healthCheckService.check() val response = HealthResponseMapper.map(result) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt index a56d692..91ba3a5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt @@ -3,5 +3,5 @@ package io.visus.demos.kotlinapi.domain import io.visus.demos.kotlinapi.domain.model.ComponentHealth interface HealthIndicator { - fun check(): ComponentHealth + suspend fun check(): ComponentHealth } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..69c9576 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/ExpiredTokenException.kt @@ -0,0 +1,5 @@ +package io.visus.demos.kotlinapi.domain.exception + +class ExpiredTokenException( + message: String, +) : RuntimeException(message) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt new file mode 100644 index 0000000..73ad5a2 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/InvalidTokenException.kt @@ -0,0 +1,5 @@ +package io.visus.demos.kotlinapi.domain.exception + +class InvalidTokenException( + message: String, +) : RuntimeException(message) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt new file mode 100644 index 0000000..88da7b6 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/SecurityException.kt @@ -0,0 +1,5 @@ +package io.visus.demos.kotlinapi.domain.exception + +class SecurityException( + message: String, +) : RuntimeException(message) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt new file mode 100644 index 0000000..b607fbf --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/TokenRevokedException.kt @@ -0,0 +1,5 @@ +package io.visus.demos.kotlinapi.domain.exception + +class TokenRevokedException( + message: String, +) : RuntimeException(message) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt new file mode 100644 index 0000000..9851e2d --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/JwtTokenType.kt @@ -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 } + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt new file mode 100644 index 0000000..fc58051 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt new file mode 100644 index 0000000..d15a0e5 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt @@ -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 +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt new file mode 100644 index 0000000..ed490e6 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt index d33b996..3ffe63f 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt @@ -1,15 +1,7 @@ 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 HealthService( - private val healthIndicators: List, -) { - fun check(): HealthStatus { - val componentHealths = healthIndicators.map { it.check() } - return HealthStatus.fromComponents(componentHealths) - } +interface HealthService { + suspend fun check(): HealthStatus } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt new file mode 100644 index 0000000..4d82673 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthServiceImpl.kt @@ -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, +) : HealthService { + override suspend fun check(): HealthStatus { + val componentHealths = healthIndicators.map { it.check() } + return HealthStatus.fromComponents(componentHealths) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt new file mode 100644 index 0000000..1edf3b7 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt @@ -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) +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt index eabf1ed..b6751e0 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt @@ -1,19 +1,5 @@ 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.UsernameNotFoundException -import org.springframework.stereotype.Service -@Service -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") - } -} +interface UserService : UserDetailsService diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt new file mode 100644 index 0000000..faad172 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt index 4aa975d..fa0faf5 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt @@ -4,6 +4,8 @@ import com.mongodb.client.MongoClient import io.visus.demos.kotlinapi.domain.HealthIndicator import io.visus.demos.kotlinapi.domain.model.ComponentHealth import io.visus.demos.kotlinapi.domain.model.ComponentStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.bson.Document import org.springframework.stereotype.Service @@ -11,10 +13,13 @@ import org.springframework.stereotype.Service class MongoHealthService( private val mongoClient: MongoClient, ) : HealthIndicator { - override fun check(): ComponentHealth = + override suspend fun check(): ComponentHealth = try { val command = Document("ping", 1) - mongoClient.getDatabase("admin").runCommand(command) + + withContext(Dispatchers.IO) { + mongoClient.getDatabase("admin").runCommand(command) + } ComponentHealth( name = "mongodb", diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt new file mode 100644 index 0000000..4079b2f --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt @@ -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 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 + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt new file mode 100644 index 0000000..7fa1fa9 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt @@ -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 { + suspend fun deleteByUserId(userId: String): Long + + @Query("{ 'userId': ?0, 'isRevoked': false }") + suspend fun countActive(userId: String): Long + + suspend fun findByFamilyId(familyId: String): List + + suspend fun findByTokenHash(tokenHash: String): RefreshToken? + + @Query("{ 'userId': ?0, 'isRevoked': false }") + suspend fun findActive(userId: String): List + + @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 +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt index ca01db2..5502b11 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt @@ -1,10 +1,10 @@ package io.visus.demos.kotlinapi.infrastructure.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 @Repository -interface UserRepository : MongoRepository { - fun findByEmail(email: String): User? +interface UserRepository : CoroutineCrudRepository { + suspend fun findByEmail(email: String): User? } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt index b6d585b..849a15d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt @@ -2,6 +2,7 @@ package io.visus.demos.kotlinapi.seed import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.infrastructure.user.UserRepository +import kotlinx.coroutines.runBlocking import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner import org.springframework.security.crypto.password.PasswordEncoder @@ -30,10 +31,10 @@ class DataSeeder( email: String, rawPassword: String, role: String, - ) { + ) = runBlocking { if (userRepository.findByEmail(email) != null) { println("User '$email' already exists — skipping.") - return + return@runBlocking } val hashedPassword = diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 324177a..594f332 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,12 @@ spring: mongodb: 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: api-docs: path: /api-docs