이전글 [https://techforme.tistory.com/48]
이전글에서 서버를 이중화 하면서 authorization_request_not_found 오류가 나는 상황에 대한 해결책으로 ip hash 방식의 부하분산을 이야기 했었다. 이는 간단한 해결책이긴 했지만, 만약 부하 분산 방식을 round robin 으로 바꾸라는 요구사항이 생긴다면, 전혀 대응할 수 없는 미봉책이었다. (사실 Nginx 를 더 활용해 보고 싶어서 해봤다.) 근본적인 해결책이 필요했다.
이번 게시글에서는 OAuth Authorization 요청 정보를 서버 내부에 저장하지 않고 외부에 저장함으로써, Nginx 의 부하 분산 알고리즘과 상관없이 해당 문제를 해결할 수 있는 방법을 설명한다.
1. 문제 상황 분석
1) 문제 발생 flow
이전 글에서 문제의 원인을 Authorization 요청에 대해 요청 정보를 서버 내부에 저장하기 때문이라고 분석하였다. 이를 조금 더 자세히 뜯어볼 필요가 있다.
OAuth 로그인 과정은 유저(리소스 오너) 가 백엔드 서버의 특정 엔드포인트에 OAuth 요청을 보냄으로써 시작된다. 요청이 들어오면 서버에서는 OAuth 요청 정보가 OAuth2AuthorizationRequest 라는 객체의 형태로 생성된다. 해당 객체는 클라이언트키, 인가 서버에 요청할 정보, 요청과 응답의 uri 주소 등 요청과 관련된 세부적인 사항들을 전부 다 담고 있다.
요청 정보 객체가 생성되고 세션에 저장 된 후, 유저는 OAuth 로그인을 거치게 되고, 로그인이 무사히 이루어 지면 OAuth 인증 서버는 미리 지정된 리다이렉트 url(백엔드 서버) 에 인가 코드를 보내주게 된다. 이렇게 다시 돌아온 요청에는 인가 코드와 함께 "state" 값이 쿼리 파라미터 형태로 돌아온다. 여기서 "state" 값은 새로 생성된 값이 아니라 사용자의 요청을 인증서버로 리다이렉션 할 때 랜덤하게 생성하여 넣어주는 값으로, OAuth2AuthorizationRequest 객체에 담겨 있다.
백엔드 서버에서는 전송 받은 state 값을 통해 저장해 놓은 OAuth2AuthorizationRequest 를 식별하여 요청 정보를 로드한다. 문제의 원인은 해당 객체가 서버 세션에 저장된다는 것이다. 때문에 인가 코드를 요청 정보가 저장된 서버가 아닌 다른 서버에서 받게 되면 state 값을 통해 요청 정보를 식별할 수 없게 되고, authorization_request_not_found 예외가 터지는 것이다.
2) state 의 역할
그런데 여기서 의문을 가져야할 지점이 있다. 왜 굳이 요청 정보를 저장해놓고 있을까? 사실 요청 정보의 대부분은 미리 설정해둔 yml 에서 로드해 오는 것으로 굳이 필요가 없을 수도 있다. 그것도 아니라면 필요한 정보들을 요청에 고스란히 담아놓는 다면 stateless 하게 동작할 수도 있지 않은가?
그 이유는 사실 state 의 역할이 단순히 요청정보를 식별해주는 식별자가 아니라, Authorization code injection 이라는 csrf 공격에 대한 대응책이기 때문이다. state 가 없다면 아래와 같은 공격이 가능해진다.
** state 가 없을 시 CSRF 공격 시나리오 (Authorization code injection) **
공격자는 이메일 등을 통해 유저에게 악성 링크를 전달. 유저는 해당 링크를 통해 OAuth 로그인을 하게되고 공격자는 인가 code 탈취함. 탈취한 인가 code 를 공격대상이 되는 서버의 엔드포인트에 전달.(code injection) state 값을 식별하지 못하는 백엔드 서버는 해당 요청을 정상적인 요청으로 인식하여 인증 서버에 토큰 발급을 요청. 인증서버는 리소스에 접근할 수 있는 토큰을 전달하고, 백엔드 서버는 해당 토큰을 악성 유저에게 리다이렉트(또는 자체 생성한 jwt 를 전달) 이 과정에서 만약 state 가 있다면 백엔드 서버가 공격자로부터 인가 코드를 inject 받았을 때 백엔드 서버의 요청이 아님을 인지할 수 있어 공격이 차단된다.
이러한 이유로 state 값은 서버에서 안전하게 보관하고 있어야 한다. 스프링 시큐리티의 경우도 사용자의 요청을 인증 서버로 리다이렉트 하기 전에 state 값을 생성하여 OAuth2AuthorizationRequest 에 저장하며, 유저가 OAuth 로그인을 마치고 다시 돌아오는 요청에 담겨있는 state 을 통해 OAuth2AuthorizationRequest 를 식별하여 이 요청이 안전한 것임을 식별하게 되는 것이다.
지난 글에서의 상황을 보자. 스프링 서버는 리다이렉트 되어 돌아온 요청을 받고 세션 저장소에서 state 값과 일치하는OAuth2AuthorizationRequest 를 찾으려 했을 것이다. 그러나 해당 값은 다른 서버에서 저장을 하고 있기 때문에 요청을 찾을 수 없다는 authorization_request_not_found 오류가 발생하게 되는 것이다.
2. 문제 해결
스프링 시큐리티는 OAuth2AuthorizationRequest 를 저장하는 방식을 달리할 수 있도록 AuthorizationRequestRepository 라는 인터페이스를 제공한다. 해당 인터페이스의 load, save, remove 세가지 메서드를 구현하여 SecurityChainFilter에 추가하면 된다.
사실 여기서 레퍼런스를 찾아보았는데 다른 기술 블로그들은 한결 같이 쿠키를 이용한 해결책을 제시한다. 생성된 OAuth2AuthorizationRequest 정보를 직렬화 하여 쿠키로 담아 요청에 실어보내게 되면, 인증 서버는 다시 해당 쿠키를 보내올 것이므로, 이를 역직렬화하여 요청 정보를 불러올 수 있는 것이다.
그런데 이렇게 하게 되면 완전한 stateless 의 구현이 될 수 있으나, state 의 본래의 목적이 지워지게 된다. 다른 보안 대책이 있다면 이러한 방식도 괜찮을 수 있겠으나, 대부분의 경우 별다른 보안 대책 없이 쿠키 전달 방식을 채택하고 있는 것 같다(...) 앞서 기술한 보안 위협들에 대해서도 인지하고 있는 것 같아 보이는데, 왜 그런 방식을 택하는 것인지 잘 모르겠다. 내 로그인 정보로 인젝션을 시도해 보고 싶은 생각이 든다. 아마 다른 대책이 있겠지. 나는 Redis 를 사용하여 해당 정보를 짧은 시간 동안(1 초) 저장해두어 state 값의 본래 취지를 살리도록 해결책을 세웠다.
3. 작성코드
OAuth2AuthorizationRequest 를 보관하는 AuthorizationRequestRepository 구현체를 아래와 같이 작성하였다.
AuthorizationRequestRepository 구현체
public class CustomAuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private final RedisTemplate<String, OAuth2AuthorizationRequest> rt;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return rt.opsForValue().get(request.getParameter("state"));
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
rt.opsForValue().set(authorizationRequest.getState(), authorizationRequest, Duration.ofSeconds(1)); // 만료시간을 짧게 지정함
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return rt.opsForValue().get(request.getParameter("state"));
}
// Deprecated 상태
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return null;
}
}
여기서 참고 해야할 점은 Redis Template 설정인데 원래 범용적인 목적으로 빈으로 등록한 RedisTemplate 은 직렬화 도구로 Jackson2JsonRedisSerializer 을 적용하였다. 그러나 여기서는 직렬화 도구로 JdkSerializationRedisSerializer 를 설정해야한다. Jackson2JsonRedisSerializer 는 객체를 Json 문자열로 직렬화 시키는 도구로, 다양한 타입의 참조 객체를 포함하는 OAuth2AuthorizationRequest 를 직렬화 하는데는 적합하지 않기 때문이다.
HttpSecurity 체인에 아래와 같이 추가하면 된다.
.authorizationEndpoint()
.authorizationRequestRepository(authorizationRequestRepository)
4. 결론
위와 같은 방식으로 해결하여, 카카오 인가 코드를 어느 서버가 받게 되든 잘 동작하도록 해결하였다.
사실 이와 관련하여 현직자의 이야기를 들었는데, 현직에서는 보통 이런식의 인가코드를 받는 과정을 모두 프론트에서 진행하고 백엔드에서는 jwt 만 전달 받아, 단순히 인증 서버에 jwt 를 찔러주는 식으로 리소스를 받아온다고 한다. 이렇게 하면 위와 같이 state 를 검증하는 과정이나 백엔드 서버에서 생성하는 jwt 전달 관련 문제도 사라지게 된다. 초기에는 리다이렉트를 백이 아닌 프론트 콜백 주소로 설정하여 프론트에서 인가 코드만 취하여 다시 백엔드로 요청을 보내는 플로우로 구현하였었는데 이 편이 현직에서 구현하는 방식과는 조금 더 유사한 게 아닌가 생각이 들었다.
보안 관련 문제는 프로젝트 내내 조금씩 자주(?) 발생해왔다. 그때마다 귀찮지만 재미있는 과제를 주는 것 같다. 이번에도 보안 관련 정책에 대해서 더 공부하고 OAuth 인증 플로우에 대해서 또 생각해보는 계기가 되었다.