[Spring] Spring Security + JWT를 통한 간단한 로그인 과정

hgh1472·2024년 7월 27일

스프링

목록 보기
4/8

[POST] /member/login

사용자는 /member/login 을 통해 로그인을 시도한다.

올바른 아이디와 패스워드를 입력했다면, JwtToken을 받는다.

MemberService.java

public JwtToken login(LoginRequestDto loginRequestDto) {
    Optional<Member> findMember = memberRepository.findById(loginRequestDto.getId());
    if (findMember.isEmpty() || !isValidPassword(findMember.get().getPassword(), loginRequestDto.getPassword())) {
        throw new InvalidLoginException("잘못된 아이디 또는 패스워드입니다.");
    }

    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequestDto.getId(), loginRequestDto.getPassword());

    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

    JwtToken jwtToken = jwtUtil.generateToken(authentication);
    return jwtToken;
}

[POST] /member/myPage

사용자는 /member/myPage 를 통해 자신의 정보를 확인하려고 시도한다.

이 때 SecurityConfig를 통해 해당 API는 ROLE_MEMBER인 경우에만 통과하도록 설정하였다.

login을 통해서 발급받은 token을 Postman을 통해서 헤더에 넣어서 확인한다.

결국 우리가 원하는건 /login 을 통해 로그인을 하고, /member/myPage 에서는 로그인 정보를 통해 자신의 정보를 확인하고, 로그인 되지 않으면 접근할 수 없게 해야 한다. 이 과정이 전반적으로 어떻게 흘러가는지 살펴보자.

로그인 과정

사용자가 Id와 Password를 작성하여 로그인을 시도한다. 즉, 우리는 id와 password를 받게되고 이 정보를 통해 이 데이터가 존재하는지 확인해야 한다. 존재한다면 JwtToken을 생성하여 응답으로 보내준다.

올바른 데이터를 받으면 Service에서 다음 로직을 실행한다.

public JwtToken login(LoginRequestDto loginRequestDto) {
    try {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequestDto.getId(), loginRequestDto.getPassword());
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        JwtToken jwtToken = jwtUtil.generateToken(authentication);
        return jwtToken;
    } catch (Exception e) {
        log.error("[MemberService.login] Error {}", e.getClass());
        throw new InvalidLoginException("유효하지 않은 아이디 또는 패스워드입니다.");
    }
}
  • 로그인 요청 DTO를 통해 UsernamePasswordAuthenticationToken 생성
  • authenticate(authenticationToken)
    • authenticationToken을 인증한다.
    • CustomUserDetailsService의 loadUserByUsername 메서드를 사용하여 확인
@Override
public UserDetails loadUserByUsername(String id) {
    Member findMember = memberRepository.findById(id).orElseThrow(() -> new NotFoundMemberException("해당하는 멤버가 존재하지 않습니다."));
    return new CustomUserDetails(
            findMember.getId(),
            findMember.getPassword(),
            findMember.getRole(),
            findMember.getEmail(),
            findMember.getName());
}

이때, 로그인 요청 아이디나 비밀번호가 잘못되면 NotFoundMemberException이 발생할 것이라 예상하여 MemberService의 login 로직에서 NotFoundMemberException를 catch하도록 작성하였는데 이 예외가 발생하지 않았다.

  • 잘못된 아이디 전송 ⇒ InternalAuthenticationServiceException
  • 잘못된 비밀번호 전송 ⇒ BadCredentialsException

아이디 오류

잘못된 아이디를 전송하면 loadUserByUsername에서 NotFoundMemberException이 발생할 것 같은데 왜 InternalAuthenticationServiceException가 발생하는걸까?

Exception의 스택 트레이스를 살펴보면 DaoAuthenticationProvider의 retrieveUser 메소드에서 발생했다.

위 코드는 DaoAuthenticationProvider의 retrieveUser 메서드 내용이다.

try문 내 코드를 보면 loadUserByUsername 메소드를 실행하고 Exception을 catch한다. 즉, CustomUserDetailsService에서 던진 NotFoundMemberExceptionInternalAuthenticationServiceException으로 바꿔서 던진 것이다.

비밀번호 오류

비밀번호 오류가 발생하면 BadCredentialsException이 발생한다. 위처럼 예외의 스택 트레이스를 찾아가보면, DaoAuthenticationProvider의 additionalAuthenticationChecks 메소드에서 발생했다.

코드를 살펴보면 passwordEncoder를 통해 패스워드를 비교한다. 즉, 입력된 패스워드가 잘못된다면 여기서 BadCredentialException이 발생하는 것이다.

따라서 로그인 과정에서 발생하는 예외를 Exception으로 넓게 catch하고, 잘못된 로그인 시도이므로 InvalidLoginException으로 바꿔서 예외를 throw했다.

UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationTokenAbstractAuthenticationToken을 상속한다.

AbstractAuthenticationToken을 살펴보자.

AbstractAuthenticationToken을 Authentication을 상속한다. 즉, UsernamePasswordAuthenticationToken은 Authentication 객체이다.

위의 login 로직에서 사용한 UsernamePasswordAuthenticationToken 생성자는 다음과 같다.

생성자를 확인해보면 setAutenticated(false) 를 실행한다. 결국 아직 인증되지 않은 객체라는 뜻이다.

결국 authenticationManagerBuilder.getObject().authenticate(authenticationToken) 이 코드는 아직 인증되지 않은 UsernamePasswordAuthenticationToken을 인증한다는 뜻이 된다. 그 후 인증을 거쳐 생성된 Authentication 객체를 이용해 토큰을 생성해서 반환한다.

그 후 인증을 거치면 Authentication 객체를 통해서 JWT Token을 생성한다.

JWT Token을 가지고 접근

SecurityConfig에 /member/myPage는 ROLE_MEMBER 권한을 가지고 있어야 접근이 가능하도록 설정했다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .httpBasic(httpBasic -> httpBasic.disable())
            .csrf(csrf -> csrf.disable())
            .formLogin(form -> form.disable())
            .authorizeHttpRequests((authorize) -> authorize
                    .requestMatchers("/member/signUp", "/login").permitAll()
                    .requestMatchers("/member/myPage").hasRole("MEMBER")
            )
            .exceptionHandling((handler) -> handler.authenticationEntryPoint(customAuthenticationEntryPoint)
                    .accessDeniedHandler(customAccessDeniedHandler))
            .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, customUserDetailsService), UsernamePasswordAuthenticationFilter.class)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
}

그렇다면 헤더에 JWT 토큰을 넣어서 보냈을 때 어떻게 인증 과정이 이루어지는 것일까?

구현한 JwtAuthenticationFilter를 확인해보자.

JwtAuthenticationFilter.java

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            String token = resolveToken((HttpServletRequest) servletRequest);
            if (token == null) {
                servletRequest.setAttribute("exception", new InvalidJwtTokenException("토큰이 존재하지 않습니다."));
            }
            else if (token != null && jwtUtil.validateToken(token)) {
                String memberId = jwtUtil.getMemberId(token);
                UserDetails userDetails = customUserDetailsService.loadUserByUsername(memberId);
                log.info("Member Id in JwtAuthenticationFilter : {}", memberId);
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        } catch (Exception e) {
            // 예외가 발생하면 해당 예외를 그대로 request에 담아둔다.
            log.error("CATCH ERROR IN JwtAuthenticationFilter {}", e.getClass());
            servletRequest.setAttribute("exception", e);
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer"))
            return bearerToken.substring(7);
        return null;
    }
}

JwtAuthenticationFilter는 SecurityConfig에서 설정한대로 UsernamePasswordAuthenticationFilter 전에 실행된다. UsernamePasswordAuthenticationFilter에서는 Authentication 객체가 Context에 있는지 검사한다.

  1. resolveToken((HttpServletRequest) servletRequest)
    • request에 Authorization 헤더가 존재하고 Bearer로 시작한다면 Token 반환
  2. (token ≠ null&& jwtUtil.validateToken(token)) ⇒ 토큰이 존재하고 유효한지 확인
    • 토큰으로부터 멤버의 Id 추출
    • 추출한 멤버 Id를 통해 UserDetails 생성
    • 생성한 UserDetails를 통해 UsernamePasswordAuthenticationToken을 생성
    • UsernamePasswordAuthenticationToken를 SecurityContext에 저장

가볍게 JWT를 통한 로그인 과정을 살펴보았다. 이 과정에서 /member/myPage 를 단순히 테스트용으로 권한이 존재하면 test를 반환하도록 하였는데, 실제로 로그인한 Member의 정보를 얻고싶다면 @AuthenticationPrincipal 어노테이션을 이용하자.

0개의 댓글