Spring Security+JWT 토큰 인증 플로우

dev-jjun·2023년 8월 13일
1

Server

목록 보기
15/33

Spring Security의 전체적인 동작

[Spring Security] 스프링 시큐리티의 간략한 구조와 시큐리티 인증, 인가용 필터 구현하기.

JwtAuthenticationFilter (UsernamePasswordAuthenticationFilter 역할)

Filter *(Servlet Filter Interface)*는 클라이언트의 요청을 가장 먼저 받는 대상으로, 서블릿 호출은 이 필터를 거쳐야 이루어지게 된다.

요청 스레드가 서블릿 컨테이너에 도착하기 전에 수행됨으로써, 필터가 사용자의 요청 정보에 대해 검증하고 필요에 따라 데이터를 추가하거나 변조할 수 있다.

🌱 필터 사용 예시
  • 오류 처리 기능
  • 인코딩 처리 기능
  • 웹 보안 관련 기능
  • 데이터 압축 및 변환 기능
  • 요청이나 응답에 대한 로그
  • 로그인 여부, 권한 검사 같은 인증 기능 → @UserId 와 같은 Resolver 없이도 헤더에 실려서 오는 토큰 요청이 Spring Security의 필터를 통해 인증 절차를 거치게 된다.

GenericFilterBean을 상속받아서 구현

  • 예시 코드
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends GenericFilterBean {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    
            // 1. Request Header에서 JWT 토큰 추출
            String token = resolveToken((HttpServletRequest) request);
    
            // 2. validateToken으로 토큰의 유효성 검사
            if (token != null && jwtTokenProvider.validateToken(token)) {
                // 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
    
            chain.doFilter(request, response);
        }
    
        // Request Header에서 토큰 정보를 추출하기 위한 메서드
        private String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
    
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
                return bearerToken.substring(6);
            }
            return null;
        }
    }

Filter의 본래 기능을 확장하여 Spring의 설정정보를 가져올 수 있도록 만들어진 필터 → 매 서블릿마다 호출된다.

서블릿은 사용자의 요청을 받으면 서블릿을 생성해 메모리에 저장해두고, 같은 클라이언트의 요청을 받으면 생성해둔 서블릿 객체를 재활용하여 요청을 처리한다. 이때 서블릿 간 실행 흐름을 동적으로 전달하는 역할의 디스패치(Dispatch)가 이루어지는 상황에서 문제가 발생할 수 있다.

Spring Security에서 인증과 접근 제어 기능이 이 Filter로 구현되는데, 이러한 인증과 접근 제어는 RequestDispatcher 클래스에 의해 다른 서블릿으로 dispatch 되고, 이때 이동할 서블릿에 도착하기 전 다시 한 번 filter chain을 거쳐 여러 번 인증처리가 수행되는 문제가 있다.

OncePerRequestFilter을 상속받아서 구현

  • 예시 코드
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        @Override
        protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws IOException, ServletException {
            try {
                final String token = getJwtFromRequest(request);
    
                if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token) == VALID_JWT) {
                    Long userId = jwtTokenProvider.getUserFromJwt(token);
                    UserAuthentication authentication = new UserAuthentication(userId, null, null);
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception exception) {
                log.error("error : ", exception);
            }
    
            filterChain.doFilter(request, response);
        }
    
        private String getJwtFromRequest(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
                return bearerToken.substring("Bearer ".length());
            }
            return null;
        }
    }

사용자의 1번의 요청 당 딱 1번만 실행되도록 만들어진 필터 → 모든 서블릿에 일관된 요청 처리가 가능하다.

Spring Security의 인증 필터가 사용자의 인증 처리를 여러 번 수행하는 것은 불필요하므로, 1번의 인증 처리만 이루어지도록 하는 것이 좋다.

🙋🏻‍♂️ 공통점은?

대상을 필터로 등록해주는 인터페이스이다!

UsernamePasswordAuthenticationToken

인증을 처리하는 UsernameAuthenticationFilter 필터를 확장하여 인증 양식을 제출하는 역할을 한다. username, password 두 필드를 기본적으로 필요로 하며, **/login** url에 응답한다.

AuthenticationEntryPoint

인증 예외가 발생하면 AuthenticationException 을 던지게 되는데, 이때 AuthenticationEntryPoint 인터페이스로 예외처리에 대한 커스텀이 가능하다.

🧐 인가 예외는 또 달라요!

인가 예외가 발생하면 AccessDeniedException 을 던지며, 이는 AccessDeniedHandler 인터페이스로 커스텀이 가능하다

*인가와 인증에 대한 구현을 명확하게 분리하여 커스텀하는 것이 중요하다!

AuthenticationManager

인증을 처리하는 UsernameAuthenticationFilter 필터로부터 인증 처리를 지시받는 첫 번째 클래스

username, password 를 저장한 Authentication 인증 객체를 전달받으면, 각 인증 처리 요건(Form / Oauth / Remember-Me)에 맞는 AuthenticationProvider를 찾아 인증 처리를 위임한다. 인증을 완료한 후 자신에게로 되돌아오면, 자신을 호출했던 Filter에게 결과를 넘겨준다.

*Remember Me란? 세션이 만료되고 브라우저를 끈 후에도 사용자를 기억하는 기능 (쿠키 기반)

AuthenticationProvider

로그인 정보(username(id), password )에 대한 실질적인 검증 로직이 이루어지는 클래스로, authenticate() 메서드가 그 역할을 수행한다.

  • ID 검증: DB(userRepository)에서 회원 조회 → UserDetails 객체 반환
  • Password 검증: UserDetails의 비밀번호와 입력한 비밀번호의 일치 여부 검사 (matches() 메서드)
  • 추가 검증

🔎 코드로 살펴보자

  1. 로그인 요청 (username, password) 로그인 요청

    Http 요청이 들어오면 Filter를 가장 먼저 거치게 되는데, 커스텀한 JwtAuthenticationFilter 에서 헤더에 실려온 Access Token으로 유저 정보를 추출하여, doFilter()를 딱 한번만 실행시키며 FilterChain을 순차적으로 수행하게 됨으로써 사용자의 인증 처리가 이루어진다.

    // SecurityConfig.java
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
    
    // JwtAuthenticationFilter.java
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, **FilterChain filterChain**) throws ServletException, IOException {
    
        try {
            // 1. Request Header에서 JWT 토큰 추출
            **final String token = resolveToken(request);**   // request에서 JWT 토큰 정보 추출하기
    
            // 2. validateToken으로 토큰의 유효성 검사
            if (StringUtils.hasText(token) && jwtTokenProvider.validateAccessToken(token)) {
                Long userId = jwtTokenProvider.getUserFromJwt(token);
                // 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            log.error("error: ", e);
        }
    
        filterChain.doFilter(request, response);
    }

    FilterChain

    단일 HTTP 요청을 처리하는 전형적인 레이어로, 여러 개의 Filter들이 사슬처럼 서로 연결되어 연쇄적으로 동작하게 된다.

    컨테이너는 서블릿과 여러 필터로 구성된 FilterChain을 만들어 요청된 URI를 기반으로 HTTP 요청을 처리한다. Filter들은 이 FilterChain 안에 있을 때 효력을 발휘하게 된다.

    특징

    • 임의 순서 지정
      • Filter 타입의 @Beans@Order를 붙이기

      • Ordered 구현

        FilterRegistrationBean 의 일부가 됨

    • 필터 간 체인을 형성하여 요청을 처리할 때 필터가 나머지 체인을 거부하게 할 수 있음
    • 요청과 응답 수정 가능 by 다운스트림 필터 + 서블릿
  2. UsernamePasswordAuthenticationFilter에서 요청으로 온 정보를 이용해 검증 작업 시작

  3. DI로 받은 AuthenticationManager 객체의 authenticate() 메서드를 통해 로그인 시도

    • 인증 전 Authentication 객체 생성 (only username + password의 정보만)
    /**
     * 인증 처리 메소드
     * - UsernamePasswordAuthenticationToken 사용 (UsernamePasswordAuthenticationFilter와 동일)
     *
     * AbstractAuthenticationProcessingFilter(부모)의 getAuthenticationManager() 로 AuthenticationManager 객체를 반환 받은 후,
     * authenticate()의 파라미터로 UsernamePasswordAuthenticationToken 객체를 넣고 인증 처리
     * (여기서 AuthenticationManager 객체는 ProviderManager -> SecurityConfig 에서 설정)
     *
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
    
        if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
        }
    
        /**
         * StreamUtils -> request의 messageBody (JSON 형식) 반환
         *   - messageBody -> objectMapper.readValue() ; Map 변환
         *     (Key : JSON의 키인 'email', 'password')
         */
        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
    
        /**
         * Map의 Key('email', 'password') -> 키에 해당하는 값인 이메일, 패스워드 추출하여
         * UsernamePasswordAuthenticationToken의 파라미터 principal, credentials 에 대입
         */
        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
        String email = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);
    
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);   // principal과 credentials 전달
    
        return this.getAuthenticationManager().authenticate(authRequest);
    }
  4. UserDetailsService를 상속받은 PrincipalDetailsService 클래스가 호출되고 loadUserByUsername() 메서드 실행 → 우리 서비스에서는 소셜 로그인만 구현했으므로 생략

    • 회원의 Repository에 접근하여 회원을 찾고, 필요에 따라 검증 후 UserDetails를 상속받은 PrincipalDetails 객체에 회원을 담아서 반환
    • Spring Security의 PasswordEncoder로 비밀번호 검증 및 암호화
    • 검증이 완료되면 Authentication 반환
  5. UsernamePasswordAuthenticationFilter에서 Authentication 인증 객체를 반환받고 이를 SecurityContext에 저장

  6. 검증 완료 시 JWT 발급

    → 자체 로그인 없이 소셜 로그인으로 인증된 유저만 존재한다면, 소셜 서비스 자체에서 발급하는 Authorization Token으로 인증 후에 사용자 정보를 불러올 수 있는 로그인 완료 상태가 되었을 때 JWT 토큰 발급이 이루어진다.

참고 자료

https://ws-pace.tistory.com/250

https://taetaetae.github.io/2020/04/06/spring-boot-filter/

https://soojae.tistory.com/55

profile
서버 개발자를 꿈꾸며 성장하는 쭌입니다 😽

0개의 댓글