[Spring Security] MVC(Servlet) 와 리액티브(Webflux) 환경에서의 인증/인가 구현방법

Sihwan Kim·2024년 4월 25일

KoPring

목록 보기
8/10
post-thumbnail

시작하기에 앞서 이 내용들은 제가 이해한 내용들을 정리한 것입니다.
틀릴 수있으며, 틀린 내용은 댓글 부탁드립니다.

MVC환경의 Security

필터 동작은 다음과 같이 진행되면 아래 구현 객체들을 구현해주어야한다.

filterChain

@EnableWebSecurity
class SecurityConfig{
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http { // kotlin DSL
            httpBasic { disable() }
            csrf { disable() }
            cors { }
            authorizeRequests {
                authorize("검증할 url",authenticated)
                authorize("/**",permitAll)
            }
            oauth2Login {
                loginPage = "/loginPage"
                defaultSuccessUrl("/",true)
                userInfoEndpoint {
                    userService = principalOauthUserService
                }
                authenticationSuccessHandler = oAuthSuccessHandler //OAuth 로그인 성공시 동작
            }
            addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthenticationFilter)
        }
        return http.build()
    }

DefaultOAuth2UserService 인터페이스 구현객체

OAuth로 로그인한 경우 처리됨.

@Service
class PrincipalOauthUserService(
    private val userService: UserService,
) : DefaultOAuth2UserService() {

    @Throws(OAuth2AuthenticationException::class)
    @Transactional(value = "transactionManager")
    override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User? {

        val oAuth2User = super.loadUser(userRequest)
        var oAuth2UserInfo: OAuth2UserInfo? = null

        when (userRequest.clientRegistration.registrationId) {
			// 인증 기관에 따른 처리 google, naver , kakao 등등
        }
        
        // oAuth2User.attribute에서 oAuth에서 받은 정보들을 확인할 수 있음
        // 이 정보로 DB에 저장하거나 등등 마음대로 구현

        return PrincipalDetails(userEntity, oAuth2User.attributes) // UserDetails와 OAuth2User 인터페이스를 상속받은 객체.
    }
}

UserDetailsService 인터페이스 구현 객체

Form로그인(아이디 비밀번호)시 적용됨.


@Service
@RequiredArgsConstructor
class PrincipalService(
    private val userService: UserService
) : UserDetailsService {
    
    @Throws(UsernameNotFoundException::class)
    override fun loadUserByUsername(username: String): UserDetails? {
        val findUser: User = userService.findUserByName(username)
        if (findUser != null) {
            return PrincipalDetails(findUser) // UserDetails와 OAuth2User 인터페이스를 상속받은 객체.
        }
        return null
    }
}

AuthenticationSuccessHandler 구현객체

로그인 성공시 동작시킬 함수 구현

@Component
class OAuthSuccessHandler(
) : AuthenticationSuccessHandler {
    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        val principal = authentication.principal as PrincipalDetails // 이 객체를 통해 oAuth나 form 둘다 가능
        val email = principal.getEmail() // 이메일로 검증하는 예시
        if(email == null){
            response.sendRedirect("에러")
        }
        else{
            redisService.saveJwt(jwt.token,user)
            response.status = HttpServletResponse.SC_OK
            response.contentType = "application/json;charset=UTF-8"
            response.sendRedirect("로그인 성공했으니 여기로 리다이렉트")
        }

    }
}

UserDetails, OAuth2User 구현객체

class PrincipalDetails : UserDetails, OAuth2User{
    private var user  : User
    private lateinit var attributes :MutableMap<String,Any>
    public constructor(user: User) {
        this.user = user
    }

    constructor(user: User, attributes: Map<String, Any>){
        this.user = user
        this.attributes = attributes as MutableMap<String, Any>
    }

    // 두 인터페이스의 모든 함수를 오버라이드 해야함.
}

😁 추가 필터 구현방법

GenericFilterBean 상속받도록 구현

아래는 jwt 검증하려고 만든 필터 예시

class JwtAuthenticationFilter(
) : GenericFilterBean() {
    override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
        val token = resolveToken(request as HttpServletRequest)// 토큰 유효성 검사후 통과 => token반환, 불통과 => null 반환

        if (token != null && jwtTokenProvider.validateToken(token)) {
            val authentication = jwtTokenProvider.getAuthentication(token)
            SecurityContextHolder.getContext().authentication = authentication //세션에 Authentication을 저장하므로써 인증된 유저임을 알릴 수있음.
            println("doFilterChain:$authentication")
        }
        chain?.doFilter(request, response)
    }

    private fun resolveToken(request : HttpServletRequest) : String? {
        val bearerToken = request.getHeader("Authorization")
        // 토큰 유효성 검사후 통과 => token반환, 불통과 => null 반환
    }

}

리액티브환경의 Security

Webflux환경에서의 Security는 filterChain부터 모든게 다른 인터페이스로 작성되어있지만 동작은 비슷하다. 다만 어노테이션을 MVC환경에서 Webflux로 모두 바꿔주어야만 정상적으로 동작하니
이 점을 꼭 확인해야한다.

FilterChain

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
위 두 어노테이션을 통해서 Webflux환경 Security관련 bean들이 자동 주입되도록 하여야한다.
특히, filterChain에서 쓰이는 ServerHttpSecurity를 주입해준다.


또한, MVC환경과 다르게 filterChainSecurityWebFilterChain를 반환해야한다.


😅 그리고 가장중요하게 ServerOAuth2AuthorizationRequestResolver 를 구현해주어서 넣어주지 않으면 clientRegistrationRepository 가 없다는 오류가 발생한다..
(MVC에서는 자동으로 해줬는데... 아직 안되는 것같다는 내생각..)

@Configuration
@EnableReactiveMethodSecurity
@EnableWebFluxSecurity
class SecurityConfig(
    val principalOauthUserService: PrincipalOauthUserServiceReactive,
    val oAuthSuccessHandler: OAuthSuccessHandlerReactive,
    val jwtAuthenticationFilter: JwtAuthenticationFilterReactive,
    val clientRegistrationRepository: ReactiveClientRegistrationRepository
) {
    @Bean
    fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http.httpBasic {
            it.disable()
            }
            .csrf {
                it.disable()
            }
            .authorizeExchange{
                it.pathMatchers("검증할 url").authenticated()
                it.pathMatchers("/**").permitAll()
            }
            .exceptionHandling{
             it.authenticationEntryPoint(RedirectServerAuthenticationEntryPoint("/loginPage")) // 로그인 안되어 있을 때 보낼 곳 설정
            }
            .oauth2Login {
                it.authenticationSuccessHandler(oAuthSuccessHandler)
                it.authorizationRequestResolver(authorizationRequestResolver())
            }
            .addFilterBefore(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
        return http.build()
    }

    private fun authorizationRequestResolver(): ServerOAuth2AuthorizationRequestResolver {
        val authorizationRequestMatcher: ServerWebExchangeMatcher = PathPatternParserServerWebExchangeMatcher(
            "/oauth2/authorization/{registrationId}"
        )

        return DefaultServerOAuth2AuthorizationRequestResolver(
            clientRegistrationRepository, authorizationRequestMatcher
        )
    }
}

ReactiveOAuth2UserService 구현 객체

@Service
class PrincipalOauthUserServiceReactive(
    private val userService: UserR2DBCService,
) : ReactiveOAuth2UserService<OAuth2UserRequest,OAuth2User> {

    @Throws(OAuth2AuthenticationException::class)
    @Transactional(value = "transactionManager")
    override fun loadUser(userRequest: OAuth2UserRequest): Mono<OAuth2User> {
        val delegate = DefaultReactiveOAuth2UserService()
        //userRequest.clientRegistration.registrationId로 google, naver,kakao 등 정보
        return delegate.loadUser(userRequest)
        		.map{OAuthUser->
                	//기타 사용자 저장같은 동작 구현
                    oAuth2UserInfo => USER로 매핑시켜야됨
                    PrincipalDetailse(user,attribute)
       }
}

ReactiveUserDetailService 구현객체

@Service
@RequiredArgsConstructor
class PrincipalServiceReactive: ReactiveUserDetailsService {

    @Throws(UsernameNotFoundException::class)
    override fun findByUsername(username: String): Mono<UserDetails> {
        return userService.findByUserId(username)
            .map{
                return@map PrincipalDetailsReactive(it)
            }
    }
}

ServerAuthenticationSuccessHandler 구현객체

@Component(value = "authenticationSuccessHandler")
class OAuthSuccessHandlerReactive: ServerAuthenticationSuccessHandler {

    override fun onAuthenticationSuccess(
        webFilterExchange: WebFilterExchange,
        authentication: Authentication
    ): Mono<Void> {
        val principal = authentication.principal as PrincipalDetailsReactive
        val userName = principal.name
        val jwt = jwtTokenProvider.createToken(userName)
       return  userService.findByUserId(userName)
            .doOnNext {user ->
                //로그인 성공했을 때 할 동작 구현
            }
            .flatMap {
            //토큰과 함께 리다이렉션
                val uri = UriComponentsBuilder.newInstance().path("/").queryParam("token",jwt.token).build().toUri()
                val exchange = webFilterExchange.exchange
                DefaultServerRedirectStrategy().sendRedirect(exchange,uri)
            }


    }
}

😁 추가 필터 구현방법

WebFilter 상속받도록 구현

아래는 jwt 검증하려고 만든 필터 예시

@Component
class JwtAuthenticationFilterReactive(
    private val jwtTokenProvider: JwtTokenProvider
) : WebFilter {

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val token = resolveToken(exchange.request)// 토큰 유효성 검사후 통과 => token반환, 불통과 => null 반환
        if (token != null && jwtTokenProvider.validateToken(token)) {
            val authentication = jwtTokenProvider.getAuthentication(token)
            SecurityContextHolder.getContext().authentication = authentication
        }
        return chain.filter(exchange)
    }
    
	 private fun resolveToken(request : ServerHttpRequest) : String? {
        val bearerToken = request.headers["Authorization"]?.get(0)
        // 토큰 유효성 검사후 통과 => token반환, 불통과 => null 반환
    }

}

참고

https://docs.spring.io/spring-security/reference/reactive/configuration/webflux.html
https://velog.io/@dlawnsdh/Webflux%EC%97%90-OAuth2-JWT-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0
https://velog.io/@rnqhstlr2297/Spring-Security-OAuth2-%EC%86%8C%EC%85%9C%EB%A1%9C%EA%B7%B8%EC%9D%B8

0개의 댓글