diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/AuthResponse.kt similarity index 93% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/AuthResponse.kt index b56c11d..8cd07db 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/AuthResponse.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/AuthResponse.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ComponentHealthDto.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ComponentHealthResponse.kt similarity index 80% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ComponentHealthDto.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ComponentHealthResponse.kt index 0ef7636..722054b 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ComponentHealthDto.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ComponentHealthResponse.kt @@ -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") diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ErrorResponse.kt similarity index 92% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ErrorResponse.kt index fa88e9d..d744d94 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/ErrorResponse.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/ErrorResponse.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/HealthResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/HealthResponse.kt similarity index 79% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/HealthResponse.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/HealthResponse.kt index 656f3a3..a6e582c 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/HealthResponse.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/HealthResponse.kt @@ -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? = null, + val components: Map? = null, ) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/LoginRequest.kt similarity index 81% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/LoginRequest.kt index 939ffa8..9675b68 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/LoginRequest.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/LoginRequest.kt @@ -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, ) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RefreshTokenRequest.kt similarity index 88% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RefreshTokenRequest.kt index 4728bfc..0a1b5b4 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/RefreshTokenRequest.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RefreshTokenRequest.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterRequest.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterRequest.kt new file mode 100644 index 0000000..09a5a19 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterRequest.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterResponse.kt similarity index 73% rename from src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterResponse.kt index 780c828..a8b26e3 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/dto/UserDto.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/RegisterResponse.kt @@ -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") diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/UserResponse.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/UserResponse.kt new file mode 100644 index 0000000..4bf48d5 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/contracts/UserResponse.kt @@ -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, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt index 6c33679..c1a1c5f 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt @@ -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() { - override fun map(from: ComponentHealth): ComponentHealthDto = +object ComponentHealthDtoMapper : ObjectMappie() { + override fun map(from: ComponentHealth): ComponentHealthResponse = mapping { - ComponentHealthDto::status fromProperty ComponentHealth::status transform { it.name } + ComponentHealthResponse::status fromProperty ComponentHealth::status transform { it.name } } } 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 105b839..6795afc 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 @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt index c47d275..bd43117 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt @@ -29,7 +29,7 @@ class OpenApiConfig { .bearerFormat("JWT") .description("Enter JWT Bearer token"), ), - ).addSecurityItem(SecurityRequirement().addList("bearerAuth")) + ) @Bean fun apiV1(): GroupedOpenApi = 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 50c53ef..5fcf61c 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt index 5bd0009..406b5b6 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt @@ -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 { + val user = authService.register(request.email, request.password) + return ResponseEntity.status(201).body(user) + } } 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 5140585..5af3cd8 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt new file mode 100644 index 0000000..3b740ad --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/UsersController.kt @@ -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> { + 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 { + 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 }, + ), + ) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt index 12f4e44..7aba091 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/exception/GlobalExceptionHandler.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } } 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 index 50aa86a..077a67e 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt @@ -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 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 index c462e42..b3a0328 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt @@ -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 } 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 index b80ca5e..f2fa3c3 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt @@ -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( 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 index 5a8133c..2f768c1 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt @@ -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 + + suspend fun getActiveSessions(userIds: List): List suspend fun revokeAllUserTokens(userId: String) suspend fun revokeTokenFamily(familyId: String) + + suspend fun validateAndRotate( + userId: String, + tokenString: String, + ): RefreshToken } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt index 7b41c0f..d4c3678 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt @@ -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 = + refreshTokenRepository.findActive(ObjectId(userId)) + + override suspend fun getActiveSessions(userIds: List): List = + 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()) } } 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 361e2ef..8fb7485 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,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 +} 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 index a8397d8..bccba3f 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserServiceImpl.kt @@ -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 = userRepository.findAll() } 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 index a98dd8e..f1daf02 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt @@ -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 { - 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 @Query("{ 'userId': ?0, 'isRevoked': false }") suspend fun findActive(userId: ObjectId): List - @Query("{ 'familyId': ?0 }") - @Update($$"{ '$set': { 'isRevoked': true } }") - suspend fun revokeByFamilyId(familyId: String): Long + @Query($$"{ 'userId': { '$in': ?0 } }") + suspend fun findByUserIds(userIds: List): List - @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 }