[Spring] Interceptor로 Authorization 권한설정하기

김재연·2023년 4월 29일
0

수숙관

목록 보기
12/17
post-thumbnail

지난번에는 ControllerAdvice를 사용해서 Validation과 TypeMismatch 예외처리를 했으니, 이번에는 Authorization을 해줄 차례. (즉, 내가 쓴 글만 수정/삭제 가능하게 하기)

사실 쉽게 생각하면 별다른 고민없이 해결할 수 있는 문제다.

String tutorIdStr = SecurityContextHolder.getContext().getAuthentication().getName();
Long tutorId = Long.parseLong(tutorIdStr);

이렇게 유저ID를 가져와서, 객체에 저장된 유저ID와 비교한 후에 동일하면 코드를 수행하고, 동일하지 않으면 수행하지 않는 방식으로 말이다.

그런데 하나하나 설정해주면 if문이 너무 많아지는게 보기 싫기도 하고, DRF로 할때 viewset 안에 permission_classes = [IsOwnerOrReadOnly] 한줄만 쓰고도 권한설정이 됐던게 생각이 나서 다른 방법을 찾아보았다.

그러다가 찾은 방법이 바로 Interceptor를 사용하는 것이다.

Interceptor

인터셉터는 컨트롤러의 핸들러(사용자가 요청한 url에 따라 실행되어야할 메소드)를 호출하기 전이나 후에 요청과 응답을 참조하거나 가공할 수 있는 일종의 필터 역할을 한다.

쉽게 말하면 사용자가 보낸 요청을 컨트롤러로 보내서 코드를 수행하기 이전에, 미리 요청을 쌔벼와서(인터셉트!) 이러쿵 저러쿵 내가 원하는 작업을 한 후에 다시 핸들러로 쌔빈 요청을 전달해주는 것이다. 경우에 따라 핸들러가 실행된 후에 생성된 응답을 쌔벼서 추가적인 작업 후에 다시 돌려주는 것도 가능하다.

요청이나 응답을 언제 인터셉트할 것인지는 다음 메소드를 적절히 사용하면 된다.

1. preHandle()

  • 컨트롤러가 호출되기 전에 실행된다.
  • 리턴값이 true이면 해당 인터셉터 실행 후 핸들러에 접근하고, false인 경우 작업을 중단한다. (이후에 남아있는 핸들러, 인터셉터(2,3) 모두 실행되지 않는다.)
  • >> Authorization 작업을 하기에 적합한 메소드❗

2. postHandle()

  • 핸들러 실행 후, view 생성 이전에 실행된다.

3. 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
    }

}

- 요청 url의 @PathVariable 가져오기

preHandle()을 사용할때 주의할 점은 인터셉터에서 요청을 한번 받아서 가공하면, 리턴값이 true이더라도 핸들러로 사용자가 보냈던 요청의 원본이 아예 가지 않는다는 점이다.

그래서 요청을 원본 그대로 핸들러로 보내되, @PathVariablereviewId만 빼오는 코드를 짰다.

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 권한 설정 성공!


Request부터 Response까지 거치는 것들

➕ 2023.06.09 추가

스프링 프로젝트에서는 다음과 같은 흐름으로 요청이 전달된다.

Request -> Interceptor -> ControllerAdvice -> Controller -> Service -> (Controller) -> (Interceptor) -> Response

다시 말해 내 코드에서는

  1. 클라이언트가 요청을 보낸다.
  2. Interceptor에서 Authorization 검사를 한다.
  3. ControllerAdvice에서 Validation과 TypeMismatch 검사를 한다.
  4. Controller에서 Service 코드를 부른다.
  5. Service에서 작업을 한다.
  6. 응답을 보낸다.

이 순서로 요청/응답이 진행되는 것이다.

그런데 일부 경우에서 에러응답이 잘못 반환되는 경우가 있어, (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까지 아예 가지 않아도 돼서 좋은 것같다.


Reference

[Spring] 스프링 인터셉터(Interceptor)란 ?
spring boot 2.x interceptor setting 스프링부트 인터셉터 등록 방법
[spring] URL path template에서 매핑된 값 구하기
HTTP Response 데이터
[URL Pattern] /*과 /**의 차이점

profile
일기장같은 공부기록📝

0개의 댓글