스프링 시큐리티 AbstractAuthenticationProcessingFilter 넌 누구냐? -1

Gi Lick·2024년 12월 11일
0

회사에서 프로젝트를 아예 싹 옮기는 마이그레이션 작업이 진행되었습니다.
새로운 프로젝트에는 스프링 시큐리티가 붙었고,
AbstractAuthenticationProcessingFilter 를 통해 로그인이 구현되어 있었습니다.

오픈 후 얼마 후...

일부 계정 (API 스캔 테스트 용 계정) 은 OTP를 스킵하고 동작해야 한다는 요구사항이 들어왔습니다.
그리고 이것이 1년차인 내가 해야 할 일입니다.

이 이야기에 들어가기 앞서서 최대한 비슷하게 회사의 시큐리티 코드 구성을 옮겨봤습니다.

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    }

    @Bean
    fun userDetailsService(): UserDetailsService {
        return InMemoryUserDetailsManager(
            User.withUsername("123")
                .password("123")
                .roles("USER")
                .build()
        )
    }

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        val securityContextRepository = getSecurityContextRepository(http)
                val securityContextRepository = getSecurityContextRepository()
        val providers = listOf(FirstAuthenticationProvider(userDetailsService()), SecondAuthenticationProvider())
        val providerManager = ProviderManager(providers)


        http
            .csrf {
                it.disable()
            }
            .authorizeHttpRequests {
                it.requestMatchers("/main")
                    .hasAuthority("ROLE_2FA_COMPLETE")
                    .anyRequest()
                    .permitAll()
            }
            .formLogin {
                it.defaultSuccessUrl("/otp", true)
                    .permitAll()
            }
            .addFilterBefore(
                firstFactorAuthenticationFilter(authenticationManager(http), securityContextRepository),
                UsernamePasswordAuthenticationFilter::class.java
            )
            .addFilterBefore(
                twoFactorAuthenticationFilter(authenticationManager(http), securityContextRepository),
                UsernamePasswordAuthenticationFilter::class.java
            )

        return http.build()
    }


    private fun getSecurityContextRepository(): SecurityContextRepository {
        return CustomSecurityContextRepository()
    }

    private fun firstFactorAuthenticationFilter(
        manager: AuthenticationManager,
        repository: SecurityContextRepository
    ): FirstFactorAuthenticationFilter {
        return FirstFactorAuthenticationFilter().apply {
            this.setAuthenticationManager(manager)
            this.setSecurityContextRepository(repository)
            this.setAuthenticationSuccessHandler(FirstFactorSuccessHandler())
            this.setAuthenticationFailureHandler(FirstFactorFailureHandler())
        }
    }

    private fun twoFactorAuthenticationFilter(
        manager: AuthenticationManager,
        repository: SecurityContextRepository
    ): TwoFactorAuthenticationFilter {
        return TwoFactorAuthenticationFilter().apply {
            this.setAuthenticationManager(manager)
            this.setSecurityContextRepository(repository)
            this.setAuthenticationSuccessHandler(TwoFactorSuccessHandler())
            this.setAuthenticationFailureHandler(TwoFactorFailureHandler())
        }
    }
}

쉽게 표시하기 위해 하나의 Config에 모두 몰아 넣었습니다.
그리고 간단하게 구현했습니다.

하나의 AbstractAuthenticationProcessingFilter 당 Provider, SuccesHandler, FailureHandler, SecurityContextRepository 가 1개씩 붙어있었습니다.

나의 생각은 간단하게 Filter을 하나 구현하고

            .addFilterAfter(
                skipOtpForScanAccountFilter(),
                FirstFactorAuthenticationFilter::class.java
            )

하나만 추가해주면 끝나는 일일 줄 알았따. 진짜로......

하지만 왜인지 세션에 저장이 되지 않는 이슈가 발생했다.
그래서 그 이슈가 왜 생겨났는지를 보려고 한다.

위 관련 이슈를 살펴보기 전에, 우선 AbstractAuthenticationProcessingFilter 가 어떻게 동작하는지 알아봐야 한다.

우리는 간단하게 한 가지 생각을 해볼 수 있다.
AbstractAuthenticationProcessingFilter에 Provider와 SuccesHandler와 SecurityContextRepository 가 어떤 순서로 동작하는지 알아야 한다.

이를 위해서 각 단계별로 println을 찍어보자.

FirstFactorAuthenticationFilter

class FirstFactorAuthenticationFilter : AbstractAuthenticationProcessingFilter("/login") {

    override fun requiresAuthentication(request: HttpServletRequest, response: HttpServletResponse): Boolean {
        return request.method.equals("POST", ignoreCase = true) && super.requiresAuthentication(request, response)
    }

    override fun attemptAuthentication(request: HttpServletRequest?, response: HttpServletResponse?): Authentication {
        println("FirstFactorAuthenticationFilter")

        if (request == null || !request.method.equals("POST", ignoreCase = true)) {
            throw AuthenticationException("Only POST requests are allowed")
        }

        val username = request.getParameter("username")
        val password = request.getParameter("password")

        if (username.isNullOrBlank() || password.isNullOrBlank()) {
            throw AuthenticationException("Invalid username or password")
        }

        val authRequest = UsernamePasswordAuthenticationToken(username, password)
        return authenticationManager.authenticate(authRequest)
    }
}

FirstAuthenticationProvider

class FirstAuthenticationProvider(
    private val userDetailsService: UserDetailsService
) : AuthenticationProvider {

    override fun authenticate(authentication: Authentication): Authentication {
        println("FirstAuthenticationProvider")

        val username = authentication.name
        val password = authentication.credentials as String

        val userDetails: UserDetails = userDetailsService.loadUserByUsername(username)

        if (userDetails.password != password) {
            throw BadCredentialsException("Invalid username or password")
        }

        return UsernamePasswordAuthenticationToken(
            userDetails.username,
            null,
            userDetails.authorities
        )
    }

    override fun supports(authentication: Class<*>): Boolean {
        return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
    }
}

FirstFactorSuccessHandler

class FirstFactorSuccessHandler: AuthenticationSuccessHandler {

    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        println("FirstFactorSuccessHandler")

        val updatedAuthentication = UsernamePasswordAuthenticationToken(
            authentication.principal,
            authentication.credentials,
            listOf(SimpleGrantedAuthority("ROLE_1FA_COMPLETE"))
        )

        SecurityContextHolder.getContext().authentication = updatedAuthentication

        response.writer.write("Authentication Successful")
        response.status = HttpServletResponse.SC_OK
    }
}

CustomSecurityContextRepository

class CustomSecurityContextRepository :  HttpSessionSecurityContextRepository() {

    override fun saveContext(
        context: SecurityContext,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        println("CustomSecurityContextRepository")

        super.saveContext(context, request, response)
    }
}

FirstFactorAuthenticationFilter
FirstAuthenticationProvider
CustomSecurityContextRepository
FirstFactorSuccessHandler

이 순서로 실행되는 것을 볼 수 있다.

오... 내가 생각했던 것과 동일하게 동작한다.

Filter를 거쳐서 Provider를 통해 인증하고, 그것을 시큐리티 컨텍스트 레포지토리에 저장하고, SuccessHandler에 저장되는 것 까지!

그럼 내가 생각 한 것 처럼 필터 하나만 더 추가해서 2차 인증을 강제로 밀어 넣으면 해결 될까...?

우선 필터를 구현해보자.

class SkipOtpForScanAccountFilter: OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        if (request.servletPath != "/login" || request.method != "POST") {
            filterChain.doFilter(request, response)
            return
        }

        println("SkipOtpForScanAccountFilter")

        filterChain.doFilter(request, response)
    }
}

그리고 실행해보자

띠옹....? 순서가........??????????
디버그 모드로 켜보자.

@EnableWebSecurity(debug = true)
2024-12-11T23:51:20.347+09:00  INFO 70961 --- [spring-security] [nio-8080-exec-3] Spring Security Debugger                 : 

************************************************************

Request received for POST '/login':

org.apache.catalina.connector.RequestFacade@4a4ea35

servletPath:/login
pathInfo:null
headers: 
host: localhost:8080
connection: keep-alive
content-length: 25
cache-control: max-age=0
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
origin: http://localhost:8080
dnt: 1
upgrade-insecure-requests: 1
content-type: application/x-www-form-urlencoded
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
sec-fetch-site: same-origin
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
referer: http://localhost:8080/login
accept-encoding: gzip, deflate, br, zstd
accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6,zh;q=0.5
cookie: Idea-7b7c9aef=051867cd-5765-4cfa-92c2-371ad6a1cf93; JSESSIONID=9B2C9F8DE3B7A9D70F4C2C7B4EDD0563


Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  LogoutFilter
  FirstFactorAuthenticationFilter
  TwoFactorAuthenticationFilter
  SkipOtpForScanAccountFilter
  UsernamePasswordAuthenticationFilter
  DefaultResourcesFilter
  DefaultLoginPageGeneratingFilter
  DefaultLogoutPageGeneratingFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]


************************************************************


FirstFactorAuthenticationFilter
FirstAuthenticationProvider
CustomSecurityContextRepository
2024-12-11T23:51:20.370+09:00  INFO 70961 --- [spring-security] [nio-8080-exec-3] Spring Security Debugger                 : 

************************************************************

New HTTP session created: 77B8C46711B3F0A7A88D4617692B8DCB

************************************************************


FirstFactorSuccessHandler

순서는 제대로 잘 된 것 같습니다.

FirstFactorAuthenticationFilter
TwoFactorAuthenticationFilter
SkipOtpForScanAccountFilter

분명 제가 원했던 순서대로 들어간 것 같거든요.
근데 왜...? 실행이 안될까요?
필터가....
대체 왜!!!!!!!!!!!!!를 알기 위해서는

AbstractAuthenticationProcessingFilter를 알아야 합니다.

다음 이 시간에는 AbstractAuthenticationProcessingFilter 를 조금 파해쳐봅시다.

profile
뒷-끝 있는 개-발자

0개의 댓글