[Springboot] Interceptor로 JWT 토큰을 처리해보자

LTT·2024년 11월 25일

프로젝트 설정


// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'com.auth0:java-jwt:4.4.0'

// web
implementation 'org.springframework.boot:spring-boot-starter-web'

다음과 같이 의존성을 추가해주고 JWT token로직에 사용할 Component부터 만들어보자.

JWT 준비


JwtDto

package site.billbill.apiserver.common.utils.jwt.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtDto {
    @Schema(description = "엑세스 토큰", example = "accessToken(20min)")
    private String accessToken;
    @Schema(description = "리프레쉬 토큰", example = "refreshToken(4weeks)")
    private String refreshToken;
    @Schema(description = "토큰 종류", example = "Bearer")
    private String grantType;
    @Schema(description = "만료 시간", example = "20min")
    private Long expiresIn;
    @Schema(description = "권한", example = "ADMIN / USER")
    private String role;
}

위와 같이 response로 반환해줄 Jwt의 DTO를 작성해준다. 다음 이 Jwt와 관련된 로직을 수행할 Component인 JWTUtil을 작성한다.

JwtUtil

package site.billbill.apiserver.common.utils.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import site.billbill.apiserver.common.enums.exception.ErrorCode;
import site.billbill.apiserver.common.enums.user.UserRole;
import site.billbill.apiserver.common.utils.jwt.dto.JwtDto;
import site.billbill.apiserver.exception.CustomException;

import javax.crypto.SecretKey;
import java.util.Date;

import static org.apache.commons.lang3.StringUtils.substring;

@Slf4j
@Component
public class JWTUtil {
    private final long ACCESS_TOKEN_EXPIRE_TIME;
    private final long REFRESH_TOKEN_EXPIRE_TIME;
    private final SecretKey key;

    public static String MDC_USER_ID = "userId";
    public static String MDC_USER_ROLE = "role";

    public JWTUtil(
            @Value("${jwt.bill.secret-key}") String secretKey,
            @Value("${jwt.bill.access-token-expired}") long ACCESS_TOKEN_EXPIRE_TIME,
            @Value("${jwt.bill.refresh-token-expired}") long REFRESH_TOKEN_EXPIRE_TIME
    ) {
        this.ACCESS_TOKEN_EXPIRE_TIME = ACCESS_TOKEN_EXPIRE_TIME;
        this.REFRESH_TOKEN_EXPIRE_TIME = REFRESH_TOKEN_EXPIRE_TIME;

        this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
    }

    public JwtDto generateJwtDto(String userId, UserRole role) {
        Date now = new Date();
        Date accessTokenExpiresIn = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME*1000);
        Date refreshTokenExpiresIn = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME*1000);

        String issuer = "BillBillServer";

        String accessToken = Jwts.builder()
                .issuer(issuer)
                .subject(userId)
                .expiration(accessTokenExpiresIn)
                .issuedAt(new Date())
                .signWith(key)
                .claim("userId", userId)
                .claim("role", role.name())
                .claim("type", "AT")
                .compact();

        String refreshToken = Jwts.builder()
                .issuer(issuer)
                .subject(userId)
                .expiration(refreshTokenExpiresIn)
                .issuedAt(new Date())
                .signWith(key)
                .claim("userId", userId)
                .claim("role", role.name())
                .claim("type", "RT")
                .compact();

        return new JwtDto(accessToken, refreshToken, "Bearer", accessTokenExpiresIn.getTime() / 1000, role.name());
    }

    public boolean isValidAccessToken(String token) {
        try {
            if(getClaims(token).get("type").equals("AT")) return true;
        } catch (ExpiredJwtException e) {
            log.error("Bill Access 토큰 시간이 만료 되었습니다. {}", e.getMessage());
            return false;
        } catch (JwtException e) {
            log.error("Bill Access 토큰 헤더값이 유효하지 않습니다. {}", e.getMessage());
            return false;
        }

        return false;
    }

    public Claims getClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public String putUserMDC(Claims claims) {
        String userId= claims.getSubject();
        String role = claims.get("role", String.class);

        MDC.put(MDC_USER_ID, userId);
        MDC.put(MDC_USER_ROLE, role);

        return userId;
    }

    public UserRole getUserRole(String token) {
        Claims claims = getClaims(token);
        String role = claims.get("role", String.class);
        return UserRole.valueOf(role);
    }

    public boolean isValidRefreshToken(String token) {
        try {
            if(getClaims(token).get("type").equals("RT")) return true;
        } catch (ExpiredJwtException e) {
            log.error("Bill Refresh 토큰 시간이 만료 되었습니다. {}", e.getMessage());
            throw new CustomException(ErrorCode.BadRequest, "Bill Refresh 토큰 시간이 만료되었습니다. ", HttpStatus.BAD_REQUEST);
        } catch (JwtException e) {
            log.error("Bill Refresh 토큰 헤더값이 유효하지 않습니다. {}", e.getMessage());
            return false;
        }

        return false;
    }
}

위와 같이 JWTUtil을 작성해준다. 각 함수별 역할에 대해 소개만 하고 넘어가겠다.

  • generateJwtDto : 새로운 토큰을 발급한다.
  • isValidAccessToken : 올바른 Access Token인지 확인한다.
  • isValidRefreshToken : 올바른 Refresh Token인지 확인한다.
  • getClaims : Token을 읽을 때 사용한다.
  • putUserMDC : API 개발때에 자주 사용할 USER IDROLE을 MDC에 넣는다.
  • getUserRole : Token에서 ROLE을 읽는다.

프로젝트 초반에 작성한 것이라, 부족해 보이지만 보완해 나갈 생각으로 이제는 API Interceptor를 구현해보자.

Interceptor 적용 이유


우선 Interceptor구현 이전에 한 가지를 짚고 넘어가보자. 왜 Interceptor를 사용하기로 결정했는가.

크게 공통된(?) 처리를 위하여 사용하는 방법은 두 가지이다.

  1. Filter
  2. Interceptor

Filter는 DispatcherServlet에 도달하기 전에 요청을 가로채서,

  • 보안 관련 공통 작업
  • Logging
  • 이미지/데이터 압축 및 문자열 인코딩

에 적합하고,

Interceptor는 DispatcherServlet과 Controller간의 요청을 가로채기 때문에,

  • Application Logging과 같은 Cross-cutting 처리
  • Detailed Authorization(인가)
  • Spring Context 또는 Model(엔티티 또는 비즈니스 로직) 조작

에 적합하다고 한다.

하지만 무엇보다 나는 최종적으로 /auth관련 API 들은 인증 절차를 제외하기 위해서, 즉 세밀한 제어를 위해 Interceptor를 적용했고, 지금부터 살펴보자.

ApiInterceptor


@Slf4j
@Component
@RequiredArgsConstructor
public class BillApiInterceptor implements HandlerInterceptor {
    private final JWTUtil jwtUtil;

    private String resolveToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");

        if (header != null) {
            if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
                // if header format is right
                if (header.length() < 8) {
                    throw new CustomException(ErrorCode.Unauthorized, "JWT 토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED);
                }

                String subString = header.substring(7);
                if (!StringUtils.hasText(subString)) {
                    throw new CustomException(ErrorCode.Unauthorized, "JWT 토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED);
                }

                return subString;
            }

        }

        return null;
    }

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) throws Exception {
        // Unique ID for each request value
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute("LOG_ID", uuid);
        if (HttpMethod.OPTIONS.name().equals(request.getMethod())) {
            log.info("pass OPTION method");
            return true;
        }

        // 메서드 정보를 로깅 처리 하기 위해 추가
        HandlerMethod hd = null;
        if (handler instanceof HandlerMethod) {
            hd = (HandlerMethod) handler;
        }

        // token check
        String token = resolveToken(request);

        if (token != null && jwtUtil.isValidAccessToken(token)) {
            // if token is valid
            // 관리자용 api가 생길 경우 해당 예외처리 필요

            log.info("UserRole : {}", jwtUtil.getUserRole(token));

            String userId = jwtUtil.putUserMDC(jwtUtil.getClaims(token));
            // log 출력
            log.info("REQUEST [{}][{}] : auth by user {}", uuid, requestURI, userId);

            return true;
        }

        log.info("REQUEST [{}][{}] : no auth by user", uuid, requestURI);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        throw new CustomException(ErrorCode.Unauthorized, "JWT 토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED);
    }

    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex
    ) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = String.valueOf(request.getAttribute("LOG_ID"));

        log.info("RESPONSE[{}][{}]", logId, requestURI);
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}

이건 풀 코드이다.

각각 prehandle()afterCompletion()은 공통적으로 로깅을 위해 사용하고, 추가로 prehandle()에서는 Authentication 관련 로직도 추가하여 이번 주제의 핵심인 JWT 토큰을 처리해보자.

prehandle()


    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) throws Exception {
        // Unique ID for each request value
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute("LOG_ID", uuid);
        if (HttpMethod.OPTIONS.name().equals(request.getMethod())) {
            log.info("pass OPTION method");
            return true;
        }

        // 메서드 정보를 로깅 처리 하기 위해 추가
        HandlerMethod hd = null;
        if (handler instanceof HandlerMethod) {
            hd = (HandlerMethod) handler;
        }

        // token check
        String token = resolveToken(request);

        if (token != null && jwtUtil.isValidAccessToken(token)) {
            // if token is valid
            // 관리자용 api가 생길 경우 해당 예외처리 필요

            log.info("UserRole : {}", jwtUtil.getUserRole(token));

            String userId = jwtUtil.putUserMDC(jwtUtil.getClaims(token));
            // log 출력
            log.info("REQUEST [{}][{}] : auth by user {}", uuid, requestURI, userId);

            return true;
        }

        log.info("REQUEST [{}][{}] : no auth by user", uuid, requestURI);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        throw new CustomException(ErrorCode.Unauthorized, "JWT 토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED);
    }
  1. 위와 같이 랜덤 UUID 값을 생성해 Logging 정보를 남기고
  2. JwtUtil에서 구현한, Access Token 검증 로직을 활용해 올바른 Token인지 확인
  3. 맞다면 MDC에 자주 쓸 ID값과 Role을 넣어주고
  4. 맞지 않다면, 401에러를 집어던진다(?)

위와 같이 prehandle()메소드를 구현했고,

afterCompletion()


    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex
    ) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = String.valueOf(request.getAttribute("LOG_ID"));

        log.info("RESPONSE[{}][{}]", logId, requestURI);
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }

afterCompletion()메소드는 아직 별다른 조치 없이 단순 Response Logging만 하고 말았다.

AddInterceptor


@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
    private final BillApiInterceptor billApiInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(billApiInterceptor)
                .excludePathPatterns("/docs/**", "/swagger-ui/**", "/v3/api-docs/**", "/error", "/api/v1/auth/**","/v3/api-docs/**",
                        "/api/v1/images/user")
                .addPathPatterns("/**");
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("*")
                .maxAge(3600L)
                .allowedHeaders("*")
                .exposedHeaders("Authorization")
                .allowCredentials(true);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

위와 같이 Interceptor 를 추가해주었다.

피드백과 질문은 언제나 환영입니다 :)

profile
개발자에서 엔지니어로, 엔지니어에서 리더로

0개의 댓글