지난번에는 ControllerAdvice를 사용해서 Validation과 TypeMismatch 예외처리를 했으니, 이번에는 Authorization을 해줄 차례. (즉, 내가 쓴 글만 수정/삭제 가능하게 하기)
사실 쉽게 생각하면 별다른 고민없이 해결할 수 있는 문제다.
String tutorIdStr = SecurityContextHolder.getContext().getAuthentication().getName();
Long tutorId = Long.parseLong(tutorIdStr);
이렇게 유저ID를 가져와서, 객체에 저장된 유저ID와 비교한 후에 동일하면 코드를 수행하고, 동일하지 않으면 수행하지 않는 방식으로 말이다.
그런데 하나하나 설정해주면 if문이 너무 많아지는게 보기 싫기도 하고, DRF로 할때 viewset 안에 permission_classes = [IsOwnerOrReadOnly]
한줄만 쓰고도 권한설정이 됐던게 생각이 나서 다른 방법을 찾아보았다.
그러다가 찾은 방법이 바로 Interceptor
를 사용하는 것이다.
인터셉터는 컨트롤러의 핸들러(사용자가 요청한 url에 따라 실행되어야할 메소드)를 호출하기 전이나 후에 요청과 응답을 참조하거나 가공할 수 있는 일종의 필터 역할을 한다.
쉽게 말하면 사용자가 보낸 요청을 컨트롤러로 보내서 코드를 수행하기 이전에, 미리 요청을 쌔벼와서(인터셉트!) 이러쿵 저러쿵 내가 원하는 작업을 한 후에 다시 핸들러로 쌔빈 요청을 전달해주는 것이다. 경우에 따라 핸들러가 실행된 후에 생성된 응답을 쌔벼서 추가적인 작업 후에 다시 돌려주는 것도 가능하다.
요청이나 응답을 언제 인터셉트할 것인지는 다음 메소드를 적절히 사용하면 된다.
preHandle()
postHandle()
afterCompletion()
기본틀은 다음과 같이 HandlerInterceptor
를 상속받아서 메소드들을 오버라이딩해서 사용한다.
public class MyInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
...
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
...
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
...
}
}
인터셉터를 만들었다고 끝나는게 아니다. WebMvcConfigurer
를 상속받은 클래스를 이용해 스프링부트에 내가 만든 인터셉터를 쓰겠다고 등록해야 한다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer{
@Autowired
private MyInterceptor myInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(myInterceptor)
.addPathPatterns("/aa/**") /* 인터셉터가 실행될 url 패턴 */
.excludePathPatterns("/aa/ee") /* 인터셉터가 실행되지 않을 url 패턴 */
;
}
}
이러면 등록된 url 패턴 전에 해당 인터셉터가 실행된다.
➕ 2023.06.09 추가
addPathPatterns
에는 추가할 url 패턴을 쓰고, 여기에 등록된 패턴에 포함되지만 제외시킬 url 패턴을excludePathPatterns
에 쓴다.- 다시 말해
addPathPatterns
에 쓴 url 패턴에 포함되지 않으면 굳이excludePathPatterns
에 쓰지 않아도 된다는 뜻이다./*
는 바로 하위경로만을,/**
는 모든 하위경로들을 가리킨다.- ex)
/api/{id}/list
는/api/**
에 포함되고,/api/*/list
로 특정해서 쓸 수도 있다.
그럼 이제 실제 코딩!
// ReviewAuthInterceptor.java
@RequiredArgsConstructor
@Component
public class ReviewAuthInterceptor implements HandlerInterceptor {
@Autowired
private final ReviewRepository reviewRepository;
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/* 요청을 보낸 유저 아이디 가져오기 */
String tutorIdStr = SecurityContextHolder.getContext().getAuthentication().getName();
Long tutorId = Long.parseLong(tutorIdStr);
/* 요청에 담긴 reviewId 가져오기 */
Map<?, ?> pathVariables = (Map<?, ?>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
Long reviewId = Long.parseLong((String)pathVariables.get("reviewId"));
/* 그 review를 생성한 유저 아이디 가져오기 */
Long tutorIdOfReview = reviewRepository.GetTutorIdOfReview(reviewId);
/* 유저아이디가 동일하지 않으면 핸들러에 접근하지 않음 */
if (tutorIdOfReview != null && tutorId != tutorIdOfReview) {
/* 핸들러에 접근하지 않고 보낼 403 Forbidden 응답 생성 */
String result = objectMapper.writeValueAsString(new ResponseMsg(ResponseMsgList.NOT_AUTHORIZED.getMsg()));
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.setStatus(403);
response.getWriter().write(result);
return false;
}
/* 유저아이디가 동일하면 핸들러 접근 고고 */
return true;
}
}
// WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
ReviewAuthInterceptor reviewAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(reviewAuthInterceptor)
.excludePathPatterns("/api/review", "/api/review/list") // 인터셉터 실행 X
.addPathPatterns("/api/review/**"); // 인터셉터 실행 O
}
}
@PathVariable
가져오기preHandle()
을 사용할때 주의할 점은 인터셉터에서 요청을 한번 받아서 가공하면, 리턴값이 true
이더라도 핸들러로 사용자가 보냈던 요청의 원본이 아예 가지 않는다는 점이다.
그래서 요청을 원본 그대로 핸들러로 보내되, @PathVariable
인 reviewId
만 빼오는 코드를 짰다.
import org.springframework.web.servlet.HandlerMapping;
Map<?, ?> pathVariables = (Map<?, ?>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
Long reviewId = Long.parseLong((String)pathVariables.get("reviewId"));
ResponseMsg
응답 만들기그리고 preHandle()
에서 보내는 403 응답도 다른 응답들과 똑같이 ResponseMsg
객체를 보내고 싶었는데, 여기서도 마음대로 되지 않고 좀 애를 먹었다.
import com.fasterxml.jackson.databind.ObjectMapper;
private ObjectMapper objectMapper = new ObjectMapper();
...
String result = objectMapper.writeValueAsString(new ResponseMsg(ResponseMsgList.NOT_AUTHORIZED.getMsg()));
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.setStatus(403);
response.getWriter().write(result);
결론은 Jackson 라이브러리를 사용해서 ResponseMsg
객체를 만든 다음에 writeValueAsString
메소드를 통해 Json 문자열로 바꾸고, 내가 원하는대로 나오도록 ContentType
, 인코딩값, HttpStatus를 설정한 다음에, response.getWriter().write()
로 응답데이터를 작성했다.
이렇게 하면 return response
를 하지 않고도 내가 만든 응답이 반환된다. preHandle()
의 리턴값은 Boolean이어야 하므로 리턴은 false
로 하지만 사용자에게 보여지는 리턴값은 false
가 아닌 response
다.
아무튼 이렇게 인터셉터를 여기저기서 만들어주고 한군데서 등록을 해주면
src/main/java/xx/ProjectName/
A
ㄴADomain.java
ㄴAController.java
ㄴAService.java
ㄴARepository.java
ㄴAAuthInterceptor.java <-- 인터셉터 A
B
ㄴBDomain.java
ㄴBController.java
ㄴBService.java
ㄴBRepository.java
ㄴBAuthInterceptor.java <-- 인터셉터 B
WebMvcConfig.java <-- 인터셉터 A, B 등록
ProjectNameApplication.java
권한이 있을 때
권한이 없을 때 (남의거를 건드리려고 할때)
Authorization 권한 설정 성공!
➕ 2023.06.09 추가
스프링 프로젝트에서는 다음과 같은 흐름으로 요청이 전달된다.
Request -> Interceptor -> ControllerAdvice -> Controller -> Service -> (Controller) -> (Interceptor) -> Response
다시 말해 내 코드에서는
- 클라이언트가 요청을 보낸다.
- Interceptor에서 Authorization 검사를 한다.
- ControllerAdvice에서 Validation과 TypeMismatch 검사를 한다.
- Controller에서 Service 코드를 부른다.
- Service에서 작업을 한다.
- 응답을 보낸다.
이 순서로 요청/응답이 진행되는 것이다.
그런데 일부 경우에서 에러응답이 잘못 반환되는 경우가 있어, (ex. 객체가 아예 없어서 null이 나오는 경우는 404
, 객체의 필드에 값이 없어서 null이 나오는 경우는 403
으로 응답이 달라야하는데 둘다 null이라서 처음엔 구분하지 않았음 / 객체가 아예 없는 경우 굳이 3번의 Validation 및 TypeMismatch 검사를 할 필요가 없는데도 흐름상 맞춰줘야 함) Service단에서 썼던 404
예외처리를 Interceptor단으로 끌어왔다.
// (1-1) assignment 404 not found
Optional<Assignment> assignment = assignmentRepository.findById(assignmentId);
if (assignment.isEmpty()) {
String result = objectMapper.writeValueAsString(new ResponseMsg(ResponseMsgList.NOT_EXIST_ASSIGNMENT.getMsg()));
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.setStatus(404); // Not Found
response.getWriter().write(result);
log.info("status code={}, body={}", response.getStatus(), ResponseMsgList.NOT_EXIST_ASSIGNMENT.getMsg());
return false;
}
// (1-2) assignment 403 not authorized (tutee)
Long tuteeIdOfAssignment = assignmentRepository.GetTuteeIdOfAssignment(assignmentId);
if ((tuteeIdOfAssignment != null && userId != tuteeIdOfAssignment) || tuteeIdOfAssignment == null) {
String result = objectMapper.writeValueAsString(new ResponseMsg(ResponseMsgList.NOT_AUTHORIZED.getMsg()));
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.setStatus(403); // Forbidden
response.getWriter().write(result);
log.info("status code={}, body={}", response.getStatus(), ResponseMsgList.NOT_AUTHORIZED.getMsg());
return false;
}
어차피 항상 하는 작업인데 Interceptor단으로 끌어와서 객체가 없으면 굳이 Controller나 Service까지 아예 가지 않아도 돼서 좋은 것같다.
[Spring] 스프링 인터셉터(Interceptor)란 ?
spring boot 2.x interceptor setting 스프링부트 인터셉터 등록 방법
[spring] URL path template에서 매핑된 값 구하기
HTTP Response 데이터
[URL Pattern] /*과 /**의 차이점