chore(auth): clean up authentication, jwt, add some exception handling
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+79
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+4
-3
@@ -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 } }")
|
||||||
|
|||||||
Reference in New Issue
Block a user