지난번 게시글에서는 시큐리티에 JWT 를 적용하여 로그인(인증)을 구현하였다.
인증을 구현하였으니 적절한 인가가 필요하다. 따라서 Intercepter를 구현하여 적절한 토큰이 맞는지 확인하는 과정을 추가할 것이다.
로그인, 회원가입을 시큐리티를 적용하고 JWT 로 구현하였다. 로그인과 회원가입이 되니 유저에 대한 부분은 끝났다고 생각했는데 프론트 쪽에서 "토큰이 왔으니 요청 보낼때마다 유효한 토큰인지 검증하는 과정이 필요하지 않을까요?"라고 질문했다.
안전한 인증, 인가를 위해 시큐리티를 선택했는데 정작 인가를 구현하지 않은 것이다. 그때 부랴부랴 인가에 대해서 공부했고 Intercepter를 이용해서 이를 구현하였다. 요구사항은 아래와 같았다.
유저 서비스에서 인가는 필수이니 나처럼 바보같은 실수는 하지 않았으면 좋겠다.
우선 Intercept 란 단어는 알다시피 '낚아채다'라는 의미이고, 스프링에서 인터셉터도 단어의 의미와 크게 다르지 않다.
사용자 요청에 의해 서버에 들어온 Request 객체를 컨트롤러의 핸들러로 도달하기 전에 낚아채서 개발자가 원하는 추가적인 작업을 한 후 핸들러로 보낼 수 있도록 해주는 것이 인터셉터 이다.
즉, 요청이 컨트롤러로 도달하기 전에 낚아채서 원하는 작업(보안, 로깅, 권한 등의 기능)을 한 후 컨트롤러에 도달하게 한다.
(등록된 인터셉터가 없다면 바로 컨트롤러에 도달한다.)
위 흐름을 보면 request가 필터를 먼저 거치고 인터셉터를 controller 전에 거치고 response가 나오는 모습을 확인할 수 있다.
- 공통 처리 로직의 분리
- 보안, 로깅, 권한 등의 기능을 중앙에서 처리
- 필터보다 더 세부적인 제어 제공
인터셉터를 사용하면 요청 전후에 실행할 공통 로직을 한 곳에 모아서 처리할 수 있어 코드의 중복을 줄이고, 유지보수를 쉽게 할 수 있다.
로그인을 완료한 사용자라도 결제, 게시글 작성 등의 서비스를 이용하기 위해서는 인가를 통해 지속적인 확인 및 검증이 필요하다. 보안, 로깅, 권한 등의 기능을 인터셉터가 처리한다.
필터보다 더 세부적인 제어를 제공하여 특정 URL 패턴에 대한 요청만 가로채고 처리할 수 있다. 이는 인터셉터는 스프링 안에서 제어가 가능하기 때문에 세부적인 제어가 가능하다. 필터는 Tomcat 안에 있고 외부적인 것으로 보기 때문에 세부적인 제어가 불가능한 것이다.(위의 흐름도 참고)
이러한 부분을 한번의 코드 작성을 통해 가능토록 하는 것이 Interceptor인 것이다.
처음에 요청을 보낼때마다 토큰에 대한 검증이 필요하다고 했을 때 '토큰이 유효한지만 체크하면 되지 않아?' 하고 로그인과 회원가입을 제외한 모든 Api 요청 토큰을 검증하는 로직을 추가했었다.
기존 코드
@PostMapping("/blog")
public ResponseEntity<Void> postBlog(HttpServletRequest request) {
String accessToken = AuthorizationExtractor.extract(request);
if (jwtTokenProvider.validateToken(accessToken)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.badRequest().build();
}
하지만 같은 작업인데 코드가 중복되는데 눈에 계속 보였고 비효율적이라는 생각을 했었다. Api는 계속해서 늘어날텐데 3줄의 코드를 기본으로 가지고 가야하는걸 없앨 수 없을까 하다가 찾은 것이 인터셉터였다.
필터와 달리 특정 URL 패턴에 대한 요청만 가로채고 처리할 수 있다는 장점과 공통 로직을 한 곳에 모아서 처리할 수 있다는 장점에 인터셉터를 선택했다.
게시물을 쓰면서 ArgumentResolver 를 인터셉터와 함께 사용한다는 것을 알게 되었다.
이에 관한 내용도 현재 프로젝트에 구현한 후 관련 내용을 블로그에 추후 업로드할 예정이다.
Interceptor를 사용하기 위해서는 HandlerInterceptor 인터페이스를 구현해야 한다. 그리고 preHandle() 메소드를 커스텀하여 인가 처리를 구현했다.
@Component
public class JwtInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;
public JwtInterceptor(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (CorsUtils.isPreFlightRequest(request)) return true;
//토큰 추출
String jwtToken = jwtTokenProvider.resolveToken(request);
//토큰이 있는지 확인
if (!StringUtils.hasText(jwtToken)) {
throw new NullJwtTokenException(ExceptionList.NULL_JWT_TOKEN);
}
try {
Claims claims = jwtTokenProvider.parseClaims(jwtToken);
//토큰 내 정보 추출
Long uid = claims.get("uid", Long.class); // 사용자 UID
String name = claims.get("name", String.class); // 사용자 이름
String username = claims.getSubject(); // 사용자 아이디
if (uid == null || !StringUtils.hasText(name) || !StringUtils.hasText(username)) {
throw new MalformedJwtException("JWT 토큰의 클레임이 잘못되었습니다.");
}
//JwtContextHolder 에 저장 (Thread)
JwtContextHolder.setUid(uid); // 사용자 UID
JwtContextHolder.setName(name); // 사용자 이름
JwtContextHolder.setUsername(username); // 사용자 아이디
} catch (JwtException e) {
throw new IllegalArgumentException("JWT 토큰 검증 실패", e);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
JwtContextHolder.clear();
}
}
preHandle()에서 요청 처리 과정에서 JWT 토큰의 정보를 쉽게 접근할 수 있도록 하기 위해 JWT 토큰을 검증한 후 해당 정보를 JwtContextHolder에 저장하였다.
1. preHandle()
2. postHandle()
3. afterComletion()
JwtContextHolder는 JWT 토큰에서 추출한 정보를 스레드 로컬 변수에 저장하여, 요청 처리 과정에서 쉽게 접근할 수 있도록 하는 역할을 한다.
JwtContextHolder 는 ThreadLocal 변수를 사용하여, 같은 스레드 내에서 어디서든 접근 가능한 Context를 제공한다.
public class JwtContextHolder {
private static final ThreadLocal<Long> uidContext = new ThreadLocal<>(); //사용자 UID
private static final ThreadLocal<String> nameContext = new ThreadLocal<>(); //사용자 이름
private static final ThreadLocal<String> usernameContext = new ThreadLocal<>(); //사용자 아이디
public static void setUid(Long uid) {
uidContext.set(uid);
}
public static Long getUid() {
return uidContext.get();
}
public static void setName(String name) {
nameContext.set(name);
}
public static String getName() {
return nameContext.get();
}
public static void setUsername(String id) {
usernameContext.set(id);
}
public static String getUsername() {
return usernameContext.get();
}
public static void clear() {
uidContext.remove();
nameContext.remove();
usernameContext.remove();
}
}
토큰 내 정보가 필요할 때마다 파싱하는 과정은 불필요하다고 생각했다.
따라서 토큰 검증 후 정보를 추출해 스레드 로컬 변수에 저장한 후 정보가 필요할 때마다 쉽게 접근할 수 있도록 JwtContextHolder를 구현하였다.
인터셉터를 구현하였으면 인터셉터를 등록해야한다. WebMvcConfigurer를 구현한 WebMvcConfig에 위 인터셉터를 등록해준다.
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
private final Environment environment;
@Override
public void addInterceptors(InterceptorRegistry registry) {
String[] activeProfiles = environment.getActiveProfiles();
if(activeProfiles != null && activeProfiles.length > 0 && !activeProfiles[0].equals("test"))
registry.addInterceptor(jwtInterceptor).addPathPatterns("/api/**")
.excludePathPatterns("/api/v1/auth/**");
}
}
여기에서 인터셉터를 적용할 url패턴과 제외할 url 패턴을 지정할 수 있다.
❗ 이상하게 excludePathPatterns을 지정할때 로그인과 회원가입 개별로 추가했는데 적용이 되지 않았다. (/api/v1/user/login, /api/v1/user/register)
✔️_인터셉터를 적용하지 않는 api들을 모아서 /api/v1/auth/** 경로를 지정해주고 여기 아래 경로들은 제외해달라고 지정해두었더니 적용이 되었다. 왜 그런지는 이유를 찾지 못했다.. 아마 개별로는 지정이 되지 않는 것 같고 전체로 묶어서 여기 아래 경로들을 제외해달라고 해야 되는 것 같다. 이유를 아시는 분은 댓글 남겨주세요!!__
또한 test를 할 때 test 프로필에서는 인터셉터를 이용한 인가가 필요하지 않다. (없어야 한다) 따라서 인터셉터가 적용되지 않도록 코드를 짰다.
마지막으로 인가처리 흐름도를 정리할 겸 보면 좋을 것 같다.
https://popo015.tistory.com/115
https://velog.io/@ung6860/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8jwt-interceptor
https://velog.io/@dlduq29/Filter-Interceptor-Argument-Resolver