728x90

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가지가 필요합니다.

  1. SecurityWebFilterChain
  2. JwtSupporter
  3. ReactiveAuthenticationManger
  4. ServerAuthenticationConverter
  5. 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 만한게 없어 좀더 공부해볼 생각입니다😆

728x90