Spring Security JWT 인증 구현 하기 2편

이영재·2024년 10월 28일
0

Spring

목록 보기
9/15

1. Spring Filter 동작

Spring에서 필터(Filter) 는 서블릿 기반 애플리케이션에서 요청(Request)와 응답(Response)를 가로채어 특정 작업을 수행할 수 있다.

  • 필터는 서블릿 스펙에 정의된 기능으로, 요청(Request)이 컨트롤러에 도달하기 전에 또는 응답(Response)이 클라이언트에 전달되기 전에 중간에 개입하여 특정 로직을 수행할 수 있는 기능을 제공
  • 필터는 요청이 전달되는 전체 체인에서 동작할 수 있으며, 로깅, 인증, 권한 부여, 데이터 압축, 캐싱, CORS 처리와 같은 다양한 용도로 사용

1.1 Interceptor VS Filter

Filter는 애플리케이션에서 요청과 응답을 가로채어 특정 작업을 수행할 수 있다. 하지만 Interceptor 또한 비슷한 동작을 하는 것 같다. 그럼 이 둘의 차이는 무엇이고 언제 사용할까?

Interceptor : Spring MVC 레벨에서 작동하며, 요청이 **DispatcherServlet을 통해 컨트롤러로 라우팅되기 전에 동작한다.

  • 컨트롤러를 호출하기 전/후 또는 응답을 반환하기 직전에 특정 작업을 수행한다.

Spring 필터와 인터셉터의 차이

기능Spring 필터(Filter)Spring 인터셉터(Interceptor)
적용 범위서블릿(Servlet) 레벨Spring MVC 레벨
작동 위치서블릿 컨테이너가 요청을 처리하기 전에 작동DispatcherServlet이 요청을 컨트롤러로 보내기 전후에 작동
대상모든 서블릿 요청 (정적 자원 포함)Spring MVC 요청 (동적 자원)
전후 처리가능 (요청 전후에 로직 추가 가능)가능 (preHandle, postHandle, afterCompletion 메서드)
순서 결정필터 체인의 등록 순서에 따름Order 인터페이스나 WebMvcConfigurer 설정
주요 사용 사례보안, 로깅, CORS 설정, 데이터 압축인증, 권한 체크, 로깅, 데이터 전처리

쉽게 생각해서 Filter는 큰 문이고 Interceptor는 그 안에 작은 문이라고 생각 하면 된다.

이제 Filter를 알았으니 JWTFilter를 만들어 보자.

2.JWTFilter 구현

package com.team29.ArtifactV2.global.security.jwt;

import ...

@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {
            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("access")) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // username, role 값을 획득
        String username = jwtUtil.getUsername(accessToken);
        String role = jwtUtil.getRole(accessToken);

        Member userEntity = new Member();
        userEntity.setUsername(username);
        userEntity.setRole(role);
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
                customUserDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}
  • OncePerRequestFilter를 상속받아 필터(특정 작업을 수행하는)를 작성했다.

기능을 설명하기 전에 JWTFilter가 상속 받는 OncePerRequestFilter 는 뭘까?

2.1 OncePerRequestFilter

스프링에서 디스패처 서블릿이 서블릿 컨테이너 앞에 모든 요청을 컨트롤러에 전달한다. 서블릿은 요청마다 서블릿을 생성하여 메모리에 저장한 뒤 같은 클라이언트의 요청이 들어올 경우 생성해둔 서블릿 객체를 재활용한다.
그런데 만약 서블릿이 다른 서블릿으로 dispatch하게 되면, 다른 서블릿 앞단에서 filter chain을 한번 더 거치게 된다. 이 차이때문에 OncePerRequestFilter를 사용한다.

쉽게 말해서, 클라이언트가 보기에 새로운 요청이 발생하지는 않지만, 서버 내부적으로는 요청이 다른 서블릿으로 전달(포워딩)된다. 이때, 서블릿 컨테이너는 이를 새로운 서블릿 요청으로 간주하기 때문에, 서블릿 앞단의 필터 체인이 다시 적용될 수 있다. 클라이언트가 새롭게 요청을 보내는 것과 유사하게 작동하게 되는 셈이다.

2.2 OncePerRequestFilter vs GenericFilterBean

  • OncePerRequestFilter 추상 클래스를 살펴 보면 GenericFilterBean를 상속 받고 있다. 이는 GenericFilterBean의 모든 기능을 그대로 활용하면서 추가적인 기능을 제공한다는 의미이다.
  • doFilterInternal() 메서드로 개발자가 필터의 핵심 로직을 구현할 수 있도록 해주며, 중복 실행 방지 로직은 그 외부에서 처리된다.

OncePerRequestFilter와 GenericFilterBean 차이

기능/구조Filter (서블릿 API)GenericFilterBean (Spring)OncePerRequestFilter (Spring Security)
상속 구조최상위 인터페이스Filter를 구현한 추상 클래스GenericFilterBean을 상속한 추상 클래스
주요 역할서블릿 필터 구현의 기본 인터페이스Spring 빈으로 쉽게 등록할 수 있는 서블릿 필터한 요청당 한 번만 실행되는 특수 서블릿 필터
Spring 통합없음, 서블릿 컨테이너에 의해 직접 관리Spring 빈으로 관리 가능, DI와 라이프사이클 활용Spring Security와 통합된 필터, 중복 실행 방지
구현 방식doFilter 메서드 구현 필요doFilter 메서드를 간단히 오버라이드doFilterInternal 메서드를 오버라이드하여 구현
중복 실행 방지없음, 직접 로직 구현 필요없음, 직접 로직 구현 필요있음, 내부적으로 동일 요청에 대해 한 번만 실행 보장
적용 사례일반적인 서블릿 필터Spring 서비스와의 통합이 필요한 필터JWT 인증, 보안 검증, 로그인 등 보안 필터에 사용

2.3 doFilterInternal() 메서드 구현

    // 헤더에서 access키에 담긴 토큰을 꺼냄
    String accessToken = request.getHeader("access");

    // 토큰이 없다면 다음 필터로 넘김
    if (accessToken == null) {
        filterChain.doFilter(request, response);
        return;
    }
  • 요청 헤더에서 "access"라는 키로 JWT 토큰을 추출
  • 토큰이 없는 경우, 클라이언트가 인증되지 않은 요청을 보낸 것이므로, 다음 필터로 요청을 넘김

    ❓토큰이 없는 경우 다음 필터로 넘기는 이유
    JWTFilter에서 토큰이 없는 경우 요청을 다음 필터로 넘긴다고 해서 모든 요청이 검증 없이 통과하는 것은 아니다. 인증이 필요한 요청은 JWTFilter에서 SecurityContext에 인증 정보가 설정되지 않았다면, Spring Security 설정에 따라 접근이 차단됩니다.

    ex) 로그인, 회원가입, Home 화면과 같은 요청은 인증이 필요하지 않기 때문에, 토큰 없이도 접근이 가능해야 한다.

토큰 만료 여부 확인

    try {
        jwtUtil.isExpired(accessToken);
    } catch (ExpiredJwtException e) {
        //response body
        PrintWriter writer = response.getWriter();
        writer.print("access token expired");

        //response status code
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return;
    }
  • jwtUtil.isExpired(accessToken);를 호출하여 토큰이 만료되었는지 확인
  • 만료된 토큰이라면, ExpiredJwtException이 발생하며, 이 경우 클라이언트에게 401 Unauthorized 응답을 반환하고 필터 체인을 종료
  • 응답 본문에 "access token expired"라는 메시지를 작성해, 토큰 만료 사실 전달

토큰의 유형(category) 확인

String category = jwtUtil.getCategory(accessToken);

if (!category.equals("access")) {
    PrintWriter writer = response.getWriter();
    writer.print("invalid access token");
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}
  • jwtUtil.getCategory(accessToken);를 사용하여 토큰의 유형을 확인.
  • 이 필터는 "access" 토큰만 처리해야 하므로, "access"가 아닌 토큰이라면 인증 실패로 간주하고 401 Unauthorized 응답을 반환.
  • 이 과정을 통해, 다른 종류의 토큰이 허용되지 않도록 함.

토큰에서 사용자 정보(username, role) 추출

String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
  • JWT 토큰에서 사용자 이름과 역할(권한)을 추출
  • jwtUtil 객체는 JWT를 파싱하여 토큰의 페이로드에서 이러한 정보를 가져온다.

사용자 정보로 인증 객체 생성 및 설정

Member userEntity = new Member();
userEntity.setUsername(username);
userEntity.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
        customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
  • Member 객체를 생성하여, 추출된 사용자 이름과 역할을 설정
  • 이 정보를 기반으로 CustomUserDetails 객체를 만들고,
  • UsernamePasswordAuthenticationToken을 생성하여 Spring Security의 인증 객체로 사용
  • SecurityContextHolder.getContext().setAuthentication(authToken);를 호출하여 SecurityContext에 인증 정보를 저장
  • 이로써 해당 요청이 인증된 사용자로 간주되어, 컨트롤러나 다른 필터에서 인증된 상태로 처리된다.

filterChain.doFilter(request, response);

  • 마지막으로, 다음 필터로 요청을 전달.
  • JWT 토큰이 유효하고, 사용자 인증 정보가 설정된 상태에서 요청이 계속 진행.
  • 이 단계가 없다면, 요청이 더 이상 진행되지 않고 멈추게 된다.

다음으로 JWT 생성하는 JWTUtil에 대해서 알아보자

0개의 댓글

관련 채용 정보