회사에서 프로젝트를 아예 싹 옮기는 마이그레이션 작업이 진행되었습니다.
새로운 프로젝트에는 스프링 시큐리티가 붙었고,
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을 찍어보자.
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)
}
}
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)
}
}
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
}
}
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 를 조금 파해쳐봅시다.