feat(user): add users controller
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
|
||||
@Schema(description = "Health status of an individual component")
|
||||
data class ComponentHealthDto(
|
||||
data class ComponentHealthResponse(
|
||||
@Schema(description = "Component status", example = "UP")
|
||||
val status: String,
|
||||
@Schema(description = "Optional message with additional details", example = "MongoDB connection is active")
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import java.time.Instant
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import java.time.Instant
|
||||
@@ -10,5 +10,5 @@ data class HealthResponse(
|
||||
@Schema(description = "Timestamp of the health check", example = "2024-01-01T12:00:00Z")
|
||||
val timestamp: Instant,
|
||||
@Schema(description = "Status of individual components")
|
||||
val components: Map<String, ComponentHealthDto>? = null,
|
||||
val components: Map<String, ComponentHealthResponse>? = null,
|
||||
)
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import jakarta.validation.constraints.Email
|
||||
@@ -11,6 +11,6 @@ data class LoginRequest(
|
||||
@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!")
|
||||
@Schema(description = "User password", example = "user12345")
|
||||
val password: String,
|
||||
)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import jakarta.validation.constraints.Email
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
@Schema(description = "User registration request")
|
||||
data class RegisterRequest(
|
||||
@field:Email(message = "Invalid email address")
|
||||
@field:NotBlank(message = "Email address cannot be blank")
|
||||
@Schema(description = "User's email address", example = "newuser@example.com")
|
||||
val email: String,
|
||||
@field:NotBlank(message = "Password cannot be blank")
|
||||
@field:Size(min = 8, message = "Password must be at least 8 characters")
|
||||
@Schema(description = "User password (minimum 8 characters)", example = "SecurePass123!")
|
||||
val password: String,
|
||||
)
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
package io.visus.demos.kotlinapi.api.dto
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
|
||||
@Schema(description = "User information")
|
||||
data class UserDto(
|
||||
@Schema(description = "Registered user information")
|
||||
data class RegisterResponse(
|
||||
@Schema(description = "Unique identifier of the user", example = "507f1f77bcf86cd799439011")
|
||||
val id: String,
|
||||
@Schema(description = "User email address", example = "user@example.com")
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.visus.demos.kotlinapi.api.contracts
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
|
||||
@Schema(description = "User information")
|
||||
data class UserResponse(
|
||||
@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,
|
||||
@Schema(description = "Active sessions", example = "0")
|
||||
val activeSessions: List<String>,
|
||||
)
|
||||
@@ -1,12 +1,12 @@
|
||||
package io.visus.demos.kotlinapi.api.mappers
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.ComponentHealthDto
|
||||
import io.visus.demos.kotlinapi.api.contracts.ComponentHealthResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.ComponentHealth
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthDto>() {
|
||||
override fun map(from: ComponentHealth): ComponentHealthDto =
|
||||
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthResponse>() {
|
||||
override fun map(from: ComponentHealth): ComponentHealthResponse =
|
||||
mapping {
|
||||
ComponentHealthDto::status fromProperty ComponentHealth::status transform { it.name }
|
||||
ComponentHealthResponse::status fromProperty ComponentHealth::status transform { it.name }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package io.visus.demos.kotlinapi.api.mappers
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.HealthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.HealthResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.HealthStatus
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class OpenApiConfig {
|
||||
.bearerFormat("JWT")
|
||||
.description("Enter JWT Bearer token"),
|
||||
),
|
||||
).addSecurityItem(SecurityRequirement().addList("bearerAuth"))
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun apiV1(): GroupedOpenApi =
|
||||
|
||||
@@ -61,15 +61,12 @@ class SecurityConfig(
|
||||
.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()
|
||||
.pathMatchers("/api/v1/auth/logout")
|
||||
.authenticated()
|
||||
.anyExchange()
|
||||
.authenticated()
|
||||
}.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
||||
|
||||
@@ -8,10 +8,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement
|
||||
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.ErrorResponse
|
||||
import io.visus.demos.kotlinapi.api.dto.LoginRequest
|
||||
import io.visus.demos.kotlinapi.api.dto.RefreshTokenRequest
|
||||
import io.visus.demos.kotlinapi.api.contracts.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.ErrorResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.LoginRequest
|
||||
import io.visus.demos.kotlinapi.api.contracts.RefreshTokenRequest
|
||||
import io.visus.demos.kotlinapi.api.contracts.RegisterRequest
|
||||
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.User
|
||||
import io.visus.demos.kotlinapi.domain.service.AuthService
|
||||
import jakarta.validation.Valid
|
||||
@@ -117,4 +119,31 @@ class AuthController(
|
||||
val response = authService.refreshToken(request.refreshToken)
|
||||
return ResponseEntity.ok(response)
|
||||
}
|
||||
|
||||
@PostMapping("/auth/register", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
@Operation(
|
||||
summary = "Register new user",
|
||||
description = "Creates a new user account",
|
||||
security = [],
|
||||
)
|
||||
@ApiResponses(
|
||||
value = [
|
||||
ApiResponse(
|
||||
responseCode = "201",
|
||||
description = "User successfully registered",
|
||||
content = [Content(schema = Schema(implementation = RegisterResponse::class))],
|
||||
),
|
||||
ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid request payload",
|
||||
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
|
||||
),
|
||||
],
|
||||
)
|
||||
suspend fun register(
|
||||
@Valid @RequestBody request: RegisterRequest,
|
||||
): ResponseEntity<RegisterResponse> {
|
||||
val user = authService.register(request.email, request.password)
|
||||
return ResponseEntity.status(201).body(user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import io.visus.demos.kotlinapi.api.ApiVersion
|
||||
import io.visus.demos.kotlinapi.api.HttpStatusCodes
|
||||
import io.visus.demos.kotlinapi.api.dto.HealthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.HealthResponse
|
||||
import io.visus.demos.kotlinapi.api.mappers.HealthResponseMapper
|
||||
import io.visus.demos.kotlinapi.domain.model.Status
|
||||
import io.visus.demos.kotlinapi.domain.service.HealthService
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.visus.demos.kotlinapi.controller
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import io.visus.demos.kotlinapi.api.ApiVersion
|
||||
import io.visus.demos.kotlinapi.api.contracts.UserResponse
|
||||
import io.visus.demos.kotlinapi.domain.model.User
|
||||
import io.visus.demos.kotlinapi.domain.service.RefreshTokenService
|
||||
import io.visus.demos.kotlinapi.domain.service.UserService
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@Tag(name = "Users", description = "Endpoints for user management")
|
||||
class UsersController(
|
||||
private val refreshTokenService: RefreshTokenService,
|
||||
private val userService: UserService,
|
||||
) {
|
||||
@GetMapping("/users", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Operation(
|
||||
summary = "Get all users",
|
||||
description = "Returns a list of all users (Admin only)",
|
||||
security = [SecurityRequirement(name = "bearerAuth")],
|
||||
)
|
||||
suspend fun listUsers(): ResponseEntity<List<UserResponse>> {
|
||||
val users = userService.findAll()
|
||||
val userIds = users.mapNotNull { user -> user.id }.toList()
|
||||
val sessions = refreshTokenService.getActiveSessions(userIds)
|
||||
|
||||
val response =
|
||||
users
|
||||
.mapNotNull { user ->
|
||||
UserResponse(
|
||||
id = user.id ?: "",
|
||||
email = user.email,
|
||||
role = user.role,
|
||||
activeSessions =
|
||||
sessions
|
||||
.filter { session ->
|
||||
session.userId.toString() == user.id
|
||||
}.mapNotNull { session -> session.id },
|
||||
)
|
||||
}.toList()
|
||||
|
||||
return ResponseEntity.ok(response)
|
||||
}
|
||||
|
||||
@GetMapping("/users/me", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
@Operation(
|
||||
summary = "Get current user",
|
||||
description = "Returns information about the currently logged in user",
|
||||
security = [SecurityRequirement(name = "bearerAuth")],
|
||||
)
|
||||
suspend fun me(authentication: Authentication): ResponseEntity<UserResponse> {
|
||||
val user = authentication.principal as User
|
||||
val sessions = refreshTokenService.getActiveSessions(user.id ?: "")
|
||||
|
||||
return ResponseEntity.ok(
|
||||
UserResponse(
|
||||
id = user.id ?: "",
|
||||
email = user.email,
|
||||
role = user.role,
|
||||
activeSessions = sessions.mapNotNull { session -> session.id },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+118
-1
@@ -1,13 +1,16 @@
|
||||
package io.visus.demos.kotlinapi.domain.exception
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.ErrorResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.ErrorResponse
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
import org.springframework.validation.FieldError
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.bind.support.WebExchangeBindException
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
|
||||
@RestControllerAdvice
|
||||
@@ -47,4 +50,118 @@ class GlobalExceptionHandler {
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidTokenException::class)
|
||||
fun handleInvalidTokenException(
|
||||
ex: InvalidTokenException,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
logger.warn("Invalid token: {}", ex.message)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.UNAUTHORIZED.value(),
|
||||
message = ex.message ?: "Invalid token",
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(SecurityException::class)
|
||||
fun handleSecurityException(
|
||||
ex: SecurityException,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
logger.error(
|
||||
"SECURITY BREACH DETECTED: {} from IP: {}",
|
||||
ex.message,
|
||||
exchange.request.remoteAddress,
|
||||
)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.UNAUTHORIZED.value(),
|
||||
message = ex.message ?: "Token reuse detected. All sessions revoked for security.",
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException::class)
|
||||
fun handleIllegalArgumentException(
|
||||
ex: IllegalArgumentException,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
logger.warn("Illegal argument: {}", ex.message)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.BAD_REQUEST.value(),
|
||||
message = ex.message ?: "Illegal request",
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(WebExchangeBindException::class)
|
||||
fun handleWebExchangeBindException(
|
||||
ex: WebExchangeBindException,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
val errors =
|
||||
ex.bindingResult.allErrors
|
||||
.joinToString(", ") { error ->
|
||||
val fieldName = (error as? FieldError)?.field ?: "unknown"
|
||||
val message = error.defaultMessage ?: "validation error"
|
||||
"$fieldName: $message"
|
||||
}
|
||||
|
||||
logger.warn("Validation error: {}", errors)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.BAD_REQUEST.value(),
|
||||
message = errors,
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthorizationDeniedException::class)
|
||||
fun handleAuthorizationException(
|
||||
ex: AuthorizationDeniedException,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
logger.warn("Authorization denied: {}", ex.message)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.UNAUTHORIZED.value(),
|
||||
message = ex.message ?: "Authorization denied",
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleException(
|
||||
ex: Exception,
|
||||
exchange: ServerWebExchange,
|
||||
): ResponseEntity<ErrorResponse> {
|
||||
logger.error("Unexpected error", ex)
|
||||
|
||||
val error =
|
||||
ErrorResponse(
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
||||
message = "An unexpected error occurred",
|
||||
path = exchange.request.path.toString(),
|
||||
)
|
||||
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ data class RefreshToken(
|
||||
val tokenHash: String,
|
||||
@Indexed
|
||||
val userId: ObjectId,
|
||||
@Indexed(expireAfter = "30d")
|
||||
@Indexed(expireAfter = "0s")
|
||||
val expiresAt: Date,
|
||||
var isRevoked: Boolean = false,
|
||||
@Indexed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
|
||||
|
||||
interface AuthService {
|
||||
suspend fun login(
|
||||
@@ -15,5 +16,5 @@ interface AuthService {
|
||||
suspend fun register(
|
||||
email: String,
|
||||
password: String,
|
||||
): AuthResponse
|
||||
): RegisterResponse
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.AuthResponse
|
||||
import io.visus.demos.kotlinapi.api.contracts.RegisterResponse
|
||||
import io.visus.demos.kotlinapi.config.JwtProperties
|
||||
import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException
|
||||
import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException
|
||||
@@ -70,8 +71,26 @@ class AuthServiceImpl(
|
||||
override suspend fun register(
|
||||
email: String,
|
||||
password: String,
|
||||
): AuthResponse {
|
||||
TODO("Not yet implemented")
|
||||
): RegisterResponse {
|
||||
val existingUser = userRepository.findByEmail(email)
|
||||
if (existingUser != null) {
|
||||
throw IllegalArgumentException("User already exists with email: $email")
|
||||
}
|
||||
|
||||
val user =
|
||||
User(
|
||||
email = email.lowercase(),
|
||||
passwordHash = passwordEncoder.encode(password)!!,
|
||||
role = "ROLE_USER",
|
||||
)
|
||||
|
||||
val savedUser = userRepository.save(user)
|
||||
|
||||
return RegisterResponse(
|
||||
id = savedUser.id ?: "",
|
||||
email = savedUser.email,
|
||||
role = savedUser.role,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun generateAuthResponse(
|
||||
|
||||
@@ -8,12 +8,18 @@ interface RefreshTokenService {
|
||||
familyId: String,
|
||||
): String
|
||||
|
||||
suspend fun validateAndRotate(
|
||||
userId: String,
|
||||
tokenString: String,
|
||||
): RefreshToken
|
||||
suspend fun getActiveSessionCount(userId: String): Long
|
||||
|
||||
suspend fun getActiveSessions(userId: String): List<RefreshToken>
|
||||
|
||||
suspend fun getActiveSessions(userIds: List<String>): List<RefreshToken>
|
||||
|
||||
suspend fun revokeAllUserTokens(userId: String)
|
||||
|
||||
suspend fun revokeTokenFamily(familyId: String)
|
||||
|
||||
suspend fun validateAndRotate(
|
||||
userId: String,
|
||||
tokenString: String,
|
||||
): RefreshToken
|
||||
}
|
||||
|
||||
+32
-26
@@ -75,6 +75,37 @@ class RefreshTokenServiceImpl(
|
||||
return jwtToken
|
||||
}
|
||||
|
||||
override suspend fun getActiveSessionCount(userId: String): Long =
|
||||
refreshTokenRepository.countActive(ObjectId(userId))
|
||||
|
||||
override suspend fun getActiveSessions(userId: String): List<RefreshToken> =
|
||||
refreshTokenRepository.findActive(ObjectId(userId))
|
||||
|
||||
override suspend fun getActiveSessions(userIds: List<String>): List<RefreshToken> =
|
||||
refreshTokenRepository.findByUserIds(
|
||||
userIds.map {
|
||||
ObjectId(it)
|
||||
},
|
||||
)
|
||||
|
||||
override suspend fun revokeTokenFamily(familyId: String) {
|
||||
val tokens = refreshTokenRepository.findByFamilyId(familyId)
|
||||
|
||||
tokens.forEach {
|
||||
it.isRevoked = true
|
||||
it.revokedAt = Instant.now()
|
||||
}
|
||||
|
||||
refreshTokenRepository.saveAll(tokens).collect()
|
||||
}
|
||||
|
||||
private fun hashToken(token: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = digest.digest(token.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
|
||||
override suspend fun validateAndRotate(
|
||||
userId: String,
|
||||
tokenString: String,
|
||||
@@ -120,31 +151,6 @@ class RefreshTokenServiceImpl(
|
||||
}
|
||||
|
||||
override suspend fun revokeAllUserTokens(userId: String) {
|
||||
val tokens = refreshTokenRepository.findActive(ObjectId(userId))
|
||||
|
||||
tokens.forEach {
|
||||
it.isRevoked = true
|
||||
it.revokedAt = Instant.now()
|
||||
}
|
||||
|
||||
refreshTokenRepository.saveAll(tokens).collect()
|
||||
}
|
||||
|
||||
override suspend fun revokeTokenFamily(familyId: String) {
|
||||
val tokens = refreshTokenRepository.findByFamilyId(familyId)
|
||||
|
||||
tokens.forEach {
|
||||
it.isRevoked = true
|
||||
it.revokedAt = Instant.now()
|
||||
}
|
||||
|
||||
refreshTokenRepository.saveAll(tokens).collect()
|
||||
}
|
||||
|
||||
private fun hashToken(token: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = digest.digest(token.toByteArray(StandardCharsets.UTF_8))
|
||||
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
refreshTokenRepository.revokeByUserId(ObjectId(userId), Instant.now())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
|
||||
|
||||
interface UserService : ReactiveUserDetailsService
|
||||
interface UserService : ReactiveUserDetailsService {
|
||||
suspend fun findAll(): Flow<User>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.domain.model.User
|
||||
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
@@ -20,4 +22,6 @@ class UserServiceImpl(
|
||||
userRepository.findByEmail(username)
|
||||
?: throw UsernameNotFoundException("User not found with email: $username")
|
||||
}
|
||||
|
||||
override suspend fun findAll(): Flow<User> = userRepository.findAll()
|
||||
}
|
||||
|
||||
+9
-8
@@ -5,10 +5,9 @@ import org.bson.types.ObjectId
|
||||
import org.springframework.data.mongodb.repository.Query
|
||||
import org.springframework.data.mongodb.repository.Update
|
||||
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
||||
import java.time.Instant
|
||||
|
||||
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
|
||||
suspend fun deleteByUserId(userId: ObjectId): Long
|
||||
|
||||
@Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true)
|
||||
suspend fun countActive(userId: ObjectId): Long
|
||||
|
||||
@@ -19,11 +18,13 @@ interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String>
|
||||
@Query("{ 'userId': ?0, 'isRevoked': false }")
|
||||
suspend fun findActive(userId: ObjectId): List<RefreshToken>
|
||||
|
||||
@Query("{ 'familyId': ?0 }")
|
||||
@Update($$"{ '$set': { 'isRevoked': true } }")
|
||||
suspend fun revokeByFamilyId(familyId: String): Long
|
||||
@Query($$"{ 'userId': { '$in': ?0 } }")
|
||||
suspend fun findByUserIds(userIds: List<ObjectId>): List<RefreshToken>
|
||||
|
||||
@Query("{ 'tokenHash': ?0 }")
|
||||
@Update($$"{ '$set': { 'isRevoked': true } }")
|
||||
suspend fun revokeByTokenHash(tokenHash: String): Long
|
||||
@Query("{ 'userId': ?0 }")
|
||||
@Update($$"{ '$set': { 'isRevoked': true, 'revokedAt': ?1 } }")
|
||||
suspend fun revokeByUserId(
|
||||
userId: ObjectId,
|
||||
revokedAt: Instant,
|
||||
): Long
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user