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,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
|
||||
|
||||
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.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<HealthResponse> {
|
||||
suspend fun health(): ResponseEntity<HealthResponse> {
|
||||
val result = healthCheckService.check()
|
||||
val response = HealthResponseMapper.map(result)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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<HealthIndicator>,
|
||||
) {
|
||||
fun check(): HealthStatus {
|
||||
val componentHealths = healthIndicators.map { it.check() }
|
||||
return HealthStatus.fromComponents(componentHealths)
|
||||
}
|
||||
interface HealthService {
|
||||
suspend fun check(): HealthStatus
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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<User, String> {
|
||||
fun findByEmail(email: String): User?
|
||||
interface UserRepository : CoroutineCrudRepository<User, String> {
|
||||
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.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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user