개발 공부 기록하기/04. Spring & Spring Boot

Spring Boot Interceptor에서 권한 관리하기 I (HttpServletRequest getInputStream 여러번)

lannstark 2020. 3. 18. 09:03

백엔드를 리딩하며 개발하고 있는 사이드 프로젝트에서 권한을 꽤나 복잡하게 관리해야 하는 요구사항이 있었다.

사이드 프로젝트에서는 원격 근무 협업툴을 만들고 있는데, 협업툴 답게 Team이라는 개념이 있으며 여러 유저가 각기 다른 여러 Team에 가입할 수 있다. Team에 가입한 사용자들은 팀 내부에서 역할(ex 관리자, 일반 멤버)이 정해지며 각 역할에 따라 사용할 수 있는 기능이 달라진다. 또한, Team 내부에는 여러 Part가 있고 Part에서만 사용할 수 있는 기능들이 존재한다.

사용자와 팀, 파트 외에도 팀이 사용하고 있는 Plan에 따라서 (Free Plan, Premium Plan 등) 사용할 수 있는 기능도 달라져야 한다.

에헴... 서버는 어쨌거나 특정 API 호출 권한을 확인해야할 책임이 있기에 어떻게 하면 제일 깔끔한 구조로 권한을 체크할수 있을지 고민을 하게 되었다.

 

API 권한 체크를 위해 떠올릴 수 있는 간단한 방법

이럴때 떠올릴 수 있는 가장 간단한 방법은 API 호출이 컨트롤러로 들어간 이후에 각각 권한을 체크하는 식이다.

예를 들자면, 아래와 같이 서비스단에서 권한을 각각 체크하는 방법이다.

@RequiredArgsConstructor
@Service
public class AService {

 	@Transactional
	public TeamResponse createTeam(TeamCreateRequest request) {
		// 여기서 권한을 체크한다.
    
	}
  
	@Transactional(readOnly = true)
	public TeamDetailResponse getTeamDetails(Long memberId) {
		// 여기서 권한을 체크한다.
	}
  
	...

}

 

이때 중복된 로직을 제거하기 위해서 <DDD START!>에 나온 개념인 ServiceUtils를 활용할 수 있다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TeamServiceUtils {

	public static void validateTeamAdmin(TeamRepository teamRepository, Long memberId) {
		// 관리자가 아니면 Exception을 반환
	}

}

 

이렇게 되면 다른 도메인을 다루는 서비스에서는 TeamRepository를 주입받아 TeamServiceUtils.validateTeamAdmin( ) 혹은 TeamServiceUtils.validateTeamMember( )등을 호출할 수 있다.

각 서비스에 권한을 체크하는 로직이 퍼져있는 것보다는 훨씬 나은 구조라고 생각한다.

 

하지만 ServiceUtils를 사용하여 각 Service에서 권한을 체크하는 방법에는 몇 가지 단점이 있다고 생각하게 되었다.

1. 그래서 이 API는 어떤 권한을 가져야 호출할 수 있나요? 라는 질문에 대답하기 위해서는 그 API가 사용하고 있는 서비스 로직을 찾아 들어가 확인을 해주어야만 알 수 있다.

2. 각 서비스마다 권한 체크를 각각 해주다 보면, 구현과정에서 혹은 다른 권한으로 바꿔야 하는 과정에서 실수할 여지가 많아진다.

3. API가 20~30개일때는 괜찮을 수 있다. 하지만 기능이 점점 많아져 80개, 100개, 200개로 늘어나면 이 구조를 사용해도 괜찮을까에 회의감이 들었다.

4. 권한이 없을 때 작동하지 않음을 보장하기 위해 테스트를 작성할때 서비스 계층 테스트를 작성해야만 했다. 때문에 각 API별로 각기 다른 길고긴 테스트 코드를 작성해야 했다.

 

그래서 생각하게 된 다른 방법

서비스 계층 말고 Controller로 진입하기 전에 권한을 미리 체크할 수 있는 다른 방법을 생각하게 되었다.

크게 2가지로 좁혀졌는데
#1 Spring Security에서 AuthenticationManager와 AccessDecisionManager를 커스터마이징 한다.
#2 Interceptor를 사용해서 Controller로 들어오기 이전에 권한 체크를 한다.
가  있었다.

이 중 1번 대신 2번을 선택했던 이유는,

1. Spring Security 커스터마이징보다 인터셉터를 사용하는 방법이 훨씬 쉬웠고

2. 들어오는 Request에 따라 권한 체크가 달라져야 했는데, Spring Security를 사용하면 이 부분을 어떻게 해결할 수 있을지 머리에 바로 떠오르지 않았다.

 

들어오는 Request에 따라 권한 체크가 달라져야 했다는 말을 조금더 풀이하자면,

팀 관련 API 호출이 일어났고, 이 API 호출이 관리자에게만 허용되었다고 가정해보자. 그러면 요청이 들어왔을때 요청을 보낸 사용자가 특정 팀의 관리자인지 확인을 해야 하는데, 이때 HTTP Request body를 알아야만 한다.

HTTP Request body 를 열어 어떤 팀에 대한 호출인지 알아야, 그 팀에 그 멤버가 관리자인지 확인할 수 있기 때문이다.

 

그래서 구현이 쉽고 직관적이며 테스트도 쉬운 Interceptor를 이용한 권한 체크 구현에 돌입했다.

 

인터셉터에서 HTTP Request body 사용하기

인터셉터를 이용해 구현하더라도 한 가지 허들이 있었는데, 바로 Http Request의 Body를 가져오기 위해 사용해야 하는 request.getInputStream( )은 한 번 밖에 호출할 수 없다는 것이다. (톰캣에서 친히 막아두셨다는 썰이 에헴...)

실제 API의 로직을 실행하기 위해서는 Controller로 들어가는 Request객체에 HTTP Request Body를 파싱해서 넣어야 하니 만약 인터셉터에서 request.getInputStream( )을 호출하게 되면 뒤에 로직이 정상 실행될 수 없었다.

그래서 실제 들어온 Request객체를 getInputStream을 여러번 할 수 있는 Request 객체로 wrapping 해주는 작업이 필요했다. 여기저기 찾아보니 예시 코드들이 있었고, 그 중에 꼭 필요한 부분만 사용해 Wrapping 객체를 만들어 보았다.

그 과정을 하나씩 설명해보면...

1. 먼저 HttpServletRequestWrapper 객체를 상속받아야 한다.

@Slf4j
public class ReadableRequestWrapper extends HttpServletRequestWrapper {

}

 

HttpServletRequestWrapper 객체는 javax.servlet.http에 저장되어 있으며, HttpServletRequest를 구현하고 싶을때 편하게 할 수 있도록 도와주는 클래스이다.

더보기

원문 설명 : Provides a convenient implementation of the HttpServletRequest interface that can be subclassed by developers wishing to adapt the request to a Servlet. This class implements the Wrapper or Decorator pattern. Methods default to calling through to the wrapped request object.

 

2. 생성자를 구현한다.

@Slf4j
public class ReadableRequestWrapper extends HttpServletRequestWrapper {

	private static final int DEFAULT_BUFFER_SIZE = 1024 * 4;
	private static final int EOF = -1;

	private final Charset encoding;
	private byte[] rawData;

	public ReadableRequestWrapper(HttpServletRequest request) {
		super(request);
		String encoding = request.getCharacterEncoding();
		this.encoding = StringUtils.isEmpty(encoding) ? StandardCharsets.UTF_8 : Charset.forName(encoding);
		try {
			InputStream is = request.getInputStream();
			this.rawData = toByteArray(is);
		} catch (IOException e) {
			log.error("ReaderRequestWrapper에서 Stream을 열다가 IOException 발생", e);
		}
	}

}

 

HttpServletRequest를 받아서 상위 클래스에 올려주고 (대부분의 HttpServletRequest 인터페이스 구현은 상위에 되어 있다) encoding 정보를 가져오고 InputStream을 열어 byte 배열에 rawData로 저장해둔다.

이때 toByteArray(InputStream) 메소드가 사용되는데 원래는 apache IOUtils에 있던 유틸 메소드이다.

나는 기능 하나를 위해 의존성 하나를 가지는게 마음에 들지 않아 필요한 부분만 가져오게 되었다.

 

3. InputStream을 받아서 byte[] 반환하는 유틸성 메소드 구현

private static byte[] toByteArray(final InputStream input) throws IOException {
	try (final ByteArrayOutputStream output = new ByteArrayOutputStream()) {
		copyLarge(input, output, new byte[DEFAULT_BUFFER_SIZE]);
		return output.toByteArray();
	}
}

private static void copyLarge(final InputStream input, final OutputStream output,
                              final byte[] buffer) throws IOException {
	int n;
	while (EOF != (n = input.read(buffer))) {
		output.write(buffer, 0, n);
	}
}

 

4. getInputStream과 getReader Override

원래의 HttpServletRequest를 래핑하고 있는 ReadableRequestWrapper에서는 getInputStream을 몇 번 하던지 상관없이 원래의 HttpServletRequest의 getInputStraem과 동일한 결과를 반환하기 위해 getInputStream을 Override 한다.

또한, ServletRequest 인터페이스의 메소드인 getReader( )역시 추후 인터셉터에서 HTTP body를 가져오는데 사용되기 때문에 구현한다.

@Override
public ServletInputStream getInputStream() {
	final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.rawData);
	return new ServletInputStream() {
		@Override
		public boolean isFinished() {
			return false;
		}

		@Override
		public boolean isReady() {
			return false;
		}

		@Override
		public void setReadListener(ReadListener readListener) {
			// Do nothing
		}

		public int read() {
			return byteArrayInputStream.read();
		}
	};
}

@Override
public BufferedReader getReader() {
	return new BufferedReader(new InputStreamReader(this.getInputStream(), this.encoding));
}

 

5. 필터에서 조건부로 HttpServletRequest 를 래핑한다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class GlobalRequestWrappingFilter implements Filter {

	@Override
	public void init(FilterConfig filterConfig) {

	}

	@Override
	public void destroy() {

	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		if (httpRequest.getRequestURI().startsWith("/h2")) {
			chain.doFilter(request, response);
			return;
		}

		HttpServletRequest wrapper = new ReadableRequestWrapper((HttpServletRequest) request);
		chain.doFilter(wrapper, response);
	}

}


/h2/**는 Wrapping을 제외했는데, 해당 필터를 모든 요청에 대해 적용하고 보니 H2 웹 콘솔이 접속되지 않았기 때문이다.

자세히 살펴보지는 않았지만, Rest API가 아닌 View Rendering까지 해야 하는 Request라면 Wrapping에 주의가 필요해 보인다.

 

이제 인터셉터에서 HTTP Request body를 마음껏 열어 권한 체크를 할 수 있게 되었다.

인터셉터 구현과 테스트는 (기회가 된다면) 다음 시간에...

 

Reference

https://meetup.toast.com/posts/44

https://taetaetae.github.io/2019/06/30/controller-common-logging/