글 작성자: beaniejoy

Photo by FLY:D on Unsplash


저번 게시글에서 Spring Security의 기본 인증 방식인 form login 인증 방식의 간략한 프로세스를 정리해보았습니다.

Spring Security의 인증 프로세스 정리(form login 인증 방식)

 

Spring Security의 인증 프로세스 정리(form login 인증 방식)

개인 프로젝트 하면서 적용했던 내용을 정리해보는 글입니다. 이번 내용은 로그인(인증) 프로세스를 순수 Spring Security만을 가지고 개발해보았던 내용을 두 번에 나누어 정리해보고자 합니다. 이

beaniejoy.tistory.com

해당 게시글에서 정리했던 내용들을 토대로 이번에는 api 인증 방식에 대해서 Spring Security를 이용해 구현해보고자 합니다.

api 방식의 인증 프로세스를 구현하기 위해서는 기존의 Spring Security form login 방식으로는 적용할 수 없습니다. 왜냐하면 이전 게시글에서도 정리했듯이 UsernamePasswordAuthenticationFilterusername, password라는 parameter를 받아서 처리하는 부분은 api 방식과 맞지 않기 때문입니다. request body에 JSON 형태로 담겨져서 들어온 요청 내용에 대해서 처리할 수 있는 Filter를 따로 만드는 것이 중요합니다.

 

📌 1. API 전용 인증 Filter 적용(ApiAuthenticationFilter)

이제 API 전용 custom한 인증 Filter를 만들어서 Spring Security 설정에다가 적용해보겠습니다. Filter는 UsernamePasswordAuthenticationFilter의 추상클래스인 AbstractAuthenticationProcessingFilter를 상속받아서 구현하면 됩니다.

class ApiAuthenticationFilter(requestMatcher: AntPathRequestMatcher) :
    AbstractAuthenticationProcessingFilter(requestMatcher) {

    private val objectMapper = jacksonObjectMapper()

    override fun attemptAuthentication(
        request: HttpServletRequest,
        response: HttpServletResponse,
    ): Authentication {
        if (isValidRequest(request).not()) {
            throw IllegalStateException("request is not supported. check request method and content-type")
        }

        val signInRequest = objectMapper.readValue(request.reader, SignInRequest::class.java)
        request.setAttribute("email", signInRequest.email)

        val token = signInRequest.let {
            if (StringUtils.hasText(it.email).not() || StringUtils.hasText(it.password).not()) {
                throw IllegalArgumentException("Email & Password are not empty!!")
            }

            UsernamePasswordAuthenticationToken(it.email, it.password)
        }

        return authenticationManager.authenticate(token)
    }

    private fun isValidRequest(request: HttpServletRequest): Boolean {
        if (request.method != HttpMethod.POST.name) {
            return false
        }

        if (request.contentType != MediaType.APPLICATION_JSON_VALUE) {
            return false
        }

        return true
    }
}

attemptAuthentication 메소드를 overriding해서 원하는 방식으로 구현하면 됩니다. 위에 작성한 코드는 예시기 때문에 참고만 하시면 될 것 같습니다. 위 코드 프로세스를 살펴보면 다음과 같습니다.

  • isValidRequest
    request의 method가 POST 방식이면서 Content-Type이 JSON 타입인지 체크합니다. 만약 해당 내용과 맞지않는 request라면 에러를 반환하게 됩니다.
  • ObjectMapper
    kotlin module을 적용한 ObjectMapper를 이용해 request의 body내용을 원하는 형식의 DTO 클래스로 변환합니다.
  • Authentication 객체로 변환
    email, password내용이 잘 들어왔는지 체크한 후 UsernamePasswordAuthenticationToken 객체를 생성합니다.
  • AuthenticationManager
    authenticate 진행을 위해 Manager에 이전 단계에서 생성했던 Authentication 객체를 담아 다음 인증과정을 진행합니다.

여기서 중요한 것은 어떤 api와 해당 Filter를 매핑할 것인지를 설정해야합니다.
UsernamePasswordAuthenticationFilter도 어떤 url과 매핑할 것인지 설정하는 부분이 있는데요.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER 
    	= new AntPathRequestMatcher("/login", "POST");
            
    //...
}

위와 같이 POST method의 /login url에 대해서 해당 Filter가 받겠다는 설정내용이 있습니다.
제가 구현한 ApiAuthenticationFilter도 이와 같이 method와 인증 api url을 매핑하는 설정이 필요합니다.
이 부분은 생성자로 AntPathRequestMatcher를 인자로 받아서 설정할 예정인데요. Spring Security 설정 부분에서 다시 언급하도록 하겠습니다.

그 다음은 실제 인증을 수행할 AuthenticationProvider를 구현해서 custom Provider를 생성해보겠습니다.

 

📌 2. Custom Provider 적용(ApiAuthenticationProvider)

AuthenticationProvider는 실제 인증 처리를 담당하는 곳입니다. Spring Security에서는 AbstractUserDetailsAuthenticationProvider에서 AuthenticationProvider를 구현해서 인증작업을 처리했었는데요. 이부분도 따로 custom Provider를 구현해서 적용해보고자 합니다.

@Component
class ApiAuthenticationProvider(
    private val userDetailsService: UserDetailsService,
    private val passwordEncoder: PasswordEncoder
) : AuthenticationProvider {
    companion object: KLogging()

    override fun authenticate(authentication: Authentication): Authentication {
        logger.info { "start authentication" }

        val email = authentication.name
        val password = authentication.credentials as String?

        val user = userDetailsService.loadUserByUsername(email)
        if (!passwordEncoder.matches(password, user.password)) {
            throw BadCredentialsException("Input password does not match stored password")
        }

        logger.info { "User password ${user.password}" }

        // password null로 반환
        return UsernamePasswordAuthenticationToken(user, null, user.authorities)
    }

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

해당 클래스의 프로세스는 다음과 같습니다.

  • email, password
    Filter에서 생성했던 Authentication 객체를 전달받아서 email, password를 추출합니다.
  • UserDetailsService
    여기서 email 값을 가지고 User 객체를 조회합니다. 이 부분은 DB 연동을 통해 따로 구현 클래스를 적용해보고자 합니다.
    (아래에서 자세히 설명하겠습니다.)
  • password 비교
    PasswordEncoder 객체를 통해서 조회해온 User 객체의 password 값과 request로 들어온 password 값을 비교합니다.
    여기서 불일치가 발생하면 Spring Security 내용대로 BadCredentialsException 예외를 던지도록 하였습니다.
  • Authentication 객체 반환
    유저 조회 및 패스워드 검증 과정을 모두 통과하게 되면 해당 User객체를 Authetication 객체(UsernamePasswordAuthenticationToken)에 담아 최종 반환하게 됩니다.
    여기서 반환할 때 credentials부분에 password 값을 담아야하는데 이미 인증 완료된 객체에 대해서는 null 값을 넣어줍니다. 이미 인증 성공된 객체이기 때문에 불필요하게 password 값을 담을 필요가 없기 때문입니다.

추가로 supports 메소드도 구현을 해야합니다. AuthenticationManager(ProviderManager)에서 여러 Provider들 중 인증 처리할 수 있는 Authentication 타입인지 아닌지 체크해서 수행가능한 타입이면 해당 Provider에게 인증처리를 넘기는 로직이 있는데요. 이를 위해 supports 메소드에 처리할 수 있는 Authentication type을 지정해야 합니다.

UsernamePasswordAuthenticationToken객체를 custom Filter에서 반환하고 있기 때문에 여기서는 해당 클래스 타입을 지원하도록 설정하였습니다.

 

📌 3. UserDetailsService 구현

위의 Provider에서 인증 과정을 수행하기 위해 User를 받아와야 하는데 조건에 맞는 User 객체를 조회해서 반환해주는 곳이 UserDetailsService 인터페이스입니다. 해당 인터페이스의 구현체를 직접 만들어서 적용해보았습니다.

@Component("userDetailsService")
class UserDetailsServiceImpl(
    private val memberRepository: MemberRepository
) : UserDetailsService {
    companion object: KLogging()

    override fun loadUserByUsername(email: String): UserDetails {
        return memberRepository.findByEmail(email)?.let {
            logger.info { "[LOAD MEMBER] email: ${it.email}, role: ${it.roleType}, activated: ${it.activated}" }
            createSecurityUser(it)
        } ?: throw UsernameNotFoundException(email)
    }

    private fun createSecurityUser(member: Member): User {
        if (member.activated.not()) {
            throw MemberDeactivatedException(member.email)
        }

        return User(
            /* username = */ member.email,
            /* password = */ member.password,
            /* authorities = */ listOf(SimpleGrantedAuthority(member.roleType.name))
        )
    }
}
  • loadUserByUsername
    메소드를 구현해야 하는데요. 여기서 JPA를 통해 DB와 연동하여 테이블에 있는 유저 데이터를 조회하고 UserDetails 구현체인 User 객체를 생성해서 반환해줍니다.
  • UsernameNotFoundException
    DB 테이블에서 email로 조건 조회했을 때 데이터가 없는 경우 던지는 예외입니다.
  • MemberDeactivatedException
    Member Entity에서 activated를 통해 활성화 여부를 체크하게 됩니다.
    비활성화(탈퇴 등의 이유로)된 맴버에 대해서 인증 시도시 따로 만들어둔 예외를 던지게 했습니다.
  • SimpleGrantedAuthority
    조회한 Member가 부여받은 권한에 대한 정보를 User 객체에 전달하기 위해 생성하는 객체입니다.
    여기서 여러 개 부여 받은 권한을 list로 전달할 수 도 있고 혹은 권한별로 레벨이 존재해서 상위, 하위로 인가 구분이 가능하다면 하나의 권한만 전달할 수도 있을 것 같습니다.

    (이 부분은 권한에 대한 인가 부분을 구현하는 사람이 어떻게 구현하느냐에 따라 나뉠 것 같네요.)

 

📌 4. AuthenticationSuccessHandler, AuthenticationFailureHandler 구현

위의 내용들은 api 방식의 인증 프로세스 내용을 적용한 것들이었습니다. 이번에는 인증 과정을 모두 통과한 경우와 인증을 통과하지 못한 경우에 대해서 처리해주는 handler를 구현해보고자 합니다.

위에서 언급했던 AbstractAuthenticationProcessingFilter에서는 요청이 들어왔을 때 request body에서 인증을 위한 email, password를 추출하고 Manager에게 인증 위임을 하고 그 결과(인증 성공시 Authentication 객체 반환)를 받아오게 됩니다.

인증 성공하게 되면 successfulAuthentication를 통해 인증 성공에 대한 handler를 호출하게 됩니다.
인증 실패시 unsuccessfulAuthentication를 통해 인증 실패에 대한 handler를 호출하게 됩니다.

 

🔖 4-1. SuccessHandler custom 구현체

@Component
class ApiAuthenticationSuccessHandler :AuthenticationSuccessHandler {
    private val objectMapper = jacksonObjectMapper()

    companion object: KLogging()

    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication,
    ) {
        val user = authentication.principal as User
        logger.info { "[AUTH SUCCESS] email: ${user.username}, authorities: ${user.authorities}" }

        response.apply {
            this.status = HttpStatus.OK.value()
            this.contentType = MediaType.APPLICATION_JSON_VALUE
        }

        objectMapper.writeValue(
            response.writer,
            AuthenticationResult(
                email = user.username,
                authorities = user.authorities,
                msg = "authentication success"
            )
        )
    }
}
  • onAuthenticationSuccess
    인증 성공시 해당 메소드 인자로 인증 성공한 Authentication 객체를 받게 됩니다. api 방식에 맞게 인증 성공한 객체를 가지고 json 형태로 반환하면 됩니다.
  • response
    성공하였기 때문에 200 OK 응답코드를 설정하고 응답데이터에 대한 타입으로 application/json을 설정합니다.
  • 응답데이터
    ObjectMapper를 통해서 인증 성공한 Authentication 객체 정보를 json 형태로 변환해서 response PrintWriter에 담아주면 됩니다.

 

🔖 4-2. FailureHandler custom 구현체

위의 SuccessHandler와 거의 유사합니다.

@Component
class ApiAuthenticationFailureHandler : AuthenticationFailureHandler {
    private val objectMapper = jacksonObjectMapper()

    companion object : KLogging()

    override fun onAuthenticationFailure(
        request: HttpServletRequest,
        response: HttpServletResponse,
        exception: AuthenticationException,
    ) {
        val email = request.getAttribute("email") as String
        logger.error { "[AUTH FAILED] $email" }

        response.apply {
            this.status = HttpStatus.UNAUTHORIZED.value()
            this.contentType = MediaType.APPLICATION_JSON_VALUE
        }

        objectMapper.writeValue(
            response.writer,
            AuthenticationResult(
                email = email,
                msg = "authentication failed"
            )
        )
    }
}
  • onAuthenticationFailure
    인증 실패시 해당 메소드로 인증 과정 중 발생한 예외를 인자로 받게 됩니다.
  • request > email
    ApiAuthenticationFilter에서 request에 parameter로 담았던 email 정보를 꺼냅니다. 사실 이부분은 응답데이터로 어떤 email 계정이 인증 실패하였는지 보내주기 위해서 Filter에서 parameter로 담았는데 좋은 방법은 아닌 것 같아보입니다.
    (Exception 정보에 email 내용을 담아서 FailureHandler에 전달하는 방법도 있을 것 같네요.)
  • 응답데이터
    위의 인증 성공 때와 마찬가지로 email 내용과 에러메시지를 가지고 json 형태로 변환해서 응답으로 내보내면 됩니다.

 

📌 5. SecurityConfig

API 방식의 인증 프로세스 관련해서 구현할만한 내용들은 모두 마쳤습니다. 이제 위의 구현체들을 SpringSecurity에 등록을 해야 하는데요. 이번에는 SecurityConfig 설정 내용에 위의 custom 구현체들을 한 번 적용해보겠습니다.

@Configuration
@EnableWebSecurity
class SecurityConfig {
    //...
    
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf().disable()

            .authorizeRequests()
            .antMatchers("/auth/members/sign-up").permitAll()
            .antMatchers("/test").hasRole("USER")   // 임시 인가 테스트용
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(apiAuthenticationFilter(), UsernamePasswordAuthenticationFilter::class.java)

            .build()
    }
    
    //...
}
  • csrf().disable()
    Spring Security를 적용하면 default로 csrf 기능이 활성화됩니다. CsrfFilter에서 request header에 담겨져온 csrf token 값을 가지고 오게 되는데요. api 방식에서는 csrf token값을 따로 request에서 설정하지 않습니다. token 값이 없이 filter에 들어오게 되면 AccessDeniedHandler에 의해 403 Forbidden호출하게 되는데요. 이를 방지하기 위해 비활성화처리를 해야 합니다.
  • 허용 api
    회원가입 api는 인증받지 않은 사용자에게도 열어두어야 하기 때문에 /auth/members/sign-up url은 permitAll 처리합니다.
  • addFilterBefore
    custom filter를 기존 인증 처리를 담당했던 UsernamePasswordAuthenticationFilter보다 앞에 필터가 등록되도록 합니다.
@Bean
fun apiAuthenticationFilter(): ApiAuthenticationFilter {
    return ApiAuthenticationFilter(
        AntPathRequestMatcher("/auth/authenticate", HttpMethod.POST.name)
    ).apply {
        this.setAuthenticationManager(authenticationConfiguration.authenticationManager)
        this.setAuthenticationSuccessHandler(apiAuthenticationSuccessHandler)
        this.setAuthenticationFailureHandler(apiAuthenticationFailureHandler)
    }
}

위에서 만들어 두었던 API 전용 인증 필터(ApiAuthenticationFilter)를 Bean 객체로 설정하는 부분입니다.

  • AuthenticationManager
    이 부분이 저한테는 아직까지 어려운 부분인데요. 애플리케이션이 실행될 때 default로 설정된 Security 관련 인증 필터들이 등록이 되면서 AuthenticationManager 객체를 주입시켜주는데요. Custom Filter를 등록할 때에는 이 부분을 따로 주입시켜줘야 합니다.
    그리고 Spring Security v5.7.x 버전부터 Security 설정방식이 바뀌었습니다. 이후 버전부터는 AuthenticationConfiguration에서 가져온 AuthenticationManager를 custom filter에 주입시켜줍니다.
  • Success, Failure Handler 등록
    apiAuthenticationFilter bean 등록할 때 위에서 따로 만들어 두었던 success, failure handler도 같이 주입시켜줍니다.
  • AntPathRequestMatcher 주입
    어떤 요청 api url, method와 해당 apiAuthenticationFilter를 매핑할 것인지 설정하는 부분입니다.
    저는 POST /auth/authenticate api를 인증 api로 설정하였습니다.

 

📌 6. Reference

위의 내용을 적용했던 제 개인 프로젝트 pull request 내용에 대해서 링크걸어두겠습니다. (참고 Github Repo PR)

 

Spring Security를 사용한 인증 프로세스 적용 by beaniejoy · Pull Request #18 · beaniejoy/dongne-cafe-api

내용 인증 전용 프로젝트 분리를 위한 multi module project로 전환 service-api, account-api, common 분리 작업 진행 Spring Security만을 사용한 api 인증프로세스 적용 api 인증을 위한 Custom Filter 적용 AuthenticationPr

github.com