JWT를 통한 인증/인가에 대해 이해가 부족하다면 이전 포스팅인 웹 애플리케이션 인증 방식 2 : JWT에 대해 읽고 오시는 것을 권장합니다.
서블릿 필터에 대한 이해가 부족하다면 이전 포스팅인
서블릿 필터(Servlet Filter)를 읽어보고 오는 것을 추천한다.
JWT 인증에서 서블릿 필터(Servlet Filter)는 클라이언트가 요청을 보낼 때, 이 요청을 가로채서 JWT를 확인하고 인증 및 권한 처리를 수행하는 역할을 하며 이를 통해 인증이 필요한 자원에 대한 접근을 제어할 수 있다.
"Bearer <JWT(토큰)>"
형식으로 포함된다.2. JWT 검증
필터는 헤더 Authorization에서 JWT를 추출하여 해당 토큰이 유효한지 검증한다. 검증해야 하는 사항은 다음과 같다:
ArgumentResolver
는 Spring MVC에서 컨트롤러 메서드의 파라미터를 해석하고 바인딩하는 데 사용되는 인터페이스이다.
스프링에서 컨트롤러 메서드를 호출할 때, 클라이언트 요청에서 전달된 데이터를 메서드의 파라미터로 변환하여 바인딩하는 역할을 수행한다.
스프링은 컨트롤러 메서드의 파라미터에 다양한 데이터를 자동으로 바인딩한다. 예를 들어, @RequestParam
, @PathVariable
, @RequestBody
등의 어노테이션을 사용하면 요청 데이터를 메서드 파라미터로 쉽게 받을 수 있는데 이때, 각각의 데이터 타입에 맞는 ArgumentResolver
가 그 변환을 처리하여 바인딩하는 역할을 한다.
JWT를 통한 인증 과정에서 Argument Resolver를 사용하면 컨트롤러의 메서드에 요청 데이터를 자동으로 바인딩할 수 있다.
특히, 인증된 사용자 정보를 컨트롤러 메서드의 파라미터로 간편하게 주입할 수 있다는 장점이 있다.
요약하자면 Argument Resolver는 컨트롤러의 메서드에서 요청 파라미터를 처리하는 과정에서 JWT로부터 인증된 사용자 정보를 추출하여 해당 정보를 컨트롤러 메서드의 파라미터로 주입시킨다.
즉, Filter를 통과한 JWT의 유저 정보를 Argument Resolver를 통하여 객체지향적이면서도 효율적으로 꺼내서 유저 정보를 필요로 하는 Controller의 각 메서드에 전달한다는 이야기이다.
2. 컨트롤러 메서드에서 사용자 정보 접근 :
인증된 사용자 정보는 보통 컨트롤러 메서드에서 사용된다.
예를 들어, 로그인한 사용자의 ID를 바탕으로 특정 데이터를 조회하고 싶다면 컨트롤러 메서드의 파라미터로 사용자 객체를 바로 주입하는 것이 편리하다. 이때, Argument Resolver가 사용자 정보를 컨트롤러 메서드에 자동으로 주입하는 역할을 한다.
(다른 설정 정보)
.
.
.
jwt:
secret:
key: ${JWT_SECRET_KEY} # JWT_SECRET_KEY 환경 변수 IDE에서 등록하기
.
.
.
@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) 추출
}
}
@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();
}
}
@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;
}
}
@Getter
@AllArgsConstructor
public class AuthUser {
private Long userId;
private String email;
private Authority authority;
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}
// 컨트롤러 메서드에 사용자 정보 파라미터로 주입해주는 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 객체를 생성하여 반환
}
}
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
// ArgumentResolver 등록
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthUserArgumentResolver());
}
}