Cross Domain API 서버 구성 시 몇가지 삽질

최근 개발하고 있는 서비스는 API 서버들은 특정 기능만을 제공하고 화면에서의 기능은 대부분 React를 이용하여 Client Side에서 처리하고 있습니다. 이런 구성을 마이크로서비스 아키텍처라고 부를 수도 있을텐데 이번 글의 주제는 마이크로 서비스 아키텍처에 대한 이야기는 아니고 이런 구성시에 API 서버쪽 설정과 관련한 몇가지 삽질한 것에 대한 내용입니다. 웹이나 API 서버 개발을 계속 해오신 분들은 잘 아는 내용이겠지만 저처럼 간만에 하시는 분들을 위한 글입니다.

api_server_architecture

[그림] 시스템 기본 구성

CORS 이슈

위와 같은 시스템 구성에서 첫번째 만나는 문제는 HTTP를 이용하여 API 서버로 GET, POST와 같은 요청을 보낼때 그 페이지를 받은 도메인(여기서는 web.popit.kr)과 API를 서비스하는 서버의 도메인(member.popit.kr 또는 shop.popit.kr)이 다르기 때문에 다음과 같은 에러가 발생한다는 것입니다.

1
2
3
XMLHttpRequest cannot load http://membership.popit.kr/api/v1/login. 
No 'Access-Control-Allow-Origin' header is present on the requested resource. 
Origin 'http://web.popit.kr' is therefore not allowed access.

이 에러 메시지의 의미는 페이지 내에서 Ajax 호출 시 다른 도메인의 URL을 호출했다는 의미입니다. 다음 문서에 보면 브라우저에서 다른 도메인으로 요청을 보낼때에 대한 규약이 잘 설명되어 있습니다.

요약하자면 CORS(Cross-Origin Resource Sharing)라고 하여 도메인이 다른 서버에 대한 호출에 대한 정의 입니다. 브라우저는 다른 도메인으로 Ajax 등의 호출을 보내기 전에 다음과 같은 순서로 동작합니다.

  1. "preflight" 확인 요청을 OPTIONS method로 전송

    • 요청 시 Http Header의 속성으로 "Origin"에 자신의 도메인을 전송

  2. 이 요청을 받은 서버는 정상적인 요청인지 확인하여, 정상적인 요청이면 Response에 허용 가능한 도메인(Access-Control-Allow-Origin), Method(Access-Control-Allow-Methods), Header 속성(Access-Control-Allow-Headers)  등을 설정하여 응답
  3. OPTIONS 요청에 대해 수신을 받은 브라우저는 Header의 "Access-Control-Allow-*" 정보를 이용하여 요청을 보낼 수 있는지 판단하여 권한이 없는 경우 위와 같은 에러 처리를 하고 요청을 보낼 수 있으면 요청 전송

위와 같은 흐름에서 브라우저가 1, 3번은 처리를 자동으로 하기 때문에 개발자나 시스템 운영자는 2번에 대한 설정을 해야만 합니다. 2번을 해결하기 위해서는 서버측에서는 다음 두가지 설정을 해야 합니다.

  1. API로 공개되는 모든 URL에 대해 OPTIONS Routes 설정 및 Http Response 설정
  2. API URL에 대한 Http Response 설정

이를 위해 Spring을 사용하는 경우라면 Controller나 Method에 "@CrossOrigin" 어노테이션 추가만 하면 됩니다. 필자의 환경은 주로 Rails 인데 "rack-cors" gem을 사용할 수도 있지만 다음과 같이 자체 해결하였습니다.

  • OPTIONS Routes 설정 모든 API 요청에 대해 OPTIONS Route 설정을 위해서 routes.rb  파일에 match 를 이용하여 모든 URL에 대해 base_controller의 cors_preflight_check acrtion을 사용
    1
    2
    3
    4
    5
    6
    7
    namespace :api, defaults: {format:'json'} do
    namespace :v1 do
    post "members/signup" => "members#signup"
    get "members/signin" => "members#signin"
    end
    match "*all" => "base#cors_preflight_check", via: [:options]
    end
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    module Api
    class BaseController < ApplicationController
    before_action :cors_preflight_check
    after_action :cors_set_access_control_headers
    def cors_preflight_check
    if request.method == :options
    headers['Access-Control-Allow-Origin'] = request.headers['Origin'] || '*'
    headers['Access-Control-Allow-Methods'] = 'POST, PUT, GET, OPTIONS'
    headers['Access-Control-Allow-Headers'] = '*'
    headers['Access-Control-Max-Age'] = '1728000'
    head :ok
    end
    end
    end
    end
  • API 요청 처리에 대한 Http Response 처리 클라이언트와 약속된 Http Header의 Attribute에 대한 허용 옵션 등을 추가
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    module Api
    class BaseController < ApplicationController
    before_action :cors_preflight_check
    after_action :cors_set_access_control_headers
    def cors_preflight_check
    if request.method == :options
    headers['Access-Control-Allow-Origin'] = request.headers['Origin'] || '*'
    headers['Access-Control-Allow-Methods'] = 'POST, PUT, GET, OPTIONS'
    headers['Access-Control-Allow-Headers'] = '*'
    headers['Access-Control-Max-Age'] = '1728000'
    head :ok
    end
    end
    def cors_set_access_control_headers
    headers['Access-Control-Allow-Origin'] = '*'
    headers['Access-Control-Allow-Methods'] = 'POST, PUT, DELETE, GET, PATCH, OPTIONS'
    headers['Access-Control-Request-Method'] = '*'
    headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Secrete_Token'
    end
    end
    end

Spring의 "@CrossOrigin"이나 Rails의 rack-cors gem을 이용하면 간단하게 해결되겠지만, 이런것을 사용할 수 없는 상황에서는 동작하는 기본 원리와 무엇을 처리해야 하는지 알고 있어야 문제를 해결할 수 있을 것 같아서 자체 설정으로 해결해 보았습니다.

422 Unprocessable Entity 에러

여기까지 해결하고 잘 동작할 것이라 생각했는데 또 문제가 되는  것이 하나 있습니다. 원래는 CORS 문제 발생 전에 이 문제부터 먼저 겪게 됩니다. Rails로 서버를 실행하고 다른 도메인에 의해 가져온 html에서 Rails 서버로 API를 호출하게 되면 브라우저에서 다음과 같은 에러 메시지가 나타납니다.

1
2
3
4
5
OPTIONS http://membership.popit.kr/api/v1/login 422 (Unprocessable Entity)
XMLHttpRequest cannot load http://membership.popit.kr/api/v1/login. 
Response to preflight request doesn't pass access control check: 
No 'Access-Control-Allow-Origin' header is present on the requested resource. 
Origin 'http://web.popit.kr' is therefore not allowed access. The response had HTTP status code 422.

OPTIONS 가 전송되는 것은 앞에서 설명드린 CORS 관련 이슈이고, "422 Unprocessable Entity 메시지는 또 다른 이유때문에 발생하는 에러 입니다. Rails 서버 측에는 다음과 같은 에러 로그가 나타납니다.

1
2
3
4
5
6
7
8
9
10
11
Started OPTIONS "/api/v1/members/login" for ::1 at 2017-01-22 15:32:07 +0800
Processing by Api::BaseController#cors_preflight_check as JSON
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity in 1ms (ActiveRecord: 0.0ms)
ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):
actionpack (5.0.1) lib/action_controller/metal/request_forgery_protection.rb:195:in `handle_unverified_request'
actionpack (5.0.1) lib/action_controller/metal/request_forgery_protection.rb:223:in `handle_unverified_request'
actionpack (5.0.1) lib/action_controller/metal/request_forgery_protection.rb:218:in `verify_authenticity_token'
activesupport (5.0.1) lib/active_support/callbacks.rb:382:in `block in make_lambda'
activesupport (5.0.1) lib/active_support/callbacks.rb:169:in `block (2 levels) in halting'
actionpack (5.0.1) lib/abstract_controller/callbacks.rb:12:in `block (2 levels) in <module:Callbacks>'

Rails 서버는 CSRF(Cross Site Request Forgery)에 대한 처리를 위해 모든 Request에 대해 서버에서 Token을 발급하고 그 Token을 세션에 저장한 다음 Client로 부터 오는 GET method가 아닌 요청에 대해 해당 Token을 비교하여 다른 경우 위와 같이 CSRF 오류를 발생시킵니다. Rails 서버에서 Render된 HTML이라면 다음과 같은 코드를 삽입하고 Ajax 호출 시 Header에 해당 메타 정보에 있는 값을 추가해주면 되지만 도메인이 다른 경우라면 이렇게 사용할 수 없습니다.

<%= csrf_meta_tags %>

CSRF에 대한 자세한 내용은 아래 링크를 참고하세요.

도메인이 다른 경우에는 이렇게 Token을 비교해서 에러 처리하는 부분을 skip 하도록 구성해야 하는데 'controllers/api/base_controller.rb'에 다음과 같이 설정하여 CSRF 문제를 해결할 수 있습니다.

1
2
3
4
5
6
7
module Api
  class BaseController < ApplicationController
    # skip_before_action :verify_authenticity_token
    protect_from_forgery with: :null_session
    ...
  end
end

인터넷에서 검색해보면 주석처리된 "verify_authenticity_token"을 skip 하라는 대답도 있는데 이것은 모든 경우에 대해 skip 하는 것이고 "protect_from_forgery" 옵션으로 주어진 상황(여기서는 null_session)에 대해서 CSRF 보호 처리를 하라는 것이기 때문에 이것이 더 안전하다고 생각되어 이것으로 적용하였습니다.

이것은 문제의 완벽한 해결이라기 보다 회피라고 할 수 있는데 이렇게 회피하는 경우라 할지라도 서비스 운영 시에는 Cross Domain API 호출에 대한 CSRF 문제 해결 방법을 찾아내서 적용해야 할 것입니다. 저도 아직 해결은 못했고 고민 중에 있습니다. 해결 방법을 아시는 분은 알려주시면 감사하겠습니다.

HTTP Request Header에서 사용자 정의 Attribute 가져오기(Rails)

이렇게 웹 페이지에서 서로 다른 서버로 호출하는 경우 인증을 위해서는 HTTP Request Header에 인증을 위한 Token을 추가해서 요청을 보내는 경우가 많습니다. 이 경우 사용자 정의된 Key를 사용하게 되는데 흔히 다음과 같이 사용합니다.

1
2
3
4
5
6
$.ajax({
    headers: {
      'security-token': localStorage.getItem("security-token")
    },
    url: myUrl
});

물론 위와 같이 인증을 위한 속성인 경우 'Authorization' 와 같이 이미 정의된 key를 사용할 수 있지만 기존 많은 화면이 위와 같이 사용자 정의 key를사용하고 있어 어쩔수 없이 사용자 정의 속성을 이용해야 했습니다. 이런 상태에서 한참을 삽질했는데 내용은 다음과 같습니다.

Client에서 전송된 HTTP Request의 Header에 있는 속성 값을 읽기 위해서는 일반적으로 다음과 같이 합니다.

token = request.headers['security-token']

이런 구현에서는 무조건 nil 이 반환됩니다. 반면 다음과 같은 속성은 잘 가져 옵니다.

referer = request.headers['Referer']

한참을 검색한 결과 다음 Link로 가는 문서를 찾았는데 2004년 만들어진 CGI(Common Gateway Interface) 에 대한 표준 스펙입니다.

여기 보면

사용자 정의 Meta Variable은 반드시 대문자를 사용해야 하고, "HTTP_"로 시작하도록 처리되어야 하며 "-" 는 "_"로 변환되어야 한다.

라고 나와 있습니다.

즉 'security-token'과 같은 경우라면 'HTTP_SECURITY_TOKEN'으로 처리되어야 한다는 것입니다. Rails는 이 조건을 무조건 따르고 있기 때문에 서버 측에서 HTTP Request Header의 사용자 정의 속성의 키는 HTTP_<upper_case(user_defined_attribute_key)> 형태로 변환하여 Controller로 보내게됩니다. 즉 위와 같은 상황에서는 다음과 같이 처리해야 합니다.

token = request.headers['HTTP_SECURITY_TOKEN']

'Referer'의 속성은 정상적으로 잘 가져오는 이유도 여기에 있습니다.

마치며

아키텍처가 진화하고 보안에 대한 위협 다양한 위협이 존재하는 상황에서 Cross Domain에 대한 처리는 더 많아지고 있습니다. 이런 환경에서의 시스템도 그에 맞게 적절한 보안 구성이 필요하게 되었습니다. Cross Domain에서 정상적인 요청과 비정상적인 요청에 대한 판별 방법에 대해서 더 많이 고민해봐야 겠습니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.