1
0

feat(user): add users controller

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-24 07:21:51 -04:00
parent 8c101614d0
commit 272340ee6c
25 changed files with 366 additions and 72 deletions
@@ -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 io.swagger.v3.oas.annotations.media.Schema
@@ -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 import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "Health status of an individual component") @Schema(description = "Health status of an individual component")
data class ComponentHealthDto( data class ComponentHealthResponse(
@Schema(description = "Component status", example = "UP") @Schema(description = "Component status", example = "UP")
val status: String, val status: String,
@Schema(description = "Optional message with additional details", example = "MongoDB connection is active") @Schema(description = "Optional message with additional details", example = "MongoDB connection is active")
@@ -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 io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant import java.time.Instant
@@ -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 io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant import java.time.Instant
@@ -10,5 +10,5 @@ data class HealthResponse(
@Schema(description = "Timestamp of the health check", example = "2024-01-01T12:00:00Z") @Schema(description = "Timestamp of the health check", example = "2024-01-01T12:00:00Z")
val timestamp: Instant, val timestamp: Instant,
@Schema(description = "Status of individual components") @Schema(description = "Status of individual components")
val components: Map<String, ComponentHealthDto>? = null, val components: Map<String, ComponentHealthResponse>? = null,
) )
@@ -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 io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Email import jakarta.validation.constraints.Email
@@ -11,6 +11,6 @@ data class LoginRequest(
@Schema(description = "User email address", example = "user@example.com") @Schema(description = "User email address", example = "user@example.com")
val email: String, val email: String,
@field:NotBlank(message = "Password is required") @field:NotBlank(message = "Password is required")
@Schema(description = "User password", example = "P@ssw0rd!") @Schema(description = "User password", example = "user12345")
val password: String, val password: String,
) )
@@ -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 io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank 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,
)
@@ -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 import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "User information") @Schema(description = "Registered user information")
data class UserDto( data class RegisterResponse(
@Schema(description = "Unique identifier of the user", example = "507f1f77bcf86cd799439011") @Schema(description = "Unique identifier of the user", example = "507f1f77bcf86cd799439011")
val id: String, val id: String,
@Schema(description = "User email address", example = "user@example.com") @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 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 io.visus.demos.kotlinapi.domain.model.ComponentHealth
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthDto>() { object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthResponse>() {
override fun map(from: ComponentHealth): ComponentHealthDto = override fun map(from: ComponentHealth): ComponentHealthResponse =
mapping { 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 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 io.visus.demos.kotlinapi.domain.model.HealthStatus
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
@@ -29,7 +29,7 @@ class OpenApiConfig {
.bearerFormat("JWT") .bearerFormat("JWT")
.description("Enter JWT Bearer token"), .description("Enter JWT Bearer token"),
), ),
).addSecurityItem(SecurityRequirement().addList("bearerAuth")) )
@Bean @Bean
fun apiV1(): GroupedOpenApi = fun apiV1(): GroupedOpenApi =
@@ -61,15 +61,12 @@ class SecurityConfig(
.pathMatchers( .pathMatchers(
"/swagger-ui.html", "/swagger-ui.html",
"/swagger-ui/**", "/swagger-ui/**",
"/v3/api-docs/**",
"/api-docs/**", "/api-docs/**",
"/api/v1/health", "/api/v1/health",
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
"/api/v1/auth/register", "/api/v1/auth/register",
).permitAll() ).permitAll()
.pathMatchers("/api/v1/auth/logout")
.authenticated()
.anyExchange() .anyExchange()
.authenticated() .authenticated()
}.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION) }.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.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.dto.AuthResponse import io.visus.demos.kotlinapi.api.contracts.AuthResponse
import io.visus.demos.kotlinapi.api.dto.ErrorResponse import io.visus.demos.kotlinapi.api.contracts.ErrorResponse
import io.visus.demos.kotlinapi.api.dto.LoginRequest import io.visus.demos.kotlinapi.api.contracts.LoginRequest
import io.visus.demos.kotlinapi.api.dto.RefreshTokenRequest 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.model.User
import io.visus.demos.kotlinapi.domain.service.AuthService import io.visus.demos.kotlinapi.domain.service.AuthService
import jakarta.validation.Valid import jakarta.validation.Valid
@@ -117,4 +119,31 @@ class AuthController(
val response = authService.refreshToken(request.refreshToken) val response = authService.refreshToken(request.refreshToken)
return ResponseEntity.ok(response) 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.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.HttpStatusCodes import io.visus.demos.kotlinapi.api.HttpStatusCodes
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.api.mappers.HealthResponseMapper
import io.visus.demos.kotlinapi.domain.model.Status import io.visus.demos.kotlinapi.domain.model.Status
import io.visus.demos.kotlinapi.domain.service.HealthService 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 },
),
)
}
}
@@ -1,13 +1,16 @@
package io.visus.demos.kotlinapi.domain.exception 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.slf4j.LoggerFactory
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authorization.AuthorizationDeniedException
import org.springframework.security.core.userdetails.UsernameNotFoundException 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.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.bind.support.WebExchangeBindException
import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.ServerWebExchange
@RestControllerAdvice @RestControllerAdvice
@@ -47,4 +50,118 @@ class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error) 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, val tokenHash: String,
@Indexed @Indexed
val userId: ObjectId, val userId: ObjectId,
@Indexed(expireAfter = "30d") @Indexed(expireAfter = "0s")
val expiresAt: Date, val expiresAt: Date,
var isRevoked: Boolean = false, var isRevoked: Boolean = false,
@Indexed @Indexed
@@ -1,6 +1,7 @@
package io.visus.demos.kotlinapi.domain.service 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 { interface AuthService {
suspend fun login( suspend fun login(
@@ -15,5 +16,5 @@ interface AuthService {
suspend fun register( suspend fun register(
email: String, email: String,
password: String, password: String,
): AuthResponse ): RegisterResponse
} }
@@ -1,6 +1,7 @@
package io.visus.demos.kotlinapi.domain.service 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.config.JwtProperties
import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException
import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException
@@ -70,8 +71,26 @@ class AuthServiceImpl(
override suspend fun register( override suspend fun register(
email: String, email: String,
password: String, password: String,
): AuthResponse { ): RegisterResponse {
TODO("Not yet implemented") 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( private suspend fun generateAuthResponse(
@@ -8,12 +8,18 @@ interface RefreshTokenService {
familyId: String, familyId: String,
): String ): String
suspend fun validateAndRotate( suspend fun getActiveSessionCount(userId: String): Long
userId: String,
tokenString: String, suspend fun getActiveSessions(userId: String): List<RefreshToken>
): RefreshToken
suspend fun getActiveSessions(userIds: List<String>): List<RefreshToken>
suspend fun revokeAllUserTokens(userId: String) suspend fun revokeAllUserTokens(userId: String)
suspend fun revokeTokenFamily(familyId: String) suspend fun revokeTokenFamily(familyId: String)
suspend fun validateAndRotate(
userId: String,
tokenString: String,
): RefreshToken
} }
@@ -75,6 +75,37 @@ class RefreshTokenServiceImpl(
return jwtToken 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( override suspend fun validateAndRotate(
userId: String, userId: String,
tokenString: String, tokenString: String,
@@ -120,31 +151,6 @@ class RefreshTokenServiceImpl(
} }
override suspend fun revokeAllUserTokens(userId: String) { override suspend fun revokeAllUserTokens(userId: String) {
val tokens = refreshTokenRepository.findActive(ObjectId(userId)) refreshTokenRepository.revokeByUserId(ObjectId(userId), Instant.now())
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)
} }
} }
@@ -1,5 +1,9 @@
package io.visus.demos.kotlinapi.domain.service 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 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 package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.reactor.mono import kotlinx.coroutines.reactor.mono
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
@@ -20,4 +22,6 @@ class UserServiceImpl(
userRepository.findByEmail(username) userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found with email: $username") ?: throw UsernameNotFoundException("User not found with email: $username")
} }
override suspend fun findAll(): Flow<User> = userRepository.findAll()
} }
@@ -5,10 +5,9 @@ import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.Update import org.springframework.data.mongodb.repository.Update
import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import java.time.Instant
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> { interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
suspend fun deleteByUserId(userId: ObjectId): Long
@Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true) @Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true)
suspend fun countActive(userId: ObjectId): Long suspend fun countActive(userId: ObjectId): Long
@@ -19,11 +18,13 @@ interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String>
@Query("{ 'userId': ?0, 'isRevoked': false }") @Query("{ 'userId': ?0, 'isRevoked': false }")
suspend fun findActive(userId: ObjectId): List<RefreshToken> suspend fun findActive(userId: ObjectId): List<RefreshToken>
@Query("{ 'familyId': ?0 }") @Query($$"{ 'userId': { '$in': ?0 } }")
@Update($$"{ '$set': { 'isRevoked': true } }") suspend fun findByUserIds(userIds: List<ObjectId>): List<RefreshToken>
suspend fun revokeByFamilyId(familyId: String): Long
@Query("{ 'tokenHash': ?0 }") @Query("{ 'userId': ?0 }")
@Update($$"{ '$set': { 'isRevoked': true } }") @Update($$"{ '$set': { 'isRevoked': true, 'revokedAt': ?1 } }")
suspend fun revokeByTokenHash(tokenHash: String): Long suspend fun revokeByUserId(
userId: ObjectId,
revokedAt: Instant,
): Long
} }