
// 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부터 만들어보자.
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을 작성한다.
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을 작성해준다. 각 함수별 역할에 대해 소개만 하고 넘어가겠다.
USER ID와 ROLE을 MDC에 넣는다.ROLE을 읽는다.프로젝트 초반에 작성한 것이라, 부족해 보이지만 보완해 나갈 생각으로 이제는 API Interceptor를 구현해보자.
우선 Interceptor구현 이전에 한 가지를 짚고 넘어가보자. 왜 Interceptor를 사용하기로 결정했는가.
크게 공통된(?) 처리를 위하여 사용하는 방법은 두 가지이다.

Filter는 DispatcherServlet에 도달하기 전에 요청을 가로채서,
에 적합하고,
Interceptor는 DispatcherServlet과 Controller간의 요청을 가로채기 때문에,
에 적합하다고 한다.
하지만 무엇보다 나는 최종적으로 /auth관련 API 들은 인증 절차를 제외하기 위해서, 즉 세밀한 제어를 위해 Interceptor를 적용했고, 지금부터 살펴보자.
@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 토큰을 처리해보자.
@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);
}
Logging 정보를 남기고JwtUtil에서 구현한, Access Token 검증 로직을 활용해 올바른 Token인지 확인MDC에 자주 쓸 ID값과 Role을 넣어주고위와 같이 prehandle()메소드를 구현했고,
@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만 하고 말았다.
@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 를 추가해주었다.
피드백과 질문은 언제나 환영입니다 :)