UsersApiController.kt
package org.openapitools.api
import jakarta.validation.Valid
import org.openapitools.entity.TokenBlocklistEntry
import org.openapitools.entity.UserEntity
import org.openapitools.model.AuthRequest
import org.openapitools.model.AuthResponse
import org.openapitools.model.UserPreferences
import org.openapitools.model.UserProfile
import org.openapitools.model.UserProfileUpdate
import org.openapitools.repository.TokenBlocklistRepository
import org.openapitools.repository.UserRepository
import org.openapitools.security.JwtUtils
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.node.ObjectNode
@RestController
@Validated
@RequestMapping("\${api.base-path:/api/v1}")
class UsersApiController(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val authManager: AuthenticationManager,
private val jwtUtils: JwtUtils,
private val objectMapper: ObjectMapper,
private val tokenBlocklist: TokenBlocklistRepository,
) : UsersApi {
private val log = LoggerFactory.getLogger(javaClass)
override fun usersRegisterPost(
@Valid authRequest: AuthRequest,
): ResponseEntity<Unit> {
if (userRepository.existsByUsername(authRequest.username)) {
throw ConflictException("Username already taken")
}
userRepository.save(
UserEntity(
username = authRequest.username,
password = passwordEncoder.encode(authRequest.password)!!,
preferences = objectMapper.writeValueAsString(UserPreferences()),
),
)
log.info("User registered [user={}]", authRequest.username)
return ResponseEntity(HttpStatus.CREATED)
}
override fun usersLoginPost(
@Valid authRequest: AuthRequest,
): ResponseEntity<AuthResponse> {
try {
authManager.authenticate(
UsernamePasswordAuthenticationToken(authRequest.username, authRequest.password),
)
} catch (e: BadCredentialsException) {
throw UnauthorizedException("Invalid username or password")
}
val user = userRepository.findByUsername(authRequest.username).orElseThrow()
log.info("User logged in [user={}]", authRequest.username)
return ResponseEntity.ok(AuthResponse(token = jwtUtils.generateToken(user.id)))
}
override fun usersLogoutPost(): ResponseEntity<Unit> {
val username = currentUsername()
val token = SecurityContextHolder.getContext().authentication?.credentials as? String
if (token != null) {
tokenBlocklist.save(
TokenBlocklistEntry(
tokenHash = jwtUtils.tokenHash(token),
expiresAt = jwtUtils.getExpirationFromToken(token),
),
)
}
log.info("User logged out [user={}]", username)
return ResponseEntity(HttpStatus.OK)
}
override fun usersProfileGet(): ResponseEntity<UserProfile> {
val user = userRepository.findByUsername(currentUsername()).orElseThrow()
val prefs =
user.preferences?.let {
objectMapper.readValue(it, UserPreferences::class.java)
} ?: UserPreferences()
return ResponseEntity.ok(UserProfile(username = user.username, preferences = prefs))
}
override fun usersProfilePut(
@Valid userProfileUpdate: UserProfileUpdate,
): ResponseEntity<Unit> {
val user = userRepository.findByUsername(currentUsername()).orElseThrow()
userProfileUpdate.preferences?.let { incoming ->
// merge so a partial update keeps other preferences
val merged = (user.preferences?.let { objectMapper.readTree(it) } as? ObjectNode) ?: objectMapper.createObjectNode()
merged.setAll(objectMapper.valueToTree<ObjectNode>(incoming))
user.preferences = objectMapper.writeValueAsString(merged)
}
userProfileUpdate.username?.let { newUsername ->
if (newUsername != user.username && userRepository.existsByUsername(newUsername)) {
throw ConflictException("Username already taken")
}
user.username = newUsername
}
userProfileUpdate.password?.let { user.password = passwordEncoder.encode(it)!! }
userRepository.save(user)
return ResponseEntity(HttpStatus.OK)
}
override fun usersProfileDelete(): ResponseEntity<Unit> {
val user = userRepository.findByUsername(currentUsername()).orElseThrow()
userRepository.delete(user)
return ResponseEntity(HttpStatus.NO_CONTENT)
}
private fun currentUsername(): String = SecurityContextHolder.getContext().authentication!!.name
}