1
0

chore(auth): clean up authentication, jwt, add some exception handling

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-22 08:13:17 -04:00
parent 435298c013
commit 8c101614d0
8 changed files with 167 additions and 17 deletions
@@ -1,13 +1,19 @@
package io.visus.demos.kotlinapi.config package io.visus.demos.kotlinapi.config
import io.visus.demos.kotlinapi.domain.service.UserService
import io.visus.demos.kotlinapi.infrastructure.security.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository
import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsConfigurationSource import org.springframework.web.cors.reactive.CorsConfigurationSource
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@@ -15,7 +21,10 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@Configuration @Configuration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@EnableReactiveMethodSecurity @EnableReactiveMethodSecurity
class SecurityConfig { class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val userDetailsService: UserService,
) {
@Bean @Bean
fun corsConfigurationSource(): CorsConfigurationSource { fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = val configuration =
@@ -34,11 +43,19 @@ class SecurityConfig {
@Bean @Bean
fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder() fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder()
@Bean
fun reactiveAuthenticationManager(passwordEncoder: PasswordEncoder): ReactiveAuthenticationManager {
val authManager = UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService)
authManager.setPasswordEncoder(passwordEncoder)
return authManager
}
@Bean @Bean
fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
http http
.csrf { it.disable() } .csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) } .cors { it.configurationSource(corsConfigurationSource()) }
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.authorizeExchange { authorize -> .authorizeExchange { authorize ->
authorize authorize
.pathMatchers( .pathMatchers(
@@ -51,10 +68,11 @@ class SecurityConfig {
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
"/api/v1/auth/register", "/api/v1/auth/register",
).permitAll() ).permitAll()
.pathMatchers("/api/v1/auth/logout").authenticated() .pathMatchers("/api/v1/auth/logout")
.authenticated()
.anyExchange() .anyExchange()
.authenticated() .authenticated()
} }.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
return http.build() return http.build()
} }
@@ -0,0 +1,50 @@
package io.visus.demos.kotlinapi.domain.exception
import io.visus.demos.kotlinapi.api.dto.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.core.userdetails.UsernameNotFoundException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ServerWebExchange
@RestControllerAdvice
class GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(this::class.java)
@ExceptionHandler(BadCredentialsException::class)
fun handleBadCredentialsException(
ex: BadCredentialsException,
exchange: ServerWebExchange,
): ResponseEntity<ErrorResponse> {
logger.warn("Bad credentials attempt from: {}", exchange.request.remoteAddress)
val error =
ErrorResponse(
status = HttpStatus.UNAUTHORIZED.value(),
message = ex.message ?: "Invalid credentials",
path = exchange.request.path.toString(),
)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
}
@ExceptionHandler(UsernameNotFoundException::class)
fun handleUsernameNotFoundException(
ex: UsernameNotFoundException,
exchange: ServerWebExchange,
): ResponseEntity<ErrorResponse> {
logger.warn("User not found: {}", ex.message)
val error =
ErrorResponse(
status = HttpStatus.UNAUTHORIZED.value(),
message = "Invalid credentials",
path = exchange.request.path.toString(),
)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error)
}
}
@@ -33,12 +33,12 @@ class RefreshTokenServiceImpl(
userId: String, userId: String,
familyId: String, familyId: String,
): String { ): String {
val activeCount = refreshTokenRepository.countActive(userId) val activeCount = refreshTokenRepository.countActive(ObjectId(userId))
if (activeCount >= MAX_SESSIONS_PER_USER) { if (activeCount >= MAX_SESSIONS_PER_USER) {
val sessions = val sessions =
refreshTokenRepository refreshTokenRepository
.findActive(userId) .findActive(ObjectId(userId))
.sortedBy { it.createdAt } .sortedBy { it.createdAt }
val oldest = sessions.first() val oldest = sessions.first()
@@ -120,7 +120,7 @@ class RefreshTokenServiceImpl(
} }
override suspend fun revokeAllUserTokens(userId: String) { override suspend fun revokeAllUserTokens(userId: String) {
val tokens = refreshTokenRepository.findActive(userId) val tokens = refreshTokenRepository.findActive(ObjectId(userId))
tokens.forEach { tokens.forEach {
it.isRevoked = true it.isRevoked = true
@@ -1,5 +1,5 @@
package io.visus.demos.kotlinapi.domain.service package io.visus.demos.kotlinapi.domain.service
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.ReactiveUserDetailsService
interface UserService : UserDetailsService interface UserService : ReactiveUserDetailsService
@@ -1,21 +1,23 @@
package io.visus.demos.kotlinapi.domain.service package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import kotlinx.coroutines.runBlocking 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
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service @Service
class UserServiceImpl( class UserServiceImpl(
private val userRepository: UserRepository, private val userRepository: UserRepository,
) : UserService { ) : UserService {
override fun loadUserByUsername(username: String): UserDetails { override fun findByUsername(username: String): Mono<UserDetails> =
if (username.isBlank()) throw UsernameNotFoundException("Username must not be null or empty") mono {
if (username.isBlank()) {
throw UsernameNotFoundException("Username must not be null or empty")
}
return runBlocking {
userRepository.findByEmail(username) userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found with email: $username") ?: throw UsernameNotFoundException("User not found with email: $username")
} }
} }
}
@@ -0,0 +1,79 @@
package io.visus.demos.kotlinapi.infrastructure.security
import io.visus.demos.kotlinapi.domain.model.JwtTokenType
import io.visus.demos.kotlinapi.domain.service.UserService
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
@Component
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val userService: UserService,
) : WebFilter {
private val logger = LoggerFactory.getLogger(JwtAuthenticationFilter::class.java)
companion object {
private const val BEARER_PREFIX = "Bearer "
}
override fun filter(
exchange: ServerWebExchange,
chain: WebFilterChain,
): Mono<Void> {
val jwt = extractJwtFromRequest(exchange)
return if (jwt != null && isValidAccessToken(jwt)) {
val username = jwtTokenProvider.extractUsername(jwt)
userService
.findByUsername(username)
.flatMap { userDetails ->
val authentication =
UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities,
)
logger.debug("Set authentication for user: {}", username)
// Set authentication in reactive security context
chain
.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))
}.onErrorResume { ex ->
logger.error("Could not set user authentication in security context", ex)
chain.filter(exchange)
}
} else {
// No JWT or invalid JWT, continue without authentication
chain.filter(exchange)
}
}
private fun isValidAccessToken(token: String): Boolean =
try {
jwtTokenProvider.validateToken(token) &&
jwtTokenProvider.extractTokenType(token) == JwtTokenType.ACCESS
} catch (ex: Exception) {
logger.debug("Token validation failed: {}", ex.message)
false
}
private fun extractJwtFromRequest(exchange: ServerWebExchange): String? {
val bearerToken = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
return if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
bearerToken.substring(BEARER_PREFIX.length)
} else {
null
}
}
}
@@ -105,7 +105,7 @@ class JwtTokenProvider(
return expiration.before(Date()) return expiration.before(Date())
} }
private fun validateToken(token: String): Boolean { fun validateToken(token: String): Boolean {
try { try {
Jwts Jwts
.parser() .parser()
@@ -1,22 +1,23 @@
package io.visus.demos.kotlinapi.infrastructure.user package io.visus.demos.kotlinapi.infrastructure.user
import io.visus.demos.kotlinapi.domain.model.RefreshToken import io.visus.demos.kotlinapi.domain.model.RefreshToken
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
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> { interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
suspend fun deleteByUserId(userId: String): Long 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: String): Long suspend fun countActive(userId: ObjectId): Long
suspend fun findByFamilyId(familyId: String): List<RefreshToken> suspend fun findByFamilyId(familyId: String): List<RefreshToken>
suspend fun findByTokenHash(tokenHash: String): RefreshToken? suspend fun findByTokenHash(tokenHash: String): RefreshToken?
@Query("{ 'userId': ?0, 'isRevoked': false }") @Query("{ 'userId': ?0, 'isRevoked': false }")
suspend fun findActive(userId: String): List<RefreshToken> suspend fun findActive(userId: ObjectId): List<RefreshToken>
@Query("{ 'familyId': ?0 }") @Query("{ 'familyId': ?0 }")
@Update($$"{ '$set': { 'isRevoked': true } }") @Update($$"{ '$set': { 'isRevoked': true } }")