diff --git a/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt b/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt new file mode 100644 index 0000000..ad21d50 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/configuration/SecurityConfiguration.kt @@ -0,0 +1,60 @@ +package ltd.hlaeja.configuration + +import ltd.hlaeja.security.JwtAuthenticationConverter +import ltd.hlaeja.security.JwtAuthenticationManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder.AUTHENTICATION +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.CsrfSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.FormLoginSpec +import org.springframework.security.config.web.server.ServerHttpSecurity.HttpBasicSpec +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.AuthenticationWebFilter + +@Configuration +@EnableWebFluxSecurity +class SecurityConfiguration { + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun securityWebFilterChain( + serverHttpSecurity: ServerHttpSecurity, + jwtAuthenticationManager: JwtAuthenticationManager, + jwtAuthenticationConverter: JwtAuthenticationConverter, + ): SecurityWebFilterChain = serverHttpSecurity + .authorizeExchange(::authorizeExchange) + .httpBasic(::httpBasic) + .formLogin(::formLogin) + .csrf(::csrf) + .addFilterAt( + AuthenticationWebFilter(jwtAuthenticationManager).apply { + setServerAuthenticationConverter(jwtAuthenticationConverter) + }, + AUTHENTICATION, + ) + .build() + + private fun csrf( + csrf: CsrfSpec, + ) = csrf.disable() + + private fun formLogin( + formLogin: FormLoginSpec, + ) = formLogin.disable() + + private fun httpBasic( + httpBasic: HttpBasicSpec, + ) = httpBasic.disable() + + private fun authorizeExchange( + authorizeExchange: AuthorizeExchangeSpec, + ) = authorizeExchange + .pathMatchers("/login").permitAll() + .anyExchange().hasRole("REGISTRY") +} diff --git a/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationConverter.kt b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationConverter.kt new file mode 100644 index 0000000..8429a66 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationConverter.kt @@ -0,0 +1,55 @@ +package ltd.hlaeja.security + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.JwtException +import java.util.UUID +import ltd.hlaeja.jwt.service.PublicJwtService +import org.springframework.http.HttpStatus.UNAUTHORIZED +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +private val log = KotlinLogging.logger {} + +@Component +class JwtAuthenticationConverter( + private val publicJwtService: PublicJwtService, +) : ServerAuthenticationConverter { + + companion object { + private const val BEARER = "Bearer " + private const val AUTHORIZATION = "Authorization" + } + + override fun convert( + exchange: ServerWebExchange, + ): Mono = Mono.justOrEmpty(exchange.request.headers.getFirst(AUTHORIZATION)) + .filter { it.startsWith(BEARER) } + .map { it.removePrefix(BEARER) } + .flatMap { token -> + try { + Mono.just(jwtAuthenticationToken(token)) + } catch (e: JwtException) { + log.error(e) { "${e.message}" } + Mono.error(ResponseStatusException(UNAUTHORIZED)) + } + } + + private fun jwtAuthenticationToken(token: String) = publicJwtService.verify(token) { claims -> + JwtAuthenticationToken( + JwtUserDetails( + UUID.fromString(claims.payload["id"] as String), + claims.payload["username"] as String, + ), + token, + (claims.payload["role"] as String).split(",") + .map { SimpleGrantedAuthority(it) } + .toMutableList(), + true, + ) + } +} diff --git a/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationManager.kt b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationManager.kt new file mode 100644 index 0000000..0a03d4a --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationManager.kt @@ -0,0 +1,27 @@ +package ltd.hlaeja.security + +import org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +@Component +class JwtAuthenticationManager : ReactiveAuthenticationManager { + + override fun authenticate( + authentication: Authentication, + ): Mono = if (authentication is JwtAuthenticationToken) { + handleJwtToken(authentication) + } else { + Mono.error(object : AuthenticationException("Unsupported authentication type") {}) + } + + private fun handleJwtToken( + token: JwtAuthenticationToken, + ): Mono = if (token.isAuthenticated) { + Mono.just(token) + } else { + Mono.error(object : AuthenticationException("Invalid or expired JWT token") {}) + } +} diff --git a/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationToken.kt b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationToken.kt new file mode 100644 index 0000000..e733e64 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/security/JwtAuthenticationToken.kt @@ -0,0 +1,28 @@ +package ltd.hlaeja.security + +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority + +data class JwtAuthenticationToken( + private val jwtUserDetails: JwtUserDetails, + private val token: String, + private var authorities: MutableCollection, + private var authenticated: Boolean = false, +) : Authentication { + + override fun getName(): String = "Bearer Token" + + override fun getAuthorities(): MutableCollection = authorities + + override fun getCredentials(): Any = token + + override fun getDetails(): Any? = null + + override fun getPrincipal(): Any = jwtUserDetails + + override fun isAuthenticated(): Boolean = authenticated + + override fun setAuthenticated(isAuthenticated: Boolean) { + authenticated = isAuthenticated + } +} diff --git a/src/main/kotlin/ltd/hlaeja/security/JwtUserDetails.kt b/src/main/kotlin/ltd/hlaeja/security/JwtUserDetails.kt new file mode 100644 index 0000000..c9cb31f --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/security/JwtUserDetails.kt @@ -0,0 +1,8 @@ +package ltd.hlaeja.security + +import java.util.UUID + +data class JwtUserDetails( + val id: UUID, + val username: String, +)