[Spring Security] 예제 프로젝트1. 목표 및 기본 구현
지금까지 정수원님의 Spring Security 강의를 보며 Spring Security 포스팅을 작성해 왔다. 하지만 해당 강의는 5.x 버전 대의 내용을 다루고 있었고, Spring Boot 3(Framework 6.x)가 출시되면서 Spring Security 역시
cares-log.tistory.com
현재 포스팅은 위 포스팅에서 이어집니다.
목표
이번 포스팅에서는 API로 로그인을 할 수 있도록 인증 필터를 구현할 것이다.
Spring Security에서 구현해 놓은 기존의 Form Login 인증 프로세스를 분석한 후, 기존의 클래스들을 사용할 수 있다면 적절히 사용하고 정말 필요한 부분만 커스터마이징 할 것이다.
이전에 인증 아키텍처, Form Login에 대한 포스팅에서 Spring Security 6.x 버전대가 아닐 때의 내용을 다뤘었는데, 인증 자체는 크게 변경된 점이 없어보이기에 해당 글을 참고한 후 이 포스팅을 읽어보면 더 좋을 거 같다.
2023.06.07 - [Spring/Security] - [Spring Security] 2. 인증 API - Form Login
[Spring Security] 2. 인증 API - Form Login
Form Login 설정 스프링 시큐리티는 세션 기반 인증을 기본으로 제공한다. 1. 인증이 안되면 로그인 페이지로 리다이렉트 2. 회원이 로그인을 하게 되면 인증 결과를 담은 Authentication 객체 생성 및
cares-log.tistory.com
2023.06.19 - [Spring/Security] - [Spring Security] 12. 아키텍처 - 인증 객체와 인증 객체 저장소
[Spring Security] 12. 아키텍처 - 인증 객체와 인증 객체 저장소
인증 객체 - Authentication 이전에 기본 인증 API에 대해 다뤘을 때 Authentication의 구현체가 자주 등장했었다. 이제 이 인터페이스를 상세하게 살펴보자. Authentication은 이름 그대로 Spring Security 전역에
cares-log.tistory.com
2023.06.20 - [Spring/Security] - [Spring Security] 13. 아키텍처 - 인증 흐름
[Spring Security] 13. 아키텍처 - 인증 흐름
지금까지 인증과 관련해서 커스텀 설정을 하는 방법을 시작해서 세부적으로 Spring Security에서 어떻게 인증 정보를 다루는지(Authentication 인터페이스), 이 인증 정보는 어디에 저장(SecurityContext)하
cares-log.tistory.com
기존 인증 프로세스 분석
우선 Form Login을 활성화 하게 되면 등록되는 `UsernamePasswordAuthenticationFilter` 클래스를 디버깅 해보면서 인증 프로세스를 분석해볼 것이다. 분석하면서 어떻게 email, password를 기반으로 API 인증을 수행할 것인지 방법을 도출해보자.
AbstractAuthenticationProcessingFilter 분석
`UsernamePasswordAuthenticationFilter` 필터는 `AbstractAuthenticationProcessingFilter`의 구현체이다. 따라서 해당 클래스에 구현되어 있는 사항을 순차적으로 분석해보자.
1. requiresAuthentication(): 인증이 필요한지 검증한다. 필요하지 않다면 filterChain의 다음 필터를 실행시킨다.
2. attemtAuthentication(): 인증을 진행한다.
3. 인증 결과가 null이라면 다음 필터를 진행시키지 않고 즉시 리턴한다.
4. sessionStrategy.onAuthentication(): 인증이 성공한 후 세션과 관련된 행위들을 수행한다. (세션 고정 보호 등)
- 기본설정은 CompositeSessionAuthenticationStrategy 객체가 초기화, ChangeSessionIdAuthenticationStrategy로 위임
5. continueChainBeforeSuccessfulAuthentication가 true일 경우 이후 과정 진행 전에 FilterChain의 다음 필터 실행
6. successfulAuthentication(): 인증 성공시 로직 수행
6-1: securityContextHolderStrategy.createEmptyContext(): 빈 SecurityContext 생성
- 기본설정은 ThreadLocalSecurityContextHolderStrategy 초기화
6-2. context.setAuthentication(): SecurityContext에 Authentication 객체 저장
6-3. securityContextHolderStrategy.setContext(): SecurityContext 저장
- 쓰레드에 대해 SecurityContext 저장
6-4. securityContextRepository.saveContext(): SecurityContext 저장
- 요청간 SecurityContext 를 유지하기 위해 저장
6-5. rememberMeServices.loginSuccess(): RememberMeServices 구현체에 따라 기능 적용
- 기본설정은 NullRememberMeServices 초기화
6-6. successHandler.onAuthenticationSuccess(): 인증 성공 시 최종적으로 다뤄야 할 로직 수행
7. unsuccessfulAuthentication(): 인증 과정 중 InternalAuthenticationServiceException, AuthenticationException이 발생한 경우 실행
7-1. securityContextHolderStrategy.clearContext(): SecurityContext 정리
7-2. rememberMeServices.loginFail(): 로그인 실패 시 처리해야 할 RememberMeServices 로직 수행
7-3: failureHandler.onAuthenticationFailure(): 인증 실패 시 최종적으로 다뤄야 할 로직 수행
위 과정을 참고하여 커스터마이징이 필요한 부분을 한번 추출한 다음 해당 클래스의 구현체를 만들어서 커스텀 인증을 구현할 것인지, 아니면 아예 다른 새로운 필터를 만들어야 할지 결정해보자.
일단 실질적으로 인증을 진행하는 2번 과정은 필수적으로 커스터마이징을 해야 한다. `attemtAuthentication()`은 추상메서드로 선언되어 있으며, 이를 `UsernamePasswordAuthenticationFilter`가 구현하고 있다. 따라서 인증 작업을 커스터마이징 하려면 `UsernamePasswordAuthenticationFilter` 를 우선 살펴본 다음에 이를 커스터마이징 해야 한다. 만약 `UsernamePasswordAuthenticationFilter` 클래스 의 구현을 보고 사용할 수 있는 부분이 많고 특정 부분만 수정해도 가능한 사항이라면 해당 필터에서 사용하는 다른 클래스들 (`AuthenticationManager`, `AuthenticationProvider` 등)을 커스터마이징 하여 설정을 수정해주면 될 것이다.
이 외에 인증이 성공했을 때와 실패했을 때 JSON으로 응답을 주기 위해 6-6, 7-3 과정도 커스터마이징이 이루어져야 할 것이다.
이외의 과정들은 따로 커스터마이징 하지 않아도 된다고 판단된다. 따라서 아예 다른 새로운 필터를 만들 필요는 없고, `AbstractAuthenticationProcessingFilter`를 구현해서 새로운 필터를 등록하거나, 아니면 기존 `UsernamePasswordAuthenticationFilter`의 하위 설정들만 바꾸어서 그대로 사용하는 방법 둘 중에 하나를 취하면 될 것 같다.
UsernamePasswordAuthenticationFilter 분석
1. obtainUsername(), obtainPassword(): 요청 객체로 부터 username, password를 얻는다.
2. UsernamePasswordAuthenticationToken.unauthenticated(): 아직 인증되지 않은 토큰을 생성한다.
3. setDetails: 인증 객체의 details를 설정한다
- 기본 설정으로는 WebAuthenticationDetailsSource의 buildDetails가 호출되어 WebAuthenticationDetails가 설정된다.
4. getAuthenticationManager().authenticate(): 인증되지 않은 토큰을 가지고 AuthenticationManager를 통해 인증을 수행한다.
- 기본 설정으로는 ProviderManager가 초기화 되어 있다.
2번 과정에서 `UsernamePasswordAuthenticationToken`은 `Authentication`의 구현체인데, 팩토리 메서드로 인증되지 않은 상태의 객체, 인증된 상태의 객체를 생성할 수 있도록 구현되어 있다. 최종적으로 4번 과정을 통해서 인증된 상태의 객체를 반환받아 바로 리턴하여 `AbstractAuthenticationProcessingFilter`에서 처리하게 되는 것이다.
다만 API로 로그인을 구현해야 하다보니, `UsernamePasswordAuthenticationFilter`를 사용하지 못하는 한 가지 문제점이 있다. 바로 1번 과정에서 문제가 생긴다. 모두 요청 객체의 파라미터로 부터 username, password를 가져오기 때문이다. 현 상황은 request body로부터 이를 추출해야 하기 때문에, 결론적으로 `UsernamePasswordAuthenticationFilter`를 사용하지는 못한다. 한가지 다행(?)인 점은 `obtainUsername()`, `obtainPassword()` 메서드는 오버라이딩이 가능하다. 따라서 `UsernamePasswordAuthenticationFilter` 자체를 상속받아 구현하면 간편할 것이다.
일단 기본으로 초기화 되어 있는 `ProviderManager`를 분석한 후에 하위 과정에서도 커스텀해야 할 부분이 있는지 알아보자.
ProviderManager 분석
1. AuthenticationProvider 객체 리스트를 루프를 돌며 인증 객체를 처리할 수 있는 객체를 찾는다. (supports)
2. 만약 처리할 수 있는 Provider가 있다면 인증을 진행한다. 인증 도중 예외가 발생한다면 lastException에 초기화한다.
3. 만약 적합한 Provider가 없고, parent가 null이 아닌 경우 parent의 authenticate을 호출한다.
- parent는 AuthenticationProvider 이므로 1번 과정부터 반복
4. 최종적으로 결과가 인증 결과가 없다면 예외를 던진다.
`ProviderManager`는 클래스명 그대로 `AuthenticationProvier`를 관리하는 역할을 한다. `providers`라는 `AuthenticationProvider` 리스트를 멤버로 가지고 있고 실제 인증 자체는 이 객체들 중에서 이루어지게 된다.
`parent`라는 멤버는 또 다른 `ProviderManager`인데, 만약 `providers` 중에서 인증을 처리할 수 있는 `AuthenticationProvider`를 찾지 못했다면 `parent`의 `authenticate()`를 호출하게 된다. `parent` 까지 거쳐 최종적으로 지원하는 `AuthenticationProvider`가 없어서 인증이 되지 않았다면, `ProviderNotFoundException`을 던지게 된다.
결론적으로 실제 인증 자체는 `AuthenticationProvider`가 수행하게 된다. 기본 설정으로는 `DaoAuthenticationProvider`가 `UsernamePasswordAuthenticationToken`을 처리할 수 있어서 해당 객체가 인증을 처리하게 된다. `DaoAuthenticationProvider`는 `AbstractUserDetailsAuthenticationProvider`의 구현체로 우선 해당 추상클래스를 분석해보자.
AbstractUserDetailsAuthenticationProvider 분석
1. determainUsername(): 유저 이름을 판단한다.
2. userCache.getUserFromCache(): 캐시된 UserDetails 객체가 있다면 가져온다.
3. retrieveUser(): 캐시된 객체가 없다면 유저를 찾는다.
- 추상 메서드, DaoAuthenticationProvider에서 UserDetailsService로 부터 조회하도록 구현됨
4. preAuthenticationChecks.check(): 계정이 정상적인지 체크한다.
5. additionalAuthenticationChecks(): 추가적으로 계정을 체크한다.
- 추상 메서드, DaoAuthenticationProvider에서 비밀번호를 확인하도록 구현됨
6. AuthenticationException이 발생한 경우, 캐시된 유저라면 3번 과정부터 다시 수행하게 되고 아니라면 예외를 그대로 던진다.
7. 인증 성공 상태의 Authentication 객체를 생성하여 리턴한다.
우선 회원의 정보는 DB에 저장한 후, 이 곳에서 유저를 조회해야 하기 때문에 3번 과정은 필수로 커스터마이징 해야한다. 기본 설정으로는 `InMemoryUserDetailsService`로 부터 유저를 조회하는데, 이를 DB에서 조회하는 구현체로 따로 만들어야 할 것이다.
이외의 과정은 기본적으로 구현되어 있는 부분을 사용하면 될 것 같은데, 한 가지 고려해야 할 사항은 `AbstractUserDetailsAuthenticationProvider`가 취급하는 `Authentication` 객체가 `UsernamePasswordAuthenticationToken`이라는 점이다.
`UsernamePasswordAuthenticationToken`은 클래스명 그대로 username, password로 인증을 진행한다. 따라서 사실 이메일과 비밀번호로 로그인을 해야하는 입장에서는 이 인증 객체를 그대로 사용해도 상관은 없어보인다. 하지만 `principal`에 초기화 되는 `UserDetails` 객체는 회원 도메인에 알맞게 커스텀한 객체로 수정을 해야할 것이다.
결론
1. UsernamePasswordAuthenticationFilter 상속하여 새로운 필터 구현
- obtainUsername, obtainPassword 메서드만 오버라이딩
2. JSON 응답을 위해 AuthenticationSuccessHandler, AuthenticationFailureHandler 구현
3. 우리 DB에서 조회하도록 UserDetailsService를 새로 구현
4. 우리 도메인에 맞는 UserDetails 구현체 새로 구현
API 인증 프로세스 구현
지금까지 Spring Security에서 Form Login 활성화 시에 기본적으로 구현되어 있던 인증 프로세스를 살펴보았다. 이 과정에서 어떤 부분이 커스터마이징 되어야 하는지 파악했고, 이제 이를 실제로 구현해볼 차례이다.
ApiAuthenticationFilter 구현
`UsernamePasswordAuthenticationFilter`를 상속받아 구현할 클래스이다. 기본적으로 `obtainUsername()`, `obtainPassword()` 메서드만 재정의하면 된다.
class ApiAuthenticationFilter(
private val objectMapper: ObjectMapper,
) : UsernamePasswordAuthenticationFilter() {
override fun obtainPassword(request: HttpServletRequest): String? {
return obtainAuthenticationRequest(request).password
}
override fun obtainUsername(request: HttpServletRequest): String? {
return obtainAuthenticationRequest(request).email
}
private fun obtainAuthenticationRequest(request: HttpServletRequest): ApiAuthenticationRequest {
return try {
objectMapper.readValue(request.reader, ApiAuthenticationRequest::class.java)
} catch (e: Exception) {
throw InvalidAuthenticationRequestBodyException(e.message)
}
}
}
data class ApiAuthenticationRequest(
val email: String,
val password: String,
)
class InvalidAuthenticationRequestBodyException(
message: String?,
) : AuthenticationException(message)
`ApiAuthenticationRequest`라는 요청 JSON 데이터 매핑용 클래스를 만들었고, `ObjectMapper`를 통해 이를 읽어들여 각각 email, password를 반환해주면 된다. 객체 매핑시 예외가 발생하는 경우에는 간단하게 `AuthenticationException`을 상속받은 `InvalidAuthenticationRequestBodyException`을 던져서 결국 추후에 `AuthenticationFailureHandler`에서 처리되도록 만들었다. (지금보다 예외를 상세하게 나누어서 던지는 것이 좋다)
JpaUserDetailsService 구현
이제 `UserDetails` 클래스를 기반으로 유저의 정보를 가지고 오는 역할을 하는 `UserDetailsService`를 구현하자. JPA를 사용하기 때문에, JPA Repository로 부터 유저 정보를 조회해올 것이다. 그리고 현재 `Account` 도메인에 맞추어 커스텀한 `UserDetails` 클래스를 새로 만들 것이다.
class JpaUserDetailsService(
private val accountRepository: AccountRepository,
) : UserDetailsService {
override fun loadUserByUsername(email: String): UserDetails {
val account = accountRepository.findByEmailOrNull(email)
?: throw UsernameNotFoundException(email)
return CustomUserDetails(
email = account.email,
password = account.password,
role = account.role,
)
}
}
data class CustomUserDetails(
val email: String,
@get: JvmName("getPasswordKt")
val password: String,
val role: AccountRole,
) : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority> {
return setOf(SimpleGrantedAuthority(role.name))
}
override fun getPassword(): String {
return password
}
override fun getUsername(): String {
return email
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
}
`isAccountNonExpired()` 메서드 부터 오버라이딩한 메서드들은 현재 `Account` 도메인에는 유저의 활성화 여부나, 잠금 여부 등등에 대해서 요구사항이 없었기 때문에 단순히 모두 true를 반환하도록 했다. 따라서 이 메서드들은 추후 `AbstractUserDetailsAuthenticationProvider`에서 검증할 때 호출될텐데 false를 응답하지 않기에 예외가 발생할 일은 없다.
AuthenticationSuccessHandler, AuthenticationFailureHandler 구현
인증이 성공할 경우, 실패할 경우 최종적으로 호출되는 로직을 구현해보자.
class ApiAuthenticationSuccessHandler(
private val objectMapper: ObjectMapper,
) : AuthenticationSuccessHandler {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
response.status = HttpStatus.OK.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
objectMapper.writeValue(response.writer, ApiResponse.ofSuccess(authentication.principal))
}
}
class ApiAuthenticationFailureHandler(
private val objectMapper: ObjectMapper,
) : AuthenticationFailureHandler {
override fun onAuthenticationFailure(
request: HttpServletRequest,
response: HttpServletResponse,
exception: AuthenticationException
) {
response.status = HttpStatus.OK.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
objectMapper.writeValue(response.writer, ApiResponse.ofError(HttpStatus.UNAUTHORIZED, exception.message))
}
}
단순히 결과에 대해서 JSON으로 응답을 내도록 구현했다. `ApiAuthenticationSuccessHandler`의 경우에는 `principal` 자체를 그대로 응답하고 있는데, 이는 사실 좋은 방법은 아니다. 이는 결국 `CustomUserDetails` 객체를 그대로 JSON으로 직렬화 하여 클라이언트에게 응답하게 되는데, 암호화된 비밀번호, 계정 잠금 정보 등등이 모두 노출되기에 원래는 필요한 값들만 담은 새로운 객체로 변환하여 응답해주는 게 좋을 것이다. 다만, 바로 다음에 토큰 기반 인증을 개발할텐데 어차피 이 경우에 로직이 변경될 것이기 때문에 이렇게 간단하게 구현했다.
API 인증 프로세스 설정
이제 구현한 클래스들을 Security 설정을 통해 적용시켜주면 된다.
설정 방법은 필터객체를 직접 생성한 후 직접 추가해주는 방법과 `AbstractHttpConfigurer`를 구현하여 Spring Security 초기화 시 자동으로 필터를 생성시키도록 하는 방법이 있다. 두 방법으로 모두 설정해보자.
필터 객체 직접 생성하여 추가하기
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val objectMapper: ObjectMapper,
private val accountRepository: AccountRepository,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.formLogin { formLogin -> formLogin.disable() }
http.csrf { csrf -> csrf.disable() }
http.rememberMe { rememberMe -> rememberMe.disable() }
http.httpBasic { httpBasic -> httpBasic.disable() }
http.addFilterBefore(apiAuthenticationFilter(http), UsernamePasswordAuthenticationFilter::class.java)
http.authorizeHttpRequests { authorize ->
authorize.requestMatchers(apiAuthenticationUrl).permitAll()
authorize.anyRequest().hasRole("USER")
}
return http.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
@Bean
fun apiAuthenticationFilter(http: HttpSecurity): ApiAuthenticationFilter {
val apiAuthenticationFilter = ApiAuthenticationFilter(objectMapper)
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
authenticationManagerBuilder.userDetailsService(jpaUserDetailsService())
val authenticationManager = authenticationManagerBuilder.build()
apiAuthenticationFilter.setAuthenticationManager(authenticationManager)
apiAuthenticationFilter.setRequiresAuthenticationRequestMatcher(
AntPathRequestMatcher("/api/login", "POST")
)
apiAuthenticationFilter.setAuthenticationSuccessHandler(apiAuthenticationSuccessHandler())
apiAuthenticationFilter.setAuthenticationFailureHandler(apiAuthenticationFailureHandler())
return apiAuthenticationFilter
}
@Bean
fun jpaUserDetailsService(): JpaUserDetailsService {
return JpaUserDetailsService(accountRepository)
}
@Bean
fun apiAuthenticationSuccessHandler(): AuthenticationSuccessHandler {
return ApiAuthenticationSuccessHandler(objectMapper)
}
@Bean
fun apiAuthenticationFailureHandler(): AuthenticationFailureHandler {
return ApiAuthenticationFailureHandler(objectMapper)
}
}
우선 `ApiAuthenticationFilter` 객체를 생성해야 한 후, 우리가 커스터마이징 한 부분은 일단 `UserDetailsService`, `AuthenticationSuccessHandler`, `AuthenticationFailureHandler` 이기에 이 부분만 설정을 해주면 된다.
이 외에 `AuthenticationManager`, `AuthenticationProvider` 등은 커스터마이징 한 부분이 없기에, 그대로 기본으로 생성되는 객체를 사용하면 된다. 따라서 `http.getSharedObject(AuthenticationManagerBuilder::class.java)`를 통해 빌더를 얻어낸 후, `UserDetailsService`만 바꿔서 빌드한 후에 `ApiAuthenticationFilter`에 세팅 해주었다.
필터 객체를 직접 생성하여 추가하면 생기는 문제점
`AbstractAuthenticationProcessingFilter`에는 우리가 지금까지 살펴본 멤버들 외에도 더 많은 멤버들이 있다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
protected ApplicationEventPublisher eventPublisher;
protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private AuthenticationManager authenticationManager;
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private RequestMatcher requiresAuthenticationRequestMatcher;
private boolean continueChainBeforeSuccessfulAuthentication = false;
private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
private boolean allowSessionCreation = true;
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
...
}
따라서 위의 멤버들을 직접 setter 메서드를 호출해 초기화 해주지 않는 이상, 위 코드에서 선언과 동시에 초기화 해준 멤버들(`securityContextRepository`, `rememberMeService` 등등..)은 각각 생성된 객체가 초기화 되고, 선언만 해 놓고 초기화 하지 않은 멤버들(`eventPublisher`, `authenticationManager` 등등..)은 null 값이 들어가게 된다.
결론적으로 위 멤버들을 모두 적절하게 초기화 해주기 위해서는 직접 setter 메서드들을 일일이 호출해줘야 한다는 문제점이 생긴다. 예를 들어, `rememberMeService` 기능을 사용하기 위해 설정을 해 놓았다고 한다면, `RememberMeService` 공유 객체를 얻은 다음에`ApiAuthenticationFilter` 설정에 `setRememberMeService()`를 호출해줘야 한다. 여간 불편한 일이 아닐 수 없다.
따라서 이를 해결하기 위해서 어떻게 Spring Security에서 `UsernamePasswordAuthenticationFilter`를 초기화 해서 추가해주는 지 확인해볼 필요가 있다. `ApiAuthenticationFilter`는 결국 `UsernamePasswordAuthenticationFilter`를 상속받아서 일부분만 커스텀한 필터이기 때문에, Spring Security에서 `UsernamePasswordAuthenticationFilter`를 어떻게 초기화 해주는지를 확인해보고 이를 그대로 활용할 수 있다면 활용하면 그만이다.
AbstractAuthenticationFilterConfigurer 클래스를 사용해서 설정하기
`UsernamePasswordAuthenticationFilter`는 초기화 될 때 `FormLoginConfigurer` 클래스를 통해서 초기화 된다. `FormLoginConfiguer` 클래스는 `AbstractAuthenticationFilterConfigurer` 클래스를 상속받은 클래스이다. 그리고 실제 애플리케이션을 실행시킨 다음 디버깅을 해보면, `AbstractAuthenticationFilterConfigurer`의 `configure()` 메서드가 실행되는 것을 볼 수 있다.
public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter> extends AbstractHttpConfigurer<T, B> {
...
@Override
public void configure(B http) throws Exception {
PortMapper portMapper = http.getSharedObject(PortMapper.class);
if (portMapper != null) {
this.authenticationEntryPoint.setPortMapper(portMapper);
}
RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache != null) {
this.defaultSuccessHandler.setRequestCache(requestCache);
}
this.authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
this.authFilter.setAuthenticationSuccessHandler(this.successHandler);
this.authFilter.setAuthenticationFailureHandler(this.failureHandler);
if (this.authenticationDetailsSource != null) {
this.authFilter.setAuthenticationDetailsSource(this.authenticationDetailsSource);
}
SessionAuthenticationStrategy sessionAuthenticationStrategy = http
.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
this.authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
RememberMeServices rememberMeServices = http.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
this.authFilter.setRememberMeServices(rememberMeServices);
}
SecurityContextConfigurer securityContextConfigurer = http.getConfigurer(SecurityContextConfigurer.class);
if (securityContextConfigurer != null && securityContextConfigurer.isRequireExplicitSave()) {
SecurityContextRepository securityContextRepository = securityContextConfigurer
.getSecurityContextRepository();
this.authFilter.setSecurityContextRepository(securityContextRepository);
}
this.authFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
F filter = postProcess(this.authFilter);
http.addFilter(filter);
}
...
}
보면 `AbstractAuthenticationProcessingFilter`가 초기화 될 때 필요한 멤버들을 적절히 공유 객체를 꺼내어 설정을 해주고 있다. 따라서 `ApiAuthenticationFilter` 역시 `AbstractAuthenticationProcessingFilter`를 상속받은 클래스이기 때문에 이를 사용할 수가 있게 된다.
class ApiAuthenticationConfigurer<H: HttpSecurityBuilder<H>>(
objectMapper: ObjectMapper,
) : AbstractAuthenticationFilterConfigurer<H, ApiAuthenticationConfigurer<H>, ApiAuthenticationFilter>(
ApiAuthenticationFilter(objectMapper), "/accounts/login"
) {
override fun createLoginProcessingUrlMatcher(loginProcessingUrl: String?): RequestMatcher {
return AntPathRequestMatcher(loginProcessingUrl, "POST")
}
}
위와 같이 `AbstractAuthenticationFilterConfigurer`를 상속 받은 `ApiAuthenticationConfigurer` 클래스를 만들었다. 현재 목표는 Spring Security가 기본적으로 `UsernamePasswordAuthenticationFilter`를 초기화 해줄 때의 설정을 그대로 가져가기 위해서이다. 따라서 `AbstractAuthenticationFilterConfigurer`의 `configure()` 메서드를 따로 오버라이딩 할 필요는 없다. 기본 설정 그대로 가져가야하기 때문이다.
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
...
http.authenticationManager(apiAuthenticationManager(http))
http.apply(ApiAuthenticationConfigurer(objectMapper))
.loginProcessingUrl(apiAuthenticationUrl)
.successHandler(apiAuthenticationSuccessHandler())
.failureHandler(apiAuthenticationFailureHandler())
.authenticationDetailsSource(WebAuthenticationDetailsSource())
...
return http.build()
}
@Bean
fun apiAuthenticationManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
authenticationManagerBuilder.userDetailsService(jpaUserDetailsService())
return authenticationManagerBuilder.build()
}
적용은 위와 같이 해주면 된다. `AbstractAuthenticationFilterConfigurer`의 `configure()` 함수를 보면, `AuthenticationManager`의 경우에는 공유 객체를 직접 가져와서 초기화를 해주고 있다. 따라서 지금처럼 `AuthenticationManager`를 커스텀한 경우에는, `httpSecurity`에 전역으로 `AuthenticationManager` 객체를 설정해줘야 해당 공유 객체를 얻어서 `AbstractAuthenticationProcessingFilter`의 `authenticationManager` 멤버에 초기화 시켜주게 된다.
실제 애플리케이션을 실행시켜 디버깅을 해보면, 이전에 직접 객체를 생성하여 필터에 추가한 것과 다르게 멤버들이 Spring Security에서 기본으로 설정되어 생성된 객체들로 적절히 초기화 되어 있다.
Trouble Shooting - InputStream이 닫히는 문제
`ApiAuthenticationFilter`를 잘 살펴보면, `obtainAuthenticationRequest()` 함수를 총 두 번 호출하게 된다. `UsernamePasswordAuthenticationFilter`에서 username과 password를 두 번으로 나눠서 가져오게 구현이 되어있기 때문이다. 근데 이 부분에서 결국 문제가 생긴다.
실제로 설정을 다 한 후에 요청을 보내보면 아래와 같은 예외가 발생하는 것을 확인할 수 있다.
이 예외가 발생하는 이유는, `ObjectMapper`가 `readValue()`를 통해 `InputStream`을 읽은 이후에 해당 `InputStream`을 닫아버리기 때문이다. 따라서 `obtainUsername()` 함수를 실행할 때 처음으로 `obtainAuthenticationRequest()` 함수를 호출하게 되고 `readValue()`로 `InputStream`을 읽게 되면 `InputStream`이 닫힌 상태가 되어버리는데 `obtainPassword()` 함수를 실행할 때 닫힌 `InputStream`을 읽으려고 하기 때문에 예외가 발생해버린다.
이를 방지하기 위해서 `InputStream`을 읽을 때 마다 새로운 `InputStream`을 반환해주도록 처리를 해줘야 할 것이다. 서블릿 요청을 개발자가 상속하여 커스터마이징 하기 위해서 `HttpServletRequestWrapper` 클래스를 사용할 수 있다. 말 그대로 요청을 감싸놓은 클래스인데, 이 클래스를 상속받아 필요한 부분만 오버라이딩 하여 이 문제를 해결해보자.
class ReReadableRequestWrapper(
request: HttpServletRequest
) : HttpServletRequestWrapper(request) {
private val encoding: Charset
private val bodyData: ByteArray
init {
val characterEncoding = if (request.characterEncoding.isBlank()) {
StandardCharsets.UTF_8.name()
} else request.characterEncoding
this.encoding = Charset.forName(characterEncoding)
bodyData = IOUtils.toByteArray(super.getInputStream())
}
override fun getInputStream(): ServletInputStream {
return BodyInputStream(bodyData)
}
override fun getReader(): BufferedReader {
return BufferedReader(InputStreamReader(this.inputStream, this.encoding))
}
}
지금 현재 필요한 것은 요청 객체의 Body를 한번 읽고 끝내는 것이 아니라 계속해서 읽을 수 있어야 한다.
따라서 객체에 요청 Body의 바이트 배열을 초기화 해둔 변수를 두고, 추후에 `InputStream`을 필요로 할 때 저장된 Body 바이트 배열을 통해 만들어진 `InputStream`을 반환해주면 될 것이다. 이렇게 되면 `getInputStream()`을 호출할 때마다 새로운 `InputStream`을 반환하게 되고, `obtainUsername()`, `obtainPassword()`를 각각 호출하더라도 항상 반환되는 `InputStream`은 다른 객체이기 때문에 Stream이 닫히는 문제를 해결할 수가 있다.
참고로 `getInputStream()`에서 반환하는 `BodyInputStream` 클래스는 `DefaultSeverRequestBuilder` 클래스에 구현되어 있는 `BodyInputStream` 내부 클래스를 복사해왔다. 접근제어자가 private이라 직접 사용할 수는 없었다.
그렇다면 이제 `Inputstream`을 꺼내더라도 항상 새로운 `InputStream`이 반환되는 요청 클래스를 만들었으니까, 이를 어떻게 적용해야 할 지가 문제였다. 처음에 생각났던 방법은, `ApiAuthenticationFilter`에서 `attemptAuthenticate()` 함수를 오버라이딩 하여 요청 객체만 바꿔주는 방법이 생각났다.
class ApiAuthenticationFilter(
private val objectMapper: ObjectMapper,
) : UsernamePasswordAuthenticationFilter() {
...
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
val wrappedRequest = ReReadableRequestWrapper(request)
return super.attemptAuthentication(wrappedRequest, response)
}
...
}
이렇게 한다면 실제 `attemtAuthentication()`의 파라미터로 전달되는 요청 객체는 `ReReadableRequestWrapper` 객체가 전달되고 예외는 발생하지 않을 것이다.
다만, 이 방법은 한 가지 문제가 있는데 `attemptAuthenticate()` 함수 말고, 다른 부분에서도 Request의 Body를 얻어야 하는 경우가 생긴다면 어떻게 될까? 이미 `ReReadableRequestWrapper`를 생성할 당시에 `InputStream`을 읽어버렸기 때문에 기존의 `HttpServletRequest`에서 `InputStream`을 읽으면 빈 스트림이 반환되게 된다. 따라서 이 방법은 추후에 Body가 필요한 경우 읽지 못하는 상황이 발생할 수도 있다.
그래서 차라리 `ApiAuthenticationFilter` 앞에 새로운 필터를 두어 요청 객체만 `ReReadableRequestWrapper`로 바꾸어서 전달하는 방법을 선택했다.
class ChangeToReReadableRequestFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val readableRequestWrapper = ReReadableRequestWrapper(request)
filterChain.doFilter(readableRequestWrapper, response)
}
}
우선 위와 같이 간단한 필터를 만들었다. 이 필터는 `HttpServletRequest`를 `ReReadableRequestWrapper`로 변환하여 다음 필터체인에 계속 전달하는 역할을 한다. 따라서 이 필터를 거치게 된 후에는 계속해서 요청 Body를 읽을 수 있게 된다.
이제 이 필터를 FilterChain에 등록하면 된다.
http.addFilterBefore(changeToReReadableRequestFilter(), ApiAuthenticationFilter::class.java)
정리
지금까지 기존 인증 프로세스를 분석해보고, 직접 필요한 부분만 구현하여 커스터마이징 해보았다. 이제 다음 과정은 토큰 방식으로 인증을 검증하여 인증된 상태를 어떻게 유지할 것인지를 구현해야 한다.
지금까지 인증을 저장하는 과정을 따로 구현하지 않았기 때문에, 현 상황에서는 `HttpSession`에 인증 객체를 저장하게 되고, SessionId를 통해 클라이언트는 계속해서 인증을 유지해 나갈 수 있게 된다. 하지만 다음 포스팅에서는 세션 기반이 아니라 토큰 기반으로 변경하기 위해 인증 객체를 어떻게 SecurityContext에 저장할 것인지, 언제 저장할 것인지 고민을 해봐야 한다.
이번 포스팅의 전체 소스 코드는 깃허브를 참고해주세요.