— 1 min read
요즘 처음으로 실무에서 Spring security로 인증 관련 로직을 구현하고 있다. 기본 로그인, 회원가입, 권한설정 등은 튜토리얼이나 문서가 많아 다소 쉽게 해결했는데, 별거 아닌듯한 부가적인 요구사항을 덧붙이는게 오히려 더 적용이 어려웠다. 그 중 로그인을 하다가 비밀번호를 여러번 잘못 치면 ‘n회연속 입력 오류’ 같은 에러 처리를 하기까지의 삽질 과정과 나름의 해결방법을 정리해봤다.
먼저 Rest API로 개발하기 위해서 만들어둔 AuthenticationFailureHandler
인터페이스의 구현체를 이용하려고 했다.
그래서 다음 코드와 같은 로직을 짜기 시작했다.
1@Component2class CustomLoginFailureHandler(3 val userService: UserService4) : AuthenticationFailureHandler {56 override fun onAuthenticationFailure(7 request: HttpServletRequest?,8 response: HttpServletResponse?,9 exception: AuthenticationException?10 ) {1112 val readString = request.reader.lines().collect(Collectors.joining())13 val jsonRequest: Map<String, String> = objectMapper.readValue(readString)14 val username = jsonRequest.getOrDefault(USERNAME_PARAM, "")15 val count = userService.incrementFailCount(username)16 // ....17}
(우선 count는 db컬럼에 저장한다고 가정한다. count를 증가시킬 유저를 알기 위해서 request를 읽어서 username을 받아 service레이어로 넘기는 코드)
request.reader.lines().collect(Collectors.joining())
수행 결과가 빈 값이 나왔다. 이렇게 읽는게 아닌가? 하고 다른 방법을 찾아봤지만 똑같았다...AbstractAuthenticationProcessingFilter
인터페이스의 구현체에서 인증을 위해 request를 읽는 로직이 있었고, 한번 읽은 로직을 다시 읽을 수 없기 때문이었다.ServletFilter 레이어에서 request의 InputStream을 읽고 다시 읽을 수 있게 InputStream을 생성해서 돌려주는 HttpServletRequestWrapper를 구현하자
request를 최초로 읽는 구현체와 FailureHandler가 request의 username을 공유하기만 하면 되지 않을까? FailureHandler에서 SecurityContextHolder에 있는 username을 읽어오자
인증을 수행하는 ProviderManager에서 실패로직 처리 일부를 맡기자 ✅
ProviderManager를 오버라이딩 하고, 여기서 인증 실패 시 (비밀번호 match되지 않을 때) 로그인실패 횟수를 증가시킨다. 실패횟수가 초과하면 AuthenticationException을 throw하는 부분에서 exception메시지와 함께 throw → FailureHandler에서 exception을 적절한 형태로 write해서 내보낸다.
좀더 자세히 설명하면
정확히는 AbstractUserDetailsAuthenticationProvider
를 상속한 AuthenticationProvider 클래스를 만들었고, 내부 구현은 디폴트로 적용되는 DaoAuthenticationProvider
를 거의 그대로 따르고 인증 실패 부분에 원하는 로직만 끼워 넣었다.
1@Throws(AuthenticationException::class)2override fun additionalAuthenticationChecks(3 userDetails: UserDetails,4 authentication: UsernamePasswordAuthenticationToken5) {6 if (authentication.credentials == null) {7 logger.debug("Failed to authenticate since no credentials provided")8 throw BadCredentialsException(9 messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")10 )11 }12 val presentedPassword = authentication.credentials.toString()13 if (!this.passwordEncoder.matches(presentedPassword, userDetails.password)) {14 logger.debug("Failed to authenticate since password does not match stored value")15 handleAuthenticationFail(userDetails.username) // 새로 추가 16 throw BadCredentialsException(17 messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")18 )19 }20}2122private fun handleAuthenticationFail(username: String) {23 // 구현24 // 여기서 count를 증가시키고, count가 시간 내에 실패하면 BadCredentialsException을 던졌다25}
그리고 response는 FailureHandler에서 처리.
예를 들어
1httpStatus: 40123{4 "message": "5회 이상 로그인 실패"5}
를 리턴하고 싶다면 아래와같이 FailureHandler를 구현한다.
1response?.contentType = MediaType.APPLICATION_JSON_VALUE2response?.status = HttpStatus.UNAUTHORIZED.value()34response?.writer?.append(5 objectMapper().writeValueAsString(6 FailureResponse(exception?.message)7 )8)910data class FailureResponse(val message: String?)
이 글은 업데이트 될 수 있다. 잘 동작하는 걸 확인했지만, 더 쉬운 방법이 있을텐데 하는 아쉬움이 남아있다.
full code는 나중에 좀더 상세한 튜토리얼을 쓴다면 공유 :)
** 더 좋은 방법을 아신다면 댓글을 남겨주세요🙏
https://meetup.toast.com/posts/44
https://gregor77.github.io/2021/05/18/spring-security-03/