1. 문제점
SpringSecurity/JWT를 사용한, 인증/인가 과정에서 발생하는 Exception을 처리하고 에러를 표준화하는 과정에서 문제가 발생했다.
JWT 로그인 구현 중 UsernameNotFoundException 처리가 안 되는 현상이 발생한 것
중요한 것은
우리가 원하는 것은 로그인 실패 응답을 두 가지 케이스로 나누는 것.
- "존재하지 않는 회원의 이메일입니다."
- "비밀번호가 일치하지 않습니다."
하지만, 포스트맨을 통해 확인한 결과


모두, 잘못된 자격증명으로 답변이 온다...
2. 원인파악
코드를 순서대로 따라가보자.
UsernamePasswordAuthenticationFilter를 확장하여 커스터마이징한, LoginFilter를 살펴보자.
여기서, 실패한 경우
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
ErrorStatus errorStatus;
if (failed instanceof UsernameNotFoundException) {
errorStatus = ErrorStatus._ACCOUNT_NOT_FOUND;
} else if (failed instanceof BadCredentialsException) {
errorStatus = ErrorStatus._BAD_CREDENTIALS;
} else {
errorStatus = ErrorStatus._AUTHENTICATION_FAILED;
}
throw new AuthHandler(errorStatus);
}
분면, 에러처리를 잘 해주었고,
이메일을 검증하는 부분에서는
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("올바르지 않은 email"));
return new PrincipalDetails(user);
}
}
UsernameNotFoundException을 잘 던지도록 해주었다!
상식적으로라면,
이메일이 틀렸을 경우 : _ACCOUNT_NOT_FOUND
비밀번호가 틀렸을 경우 : _BAD_CREDENTIALS 의 에러메시지가 담겨야 하는데...
// 인증관련
_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_0", "인증에 실패했습니다."),
_BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH401_1", "잘못된 자격증명."),
_ACCOUNT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH401_4", "계정을 찾을 수 없습니다.");
1. UserDetailService 구현체에서 loadUserByUsername()을 통해 이메일 검증을 하다가 실패하게 되면, UsernameNotFoundException을 던진다.
2. DaoAuthenticationProvider에서의 additionalAuthenticationChecks()에서 비밀번호 검증이 실패하면 BadCredentialsException을 던진다.
왜 구분이 안되는 것일까...........?
코드를 차분히 따라가보면 원인을 알 수 있다. (근본 해결책)
결국 인증이 일어나는 부분은 " authenticationManager.authenticate "
그렇다면 AuthenticationManager란 무엇일까?

authenticate 라는 메서드 하나만 담고 있는 인터페이스이다.
즉 이 인터페이스의 구현체는 Authentication 요청을 처리한다.
그럼 구현체는 무엇일까?
ProviderManager은 가장 흔한 AuthenticationManager의 구현체이다.
이 구현체의 역할은 적합한 인증 제공자 <인증 처리가 가능한 AuthenticationProvider>를 찾아주는 것이다.

그리고 해당 인증 처리를 할 수 있는 객체인 AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드가 실행된다.


authenticate() 메서드 중 retrieveUser 메서드가 실행되고, 여기서 UsernameNotFoundException이 발생한다면 캐치하여 처리하는 구조로 되어있다.
retrieveUser() 를 봐보자.

예상대로, loadUserByUsername() 메서드가 실행되며 UsernameNotFoundException을 발생시킨다.
다시 나와보자.
- 중요한 것은 이부분

retrieveUser() 메서드가 실행되며 발생한 UsernameNotFoundException에 의해 catch 부분에서 잡히게 된다.
문제는 이때 hideUserNotFoundException 값이 true로 최종적으로 new BadCredentialsException을 던지게 된다는 것이다.
3. 해결방법
문제점은 찾았다.
그렇다면 여러가지 방법이 있는데,
1. daoAuthenticationProvider class의 hideUserNotFoundException(false) 강제로 설정하기
2. authenticate() 메서드 실행 전에 별도의 응답 보내기
2번 방법은 인증 전에 DB를 한 번더 조회함으로써 이를 처리하는 방식이지만,
나는 1번 방법을 선택했다.
일단 이런 내부 코드를 커스터마이징 할 때는, "왜?" 이렇게 짜놓았는지 이해하고 변경해야할 필요가 있다.
(우리보다 훨씬똑똑한 사람들이 짜놓은 코드다... 나중에 막 변경하다가 '아..이래서 이렇게 해놓았구나..' 할 때가 온다..)
Spring Security에서 hideUserNotFoundExceptions 값을 기본적으로 true로 설정한 이유는 보안상의 이유 때문!
UsernameNotFoundException을 클라이언트에게 노출하지 않음으로써,
잠재적인 공격자가 유효한 사용자 이름을 추측하는 것을 어렵게 만든다.
그러나, 특정 애플리케이션에서는 사용자 경험을 개선하기 위해 이를 false로 설정해야 할 수도 있다.
이렇게 하면 더 명확한 에러 메시지를 사용자에게 제공할 수 있다.
하지만, 다시 고려해보아야 할 것은
비밀번호와 사용자 이름이 모두 정확하지 않은 경우에도 동일한 에러 메시지를 제공하여 보안을 강화하는 전략을 포기하게 된다는 것이다.
시용자 경험을 개선할 수 있는 또 다른 접근 방식은 사용자 이름이 없는 경우와 비밀번호가 틀린 경우를 동일한 에러 메시지로 처리하는 것이다.
이를 테면,
BadCredentialsException을 그냥 아예 "Invalid username or password"라는 메시지를 주는 것이다.
그래서 간혹 어떤 사이트에서는 아이디나 비밀번호가 틀렸을 때 이를 구분하지 않고, 단일 에러 메시지를 클라이언트에게 제공하는 사이트도 다수 보인다.

어쨌든... 구분을 하고 싶다면!!
1번 방식으로 일단 해결해보자.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
ErrorStatus errorStatus;
if (failed instanceof UsernameNotFoundException) {
errorStatus = ErrorStatus._ACCOUNT_NOT_FOUND;
} else if (failed instanceof BadCredentialsException) {
errorStatus = ErrorStatus._BAD_CREDENTIALS;
} else {
errorStatus = ErrorStatus._AUTHENTICATION_FAILED;
}
throw new AuthHandler(errorStatus);
}
- CustomDaoAuthenticationProvider으로 확장하여 커스터마이징해준다. 할 일은, 그냥 false로 설정해주는 것 뿐.
public class CustomDaoAuthenticationProvider extends DaoAuthenticationProvider {
public CustomDaoAuthenticationProvider() {
// hideUserNotFoundExceptions 값을 false로 설정
this.setHideUserNotFoundExceptions(false);
}
}
- 그리고 Security에 CustomDaoAuthenticationProvider 빈등록해주면 끝
@Bean
public CustomDaoAuthenticationProvider customDaoAuthenticationProvider() {
CustomDaoAuthenticationProvider provider = new CustomDaoAuthenticationProvider();
provider.setUserDetailsService(principalDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
- 그리고, 오류 메시지를 수정해준다..
_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_0", "인증에 실패했습니다."),
_BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH401_1", "잘못된 자격 증명 : 비밀번호가 틀렸습니다."),
_ACCOUNT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH401_4", "잘못된 자격 증명 : 계정을 찾을 수 없습니다.");
4. 결과
1. 이메일 오류

2. 비밀번호 오류

LockedException, DisabledException, AuthenticationServiceException 등의 디테일한 오류 메시지도 써주면 좋다!
1. 문제점
SpringSecurity/JWT를 사용한, 인증/인가 과정에서 발생하는 Exception을 처리하고 에러를 표준화하는 과정에서 문제가 발생했다.
JWT 로그인 구현 중 UsernameNotFoundException 처리가 안 되는 현상이 발생한 것
중요한 것은
우리가 원하는 것은 로그인 실패 응답을 두 가지 케이스로 나누는 것.
- "존재하지 않는 회원의 이메일입니다."
- "비밀번호가 일치하지 않습니다."
하지만, 포스트맨을 통해 확인한 결과


모두, 잘못된 자격증명으로 답변이 온다...
2. 원인파악
코드를 순서대로 따라가보자.
UsernamePasswordAuthenticationFilter를 확장하여 커스터마이징한, LoginFilter를 살펴보자.
여기서, 실패한 경우
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
ErrorStatus errorStatus;
if (failed instanceof UsernameNotFoundException) {
errorStatus = ErrorStatus._ACCOUNT_NOT_FOUND;
} else if (failed instanceof BadCredentialsException) {
errorStatus = ErrorStatus._BAD_CREDENTIALS;
} else {
errorStatus = ErrorStatus._AUTHENTICATION_FAILED;
}
throw new AuthHandler(errorStatus);
}
분면, 에러처리를 잘 해주었고,
이메일을 검증하는 부분에서는
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("올바르지 않은 email"));
return new PrincipalDetails(user);
}
}
UsernameNotFoundException을 잘 던지도록 해주었다!
상식적으로라면,
이메일이 틀렸을 경우 : _ACCOUNT_NOT_FOUND
비밀번호가 틀렸을 경우 : _BAD_CREDENTIALS 의 에러메시지가 담겨야 하는데...
// 인증관련
_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_0", "인증에 실패했습니다."),
_BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH401_1", "잘못된 자격증명."),
_ACCOUNT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH401_4", "계정을 찾을 수 없습니다.");
1. UserDetailService 구현체에서 loadUserByUsername()을 통해 이메일 검증을 하다가 실패하게 되면, UsernameNotFoundException을 던진다.
2. DaoAuthenticationProvider에서의 additionalAuthenticationChecks()에서 비밀번호 검증이 실패하면 BadCredentialsException을 던진다.
왜 구분이 안되는 것일까...........?
코드를 차분히 따라가보면 원인을 알 수 있다. (근본 해결책)
결국 인증이 일어나는 부분은 " authenticationManager.authenticate "
그렇다면 AuthenticationManager란 무엇일까?

authenticate 라는 메서드 하나만 담고 있는 인터페이스이다.
즉 이 인터페이스의 구현체는 Authentication 요청을 처리한다.
그럼 구현체는 무엇일까?
ProviderManager은 가장 흔한 AuthenticationManager의 구현체이다.
이 구현체의 역할은 적합한 인증 제공자 <인증 처리가 가능한 AuthenticationProvider>를 찾아주는 것이다.

그리고 해당 인증 처리를 할 수 있는 객체인 AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드가 실행된다.


authenticate() 메서드 중 retrieveUser 메서드가 실행되고, 여기서 UsernameNotFoundException이 발생한다면 캐치하여 처리하는 구조로 되어있다.
retrieveUser() 를 봐보자.

예상대로, loadUserByUsername() 메서드가 실행되며 UsernameNotFoundException을 발생시킨다.
다시 나와보자.
- 중요한 것은 이부분

retrieveUser() 메서드가 실행되며 발생한 UsernameNotFoundException에 의해 catch 부분에서 잡히게 된다.
문제는 이때 hideUserNotFoundException 값이 true로 최종적으로 new BadCredentialsException을 던지게 된다는 것이다.
3. 해결방법
문제점은 찾았다.
그렇다면 여러가지 방법이 있는데,
1. daoAuthenticationProvider class의 hideUserNotFoundException(false) 강제로 설정하기
2. authenticate() 메서드 실행 전에 별도의 응답 보내기
2번 방법은 인증 전에 DB를 한 번더 조회함으로써 이를 처리하는 방식이지만,
나는 1번 방법을 선택했다.
일단 이런 내부 코드를 커스터마이징 할 때는, "왜?" 이렇게 짜놓았는지 이해하고 변경해야할 필요가 있다.
(우리보다 훨씬똑똑한 사람들이 짜놓은 코드다... 나중에 막 변경하다가 '아..이래서 이렇게 해놓았구나..' 할 때가 온다..)
Spring Security에서 hideUserNotFoundExceptions 값을 기본적으로 true로 설정한 이유는 보안상의 이유 때문!
UsernameNotFoundException을 클라이언트에게 노출하지 않음으로써,
잠재적인 공격자가 유효한 사용자 이름을 추측하는 것을 어렵게 만든다.
그러나, 특정 애플리케이션에서는 사용자 경험을 개선하기 위해 이를 false로 설정해야 할 수도 있다.
이렇게 하면 더 명확한 에러 메시지를 사용자에게 제공할 수 있다.
하지만, 다시 고려해보아야 할 것은
비밀번호와 사용자 이름이 모두 정확하지 않은 경우에도 동일한 에러 메시지를 제공하여 보안을 강화하는 전략을 포기하게 된다는 것이다.
시용자 경험을 개선할 수 있는 또 다른 접근 방식은 사용자 이름이 없는 경우와 비밀번호가 틀린 경우를 동일한 에러 메시지로 처리하는 것이다.
이를 테면,
BadCredentialsException을 그냥 아예 "Invalid username or password"라는 메시지를 주는 것이다.
그래서 간혹 어떤 사이트에서는 아이디나 비밀번호가 틀렸을 때 이를 구분하지 않고, 단일 에러 메시지를 클라이언트에게 제공하는 사이트도 다수 보인다.

어쨌든... 구분을 하고 싶다면!!
1번 방식으로 일단 해결해보자.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
ErrorStatus errorStatus;
if (failed instanceof UsernameNotFoundException) {
errorStatus = ErrorStatus._ACCOUNT_NOT_FOUND;
} else if (failed instanceof BadCredentialsException) {
errorStatus = ErrorStatus._BAD_CREDENTIALS;
} else {
errorStatus = ErrorStatus._AUTHENTICATION_FAILED;
}
throw new AuthHandler(errorStatus);
}
- CustomDaoAuthenticationProvider으로 확장하여 커스터마이징해준다. 할 일은, 그냥 false로 설정해주는 것 뿐.
public class CustomDaoAuthenticationProvider extends DaoAuthenticationProvider {
public CustomDaoAuthenticationProvider() {
// hideUserNotFoundExceptions 값을 false로 설정
this.setHideUserNotFoundExceptions(false);
}
}
- 그리고 Security에 CustomDaoAuthenticationProvider 빈등록해주면 끝
@Bean
public CustomDaoAuthenticationProvider customDaoAuthenticationProvider() {
CustomDaoAuthenticationProvider provider = new CustomDaoAuthenticationProvider();
provider.setUserDetailsService(principalDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
- 그리고, 오류 메시지를 수정해준다..
_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH401_0", "인증에 실패했습니다."),
_BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH401_1", "잘못된 자격 증명 : 비밀번호가 틀렸습니다."),
_ACCOUNT_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH401_4", "잘못된 자격 증명 : 계정을 찾을 수 없습니다.");
4. 결과
1. 이메일 오류

2. 비밀번호 오류

LockedException, DisabledException, AuthenticationServiceException 등의 디테일한 오류 메시지도 써주면 좋다!