본문 바로가기
PlayGround/마실가실 리팩토링

[1년 후 마실가실] Spring Security - UserDetailsService

by HJ0216 2024. 11. 29.

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

Security는 끝날 때까지 끝난 게 아닙니다..

지금은 그간 놓치고 있던 오류 처리를 해주고 있습니다.

 

회원 탈퇴 기능을 추가하면서 User 테이블에 IsUsed를 선언해서 flag 처럼 사용하고 있습니다.

이제 탈퇴한 회원은 조회가 되면 안되기에 검색 로직에서 IsUsed를 추가해주고 로그인을 테스트해본 것이 가까워진 Security와 멀어진 계기가 되었죠..

 

멀어진 Security와 다시 가까워지기 위한 노력을 정리해 보았습니다..

 

자.. 이제 문제가 뭐였느냐..

회원 정보가 없을 때, 제가 Throw한 Exception과 전달받은 Exception이 달랐습니다..

 

준 적이 없는데 누군가는 받은 셈..

 

LoginFilter.java
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request,
      HttpServletResponse response) throws AuthenticationException {

    try {
      LoginRequestDTO loginRequestDto = objectMapper.readValue(request.getInputStream(),
          LoginRequestDTO.class);

      String email = loginRequestDto.getEmail();
      String password = loginRequestDto.getPassword();

      UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
          email, password, null);

      return authenticationManager.authenticate(authToken);

    } catch (IOException e) {
      log.error("Failed to parse authentication request body {}", e);

      throw new BusinessException(INVALID_CREDENTIALS);
    }
  }

  @Override
  protected void unsuccessfulAuthentication(HttpServletRequest request,
      HttpServletResponse response,
      AuthenticationException failed) {
    log.error("Login failed: {}", failed.getMessage());

    throw new BusinessException(CHECK_LOGIN_ID_OR_PASSWORD);
  }
}

로그인의 경우에는 Controller를 사용하지 않고, UsernamePasswordAuthenticationFilter를 상속받아 LoginFilter를 구현해서 사용하고 있습니다.

 

코드에 명시적으로 적혀있진 않지만, AuthenticationManager의 authenticate()를 호출하면 회원 및 비밀번호 검증이 이뤄지게 됩니다.

(관련 내용은 이 포스팅에 적어놨었죠.. 저도 다시 찾아봤습니다^,,^)

간단히 다시 정리해보자면, 다음과 같습니다.

  * login Filter는 AuthenticationManager를 사용하여 인증 작업을 수행

  * AuthenticationManager는 interface이기 때문에 실제로는 AuthenticationProvider가 인증 작업을 수행

  * AuthenticationProvider는 인증 작업을 수행할 때, UserDetailsService를 호출

    * 저의 경우에는 인터페이스인 UserDetailsService를 구현한 CustomUserDetailsService가 호출되었습니다.

  * UserDetailsService는 loadUserByUsername()를 통해 사용자 정보를 조회한 후, 사용자 인증 정보인 UserDetails를 반환

  * AuthenticationProvider는 반환된 UserDetails 객체와 클라이언트가 제공한 비밀번호를 비교하여 인증을 수행

 

따라서 실제 사용자가 존재하는지를 확인하는 로직은 UserDetailsService를 구현한 CustomUserDetailsService에 있기에 이곳을 검토해봐야 합니다.

 

CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(email).orElseThrow(() ->
        new UsernameNotFoundException("Authentication failed"));

    return UserPrinciple.builder()
                        .user(user)
                        .build();
  }
}

지금은 회원이 없을 때, UsernameNotFoundException이지만, 기존에는 Custom한 Exception인 BusinessException을 Throw했습니다.

 

그렇다면 Filter에서 Exception 컨트롤을 했으므로, BusinessException이 뜨면서 종료가 되면 문제가 없는데..

unsuccessfulAuthentication를 타면서 Exception을 디버깅해보면, 저는 보낸 적 없는 InternalAuthenticationServiceException가 나옵니다. 콩 심은데 콩 나고 팥 심은데 팥 난다는 속담이 의미가 없어지는.. 그런 상황인 거죠..

 

자, 이제 동작 방식을 이해해 볼 차례입니다.

 

UserDetailsService 클래스의 loadUserByUsername 메서드에서 회원이 존재하지 않을 때 발생하는 BusinessException은 Spring Security에서 InternalAuthenticationServiceException로 래핑이 됩니다. 래핑을 원치 않을 경우, DaoAuthenticationProvider를 커스터마이징하거나 Spring Security에서 인증 실패로 처리하기 위한 표준 예외인  UsernameNotFoundException를 던져서 BadCredentialsException로 받아 처리하는 방법이 있습니다.

 

필터를 추가해 복잡하게 처리하기보다는 표준 방식을 따르기로 하였습니다.

이 때, 걱정을 했던 부분은 회원이 가입은 했지만 비밀번호가 틀린 경우와 회원이 가입을 하지 않았을 때 구분하는 것이었습니다.

하지만, 요새 로그인을 시도해보시면 아실 수도 있겠지만 비밀번호를 틀렸다는 말을 잘 안해줍니다. 아이디나 비밀번호를 다시 확인하라고 합니다. Spring Security에서는 보안상 의도한 것이라하여, 저도 구분하지 않기로 했습니다.

 

정리하자면, LoginFilter에서 인증 과정을 거치기 위해 UserDetailsService을 구현한 CustomUserDetailsService에서 Custom Exception을 발생시켰지만, 내부적으로 InternalAuthenticationServiceException로 래핑되는 문제가 있었습니다. Filter를 추가하여 복잡성을 올리기보다는 표준 방식을 따르기로 했고, 이에 따라 Custom한 Exception보다는 UsernameNotFoundException을 발생시켜 unsuccessfulAuthentication에서 BadCredentialsException을 처리하는 방식으로 수정하였습니다.

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

Chat GPT

https://hj0216.tistory.com/963

 

[1년 후 마실가실] Spring Security + JWT - LoginFilter

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.  요즘 제 표정입니다. Spring Security Exception 처리에 막혀서, 이건.. 이건.. 학습이 필요하다는 생각이 간절히 들어

hj0216.tistory.com

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details-service.html#page-title

 

UserDetailsService :: Spring Security

This is only used if the AuthenticationManagerBuilder has not been populated and no AuthenticationProviderBean is defined.

docs.spring.io