Filter와 ArgumentResolver를 이용한 JWT 인증/인가 구현

KDG: First things first!·2024년 9월 29일
0

Spring

목록 보기
5/5


JWT를 통한 인증/인가에 대해 이해가 부족하다면 이전 포스팅인 웹 애플리케이션 인증 방식 2 : JWT에 대해 읽고 오시는 것을 권장합니다.



JWT에서 서블릿 필터(Servlet Filter)의 역할

서블릿 필터에 대한 이해가 부족하다면 이전 포스팅인
서블릿 필터(Servlet Filter)를 읽어보고 오는 것을 추천한다.


JWT 인증에서 서블릿 필터(Servlet Filter)클라이언트가 요청을 보낼 때, 이 요청을 가로채서 JWT를 확인하고 인증 및 권한 처리를 수행하는 역할을 하며 이를 통해 인증이 필요한 자원에 대한 접근을 제어할 수 있다.



JWT 인증에서의 필터의 흐름 및 작동 과정


  • 1. 요청 가로채기
    클라이언트가 서버로 HTTP 요청을 보낼 때, 서블릿 필터는 이 요청을 먼저 가로챈 후 일반적으로 클라이언트의 HTTP 요청 헤더에 포함된 JWT를 확인한다. 가장 흔히 사용하는 헤더는 Authorization이며, 토큰은 "Bearer <JWT(토큰)>" 형식으로 포함된다.

  • 2. JWT 검증
    필터는 헤더 Authorization에서 JWT를 추출하여 해당 토큰이 유효한지 검증한다. 검증해야 하는 사항은 다음과 같다:

    • 서명(Signature) 검증: 시크릿 키를 통해 만들어진 JWT의 서명이 유효한지 확인하여 토큰이 변조되지 않았는지 확인한다.
    • 유효 기간 확인: JWT는 일반적으로 만료 기간이 설정되어 있는데 해당 토큰이 만료되었는지 확인한다.
    • 클레임(Claims) 확인: 토큰의 페이로드(payload)에 포함된 정보(사용자 ID, 이메일, 권한 등)를 확인한다.

  • 3. 인증 정보 설정
    검증이 성공하면, JWT에 포함된 사용자 정보를 기반으로 인증된 사용자로 설정해야 한다. Spring Security 환경에서는 이 과정을 통해 Authentication 객체를 생성하고, 이를 SecurityContextHolder에 저장한다. 이렇게 하면 해당 요청에 대해 이후의 모든 보안 컨텍스트에서 인증된 사용자로 간주되어 인증을 통과할 수 있다.

  • 4. 요청 처리
    필터가 JWT를 성공적으로 검증하고 인증을 완료하면, 요청을 다음 필터나 서블릿으로 전달하여 정상적으로 처리가 진행되게 한다. 만약 인증이 실패하면 요청은 거부되며, 403 Forbidden 등의 HTTP 상태 코드가 반환된다.



JWT와 Argument Resolver


Argment Resolver란

ArgumentResolver는 Spring MVC에서 컨트롤러 메서드의 파라미터를 해석하고 바인딩하는 데 사용되는 인터페이스이다.
스프링에서 컨트롤러 메서드를 호출할 때, 클라이언트 요청에서 전달된 데이터를 메서드의 파라미터로 변환하여 바인딩하는 역할을 수행한다.


스프링은 컨트롤러 메서드의 파라미터에 다양한 데이터를 자동으로 바인딩한다. 예를 들어, @RequestParam, @PathVariable, @RequestBody 등의 어노테이션을 사용하면 요청 데이터를 메서드 파라미터로 쉽게 받을 수 있는데 이때, 각각의 데이터 타입에 맞는 ArgumentResolver가 그 변환을 처리하여 바인딩하는 역할을 한다.



JWT를 통한 인증에서 Argument Resolver의 역할


JWT를 통한 인증 과정에서 Argument Resolver를 사용하면 컨트롤러의 메서드에 요청 데이터를 자동으로 바인딩할 수 있다.
특히, 인증된 사용자 정보를 컨트롤러 메서드의 파라미터로 간편하게 주입할 수 있다는 장점이 있다.

요약하자면 Argument Resolver는 컨트롤러의 메서드에서 요청 파라미터를 처리하는 과정에서 JWT로부터 인증된 사용자 정보를 추출하여 해당 정보를 컨트롤러 메서드의 파라미터로 주입시킨다.

즉, Filter를 통과한 JWT의 유저 정보를 Argument Resolver를 통하여 객체지향적이면서도 효율적으로 꺼내서 유저 정보를 필요로 하는 Controller의 각 메서드에 전달한다는 이야기이다.



Argument Resolver 동작 방식


  • 1. JWT 인증 후 사용자 정보 추출 :
    필터를 거쳐 JWT 토큰이 유효한지 검증된 후, JWT에 포함된 사용자 정보(예: 사용자 ID, 권한 등) 정보를 기반으로 사용자를 식별하거나 권한을 확인할 수 있다.

  • 2. 컨트롤러 메서드에서 사용자 정보 접근 :
    인증된 사용자 정보는 보통 컨트롤러 메서드에서 사용된다.

    예를 들어, 로그인한 사용자의 ID를 바탕으로 특정 데이터를 조회하고 싶다면 컨트롤러 메서드의 파라미터로 사용자 객체를 바로 주입하는 것이 편리하다. 이때, Argument Resolver가 사용자 정보를 컨트롤러 메서드에 자동으로 주입하는 역할을 한다.



필터와 Argument Resolver를 이용한 JWT 사용 구현


0. application.yml에서 Secret Key 설정

(다른 설정 정보)
    .
    .
    .

jwt:
  secret:
    key: ${JWT_SECRET_KEY} # JWT_SECRET_KEY 환경 변수 IDE에서 등록하기
    
    .
    .
    .

1. JWT 생성 및 파싱 클래스 생성


@Slf4j
@Component
// JWT (JSON Web Token)를 생성하고 파싱하는 데 필요한 유틸리티 클래스
public class JwtUtil { 

    // Bearer 접두사로, JWT 토큰 앞에 붙는 표준 문자열(Authorization: Bearer <JWT> 형식)
    private static final String BEARER_PREFIX = "Bearer "; 
    private static final long TOKEN_TIME =  30 * 60 * 1000L;    //발급 후 토큰 유효시간 30분

    @Value("${jwt.secret.key}") 
    private String secretKey; // JWT 서명(signature) 만들 때 사용할 비밀 키
    private Key key; // secretKey를 Base64로 디코딩하여 생성한 서명에 사용할 실제 HMAC 키 객체
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // JWT 서명에 사용할 알고리즘(HMAC-SHA256(HS256))

    @PostConstruct // 빈 초기화 직후 추가작업
    public void init(){
        byte[] bytes = Base64.getDecoder().decode(secretKey); // secretKey를 Base64로 디코딩하여 바이트 배열로 변환

        key = Keys.hmacShaKeyFor(bytes); // 디코딩된 바이트 배열로부터 HMAC 키를 생성(이후 JWT 토큰 생성 및 검승에 사용됨)
    }

    // JWT 생성 메서드
    public String createToken(Long userId, String email, Authority authority){
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId)) //  JWT의 Subject 필드에 고유 식별자인 사용자의 ID를 저장
                        .claim("email", email) // JWT의 클레임(토큰에 담기는 정보)에 사용자의 이메일을 추가
                        .claim("authority", authority.name()) // JWT의 클레임(토큰에 담기는 정보)에 사용자의 권한(USER, ADMIN) ENUM 값 추가
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 설정(30분)
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 키와 알고리즘을 사용하여 JWT에 서명을 추가
                        .compact(); // 최종적으로 토큰을 생성하고 반환
    }

    // JWT 추출 메서드
    public String substringToken(String tokenValue){
        // 토큰 존재하고 "Bearer"로 시작하는지 확인
        if(StringUtils.hasText(tokenValue)&&tokenValue.startsWith(BEARER_PREFIX)){
            return tokenValue.substring(7);// 접두사인 "Bearer "제거하고 JWT(토큰) 반환
        }
        log.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }

    // JWT 파싱후 Claims 추출하는 메서드(Claims = 페이로드. 즉, 토큰에 담긴 사용자 정보, 만료 시간)
    public Claims extractClaims(String token) {
        return Jwts.parserBuilder() 
                .setSigningKey(key) // 1. 서명을 검증할 키 설정(JWT 위,변조 여부 확인)
                .build() // 2. JWT 파서 생성
                .parseClaimsJws(token) // 3. 토큰 파싱 및 서명 검증
                .getBody(); // 4. 페이로드(Claims) 추출
    }
}


2. Filter 생성



@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;
 
    // authPattern : `/v{숫자}/auth`로 시작하는 URL 상수
    private final Pattern authPattern = Pattern.compile("^/v\\d+/auth.*");

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        
        // 특정 URL 패턴(JWT 발급 아직 못 받는 로그인, 회원가입)은 검증 없이 바로 doFilter로 필터 통과
        if (authPattern.matcher(url).matches()) {
            chain.doFilter(request, response);
            return;
        }

        // 위 코드 이해하기 어렵다면 startsWith()으로 로그인, 회원가입의 URL은 필터 통과시키기
//        if (url.startsWith("/v1/auth") || url.startsWith("/v2/auth")) {
//            chain.doFilter(request, response);
//            return;
//        }

        // 헤더의 Authorization에 들어 있는 값인 JWT 토큰 추출   
        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) {
            // 토큰이 없는 경우 400을 반환
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);

            // 사용자 정보를 ArgumentResolver 로 넘기기 위해 HttpServletRequest 에 세팅
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email", String.class));

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("JWT 토큰 검증 중 오류가 발생했습니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰 검증 중 오류가 발생했습니다.");
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

3. Filter 등록


@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;

    // JwtFilter 클래스 Filter로 등록해서 Bean으로 등록
    @Bean
    // FilterRegistrationBean : 필터의 등록과 초기화 매개변수를 설정하게 해주는 Spring 유틸리티 클래스
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JwtFilter(jwtUtil)); // JwtUtil에서 만든 JWT 검증하는 JwtFilter 클래스 필터로 등록
        registrationBean.addUrlPatterns("/*"); // 해당 필터를 거치는 URL 설정(여기서는 모든 URL 경로)

        return registrationBean;
    }
}

4. 인증된 유저의 정보 담는 DTO(AuthUser) 생성

@Getter
@AllArgsConstructor
public class AuthUser {
    private Long userId;
    private String email;
    private Authority authority;
}

5. 유저 인증에 사용되는 애노테이션(@Auth) 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}

6. ArgumentResolver 생성


// 컨트롤러 메서드에 사용자 정보 파라미터로 주입해주는 ArgumentResolver 클래스 생성
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 메서드 파라미터가 @Auth 어노테이션을 가지고 있는지 확인
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null; 
        
        // 메서드 파라미터의 타입이 AuthUser인지 확인
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new IllegalArgumentException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

        return hasAuthAnnotation;
    }

    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email,authority 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");
        Authority authority = (Authority) request.getAttribute("authority");
        return new AuthUser(userId, email, authority);  // 유저 정보 담은 AuthUser 객체를 생성하여 반환
    }
}

7. ArgumentResolver 등록


@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}

8. Controller의 파라미터로 사용

profile
알고리즘, 자료구조 블로그: https://gyun97.github.io/

0개의 댓글