How to set up Security in WebFlux
WebFlux 에서 Spring Security 를 구성하려면 WebMvc 에서의 Security Configuration 과 조금 다른부분이 있어 정리 해두려고 합니다.
WebFlux 란?
build.gradle.kts 구성
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.graphql-java:graphql-java-extended-scalars:18.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("io.netty:netty-all:4.1.80.Final")
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
WebFlux 에서 Secuirty 를 구성하려면 총 5가지가 필요합니다.
- SecurityWebFilterChain
- JwtSupporter
- ReactiveAuthenticationManger
- ServerAuthenticationConverter
- ReactiveUserDetailService
JwtSupport
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Date
class BearerToken(val value: String) : AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) {
override fun getCredentials() = value
override fun getPrincipal() = value
}
@Component
class JwtSupport(
@Value("\${jwt.key}")
private val key: ByteArray
) {
private val jwtKey = Keys.hmacShaKeyFor(key)
private val parser = Jwts.parserBuilder().setSigningKey(jwtKey).build()
fun generate(memberEmail: String): BearerToken {
val builder = Jwts.builder()
.setSubject(memberEmail)
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plus(15, ChronoUnit.MINUTES)))
.signWith(jwtKey)
return BearerToken(builder.compact())
}
fun getMemberEmail(token: BearerToken): String {
return parser.parseClaimsJws(token.value).body.subject
}
/**
* userDetails is nullable
*/
fun isValid(token: BearerToken, userDetails: UserDetails?): Boolean {
val claims = parser.parseClaimsJws(token.value).body
val unexpired = claims.expiration.after(Date.from(Instant.now()))
return unexpired && (claims.subject == userDetails?.username)
}
}
view raw
Jwt 와 관련된 util 기능들을 보아놓은 class 라고 보시면 될거 같습니다.
BearerToken 이라는 class 를 AbstractAuthenticationToken 를 상속 해서 구현 했고 AbstractAuthenticationToken 는 Authentication 를 구현한 abstract class 입니다.
ReactiveAuthenticationManager
import com.daily.view.api.exception.BusinessException
import com.daily.view.api.exception.ErrorCode
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.reactor.mono
import org.apache.http.auth.AuthenticationException
import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@Component
class JwtAuthenticationManager(
private val jwtSupport: JwtSupport,
private val users: ReactiveUserDetailsService
) : ReactiveAuthenticationManager {
override fun authenticate(authentication: Authentication?): Mono<Authentication> {
return Mono.justOrEmpty(authentication)
.filter { auth -> auth is BearerToken }
.cast(BearerToken::class.java)
.flatMap { jwt -> mono { validate(jwt) } }
.onErrorMap { error -> InvalidBearerToken(error.message) }
}
private suspend fun validate(token: BearerToken): Authentication {
val memberEmail = jwtSupport.getMemberEmail(token)
val user = users.findByUsername(memberEmail).awaitSingleOrNull()
if (jwtSupport.isValid(token, user)) {
return UsernamePasswordAuthenticationToken(user!!.username, user.password, user.authorities)
}
throw BusinessException(ErrorCode.INVALID_JWT_TOKEN, "유효하지 않은 jwt token 입니다.")
}
}
class InvalidBearerToken(message: String?): AuthenticationException(message)
view raw
JwtAuthenticationManager 는 AuthenticationFilter 의 재료로 사용 될 것입니다.
authenticate 를 override 하여 비동기적으로 Authentication 객체를 만들어 반환합니다.
ServerAuthenticationConverter
import org.springframework.http.HttpHeaders
import org.springframework.security.core.Authentication
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
@Component
class JwtAuthenticationConverter : ServerAuthenticationConverter {
override fun convert(exchange: ServerWebExchange): Mono<Authentication> {
return Mono.justOrEmpty(exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION))
.filter { it.startsWith("Bearer ") }
.map { it.substring(7) }
.map { jwt -> BearerToken(jwt) }
}
}
JwtAuthenticationConverter 는 JwtAuthenticationManager 로 만든 filter 의
Authentication 객체를 converting 하는 역활을 담당합니다.
WebMVC 를 예를 들자면 jwtFilter 와 같은 역활인데 request 요청이 오면 header 를 파싱해서 token 이 유효한지 검사한뒤 Authentication 객체를 반환합니다.
ReativeUserDetailService
import com.daily.view.api.entity.member.MemberRepository
import com.daily.view.api.service.auth.UserDetailsImpl
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
class UserDetailsService(
private val memberRepository: MemberRepository
) : ReactiveUserDetailsService {
override fun findByUsername(username: String?): Mono<UserDetails> {
if (username == null) return Mono.empty()
val member = memberRepository.findByEmail(username) ?: return Mono.empty()
return Mono.just(UserDetailsImpl(member))
}
}
UserDetailService 의 Reative version 이라고 보시면 될것 같습니다.
private suspend fun validate(token: BearerToken): Authentication {
val memberEmail = jwtSupport.getMemberEmail(token)
val user = users.findByUsername(memberEmail).awaitSingleOrNull()
if (jwtSupport.isValid(token, user)) {
return UsernamePasswordAuthenticationToken(user!!.username, user.password, user.authorities)
}
throw BusinessException(ErrorCode.INVALID_JWT_TOKEN, "유효하지 않은 jwt token 입니다.")
}
validate 에서 users.findByUsername method 를 호출하면 override 한 findByUsername 이 호출됩니다.
저는 Mysql 을 베이스로 하여 코드를 작성하였습니다. 그래서 findByEmail 은 동기적으로 작동하기 때문에
Possibly blocking call in non-blocking context could lead to thread starvation 이슈가 발생합니다.
비동기적으로 호출하도록 수정하여 사용하면 될것 같습니다.
마지막으로 위에 적힌 코드들을 조합하여 SecurityConfig 를 작성하겠습니다.
SecurityConfig
import com.daily.view.api.configuration.jwt.JwtAuthenticationConverter
import com.daily.view.api.configuration.jwt.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
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService
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
import org.springframework.security.core.userdetails.User
@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
authManger: JwtAuthenticationManager,
converter: JwtAuthenticationConverter
): SecurityWebFilterChain {
val filter = AuthenticationWebFilter(authManger)
filter.setServerAuthenticationConverter(converter)
return http
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.csrf().disable()
.logout().disable()
.authorizeExchange()
.pathMatchers("/**").permitAll()
.and()
.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange().anyExchange().authenticated().and()
.build()
}
}
AuthenticationWebFilter 를 만들때 JwtAuthenticationManger 를 파라미터로 사용하여 생성했고 converter 로는 JwtAuthenticationConverter 를 사용했습니다.
addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION)
필터로써 동작하려면 이부분이 필요합니다.
WebMVC 에서의 작업이 익숙하여 WebFlux 에서 Security 를 설정하는데 애를 먹었습니다.
그래도 대규모 트래픽을 처리하는데 Reactive 만한게 없어 좀더 공부해볼 생각입니다😆
'Kotlin' 카테고리의 다른 글
How to use Redisson with kotlin (0) | 2022.10.02 |
---|---|
spring 에서 비동기 처리를 위한 TreadPoolTaskExecutor 알아보기 (0) | 2022.08.24 |
kotlin coroutine - Mutex 와 Actor (공유자원) (0) | 2022.08.05 |
Kotlin - with 과 apply (0) | 2022.07.24 |
댓글
이 글 공유하기
다른 글
-
How to use Redisson with kotlin
How to use Redisson with kotlin
2022.10.02 -
spring 에서 비동기 처리를 위한 TreadPoolTaskExecutor 알아보기
spring 에서 비동기 처리를 위한 TreadPoolTaskExecutor 알아보기
2022.08.24 -
kotlin coroutine - Mutex 와 Actor (공유자원)
kotlin coroutine - Mutex 와 Actor (공유자원)
2022.08.05 -
Kotlin - with 과 apply
Kotlin - with 과 apply
2022.07.24