spring security를 사용하여 로그인 로직을 개발하던 중
마주한 문제였다.
문제 상황은 다음과 같다.
데이터베이스에 없는 유저가 로그인을 시도 했을 때 UsernameNotFoundException이 아닌
BadCredentialsException으로 예외처리가 됐다.
그래서 왜 그런지 코드를 살펴보았다.
[userDetailService]
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.getAuthInfo(username);
if (user == null) {
throw new UsernameNotFoundException("UsernameNotFoundException");
}
user.setDetail(user);
return new CustomUserDetails(user);
}
[CustomAuthenticationProvider]
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
CustomUserDetails userDetails = (CustomUserDetails) userDetailService.loadUserByUsername(username);
//비밀번호가 일치하지 않으면 예외를 던짐
if(!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("BadCredentialsException");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
return authenticationToken;
}
코드의 일부분만 가지고 왔다.
로그인 과정을 살펴보면
1. spring security에서 AuthenticationManagerBuilder에 커스텀한 AuthenticationProvider를 등록하였고 따라서 DaoAuthenticationProvider가 등록해준 커스텀 된 UserDetailsService를 가지고 있게 된다.
// 사용자 정보를 가져오고 인증
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(customAuthenticationProvider);
}
2. DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속받고 있는데
DaoAuthenticationProvider에서 loadUserByUsername을 통해 가져온 loadedUser가 null이라면 AbstractUserDetailsAuthenticationProvider로 예외를 던진다.
[DaoAuthenticationProvider]
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
/**
*... 생략
**/
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
3. AbstractUserDetailsAuthenticationProvider에서는
try-catch로 실행한
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); 에서 예외를 받았으므로
boolean타입의 hideUserNotFoundException을 확인하여 BadCredentialsException을 내려준다.
**AbstractUserDetailsAuthenticationProvider에서 hideUserNotFoundException의 기본 값
: protected boolean hideUserNotFoundExceptions = true;
[AbstractUserDetailsAuthenticationProvider]
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {
/**
* 생략
**/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
결론적으로 더 강력한 보안을 위해 아이디나 비밀번호 혹은 유저가 없을 때도 BadCredential을 내려주는 것이 맞았다!
그런데 꼼꼼하게 분기처리를 해보고 싶었기 때문에 위에서 확인했던
hideUserNotFoundException의 설정을 바꿔줌으로써
에러 분기처리를 해결할 수 있었다.
[spring security]
// usernameNotFoundException 활성화
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
authenticationProvider.setHideUserNotFoundExceptions(false);
return authenticationProvider;
}