Serverless FrameworkでCORSに対応しつつ、認証にCustom Authorizerを使ってみる

記事タイトルとURLをコピーする

業務改善の中でChromeExtensionを作成中に、API GatewayのCORS対応をしたのでその内容をご紹介します。

CORSとは?

CORSとはCross Origin Resource Sharingのことで、とあるWebアプリケーションから、そのアプリケーションとは異なるドメインにリクエストを行うときの取り決めです。
今回は、ユーザーが表示しているWebページ(google.com)から自分が作成したAPI(xxx.execute-api.ap-northeast-1.amazonaws.com)を叩く、というようなChromeExtensionを開発していました。
このケースの場合、WebページのドメインとAPIのドメインは異なるのでCORS対応を行わない場合には、ブラウザによってレスポンスが捨てられます。

こんなときは、アクセスされる側(今回のケースではAPI Gateway)からレスポンスを返す時に、Headerに Access-Control-Allow-Origin や、Access-Control-Allow-Headers を含めることで「このドメインからの、このヘッダーを含むリクエストなら受けてもOK」を示すことができ、これによりブラウザとAPI間で正しい通信ができるようになります。

Custom Authorizerとは?

Custom Authorizerとは、AWSのAPI Gatewayに用意されている認証・認可の仕組みです。API Gatewayのリクエストの処理が行われる前に、CognitoまたはLambdaによる認証・認可の処理を実装することができます。今回はLambdaで実装しました。

本題

というわけで、今回はServerless Frameworkを使った時のCORSとCustom Authorizerの実装方法のサンプルです。
リポジトリは以下です。

https://github.com/galactic1969/serverless-auth-cors-sample

serverless.ymlの抜粋を記載します。


functions:
  sampleGet:
    name: ${self:provider.stage}-${self:service}-sample-get
    handler: src/get.main
    timeout: 30
    events:
      - http:
          path: /sample/
          method: get
          authorizer:
            name: custom-authorizer
            identitySource: method.request.header.Authorization, context.identity.sourceIp
            type: request
          integration: lambda
          cors:
            origin: '*'
            headers:
              - Authorization
  samplePost:
    name: ${self:provider.stage}-${self:service}-sample-post
    handler: src/post.main
    timeout: 30
    events:
      - http:
          path: /sample/
          method: post
          authorizer:
            name: custom-authorizer
            identitySource: method.request.header.Authorization, context.identity.sourceIp
            type: request
          integration: lambda
          cors:
            origin: '*'
            headers:
              - Authorization
              - Content-Type
  custom-authorizer:
    name: ${self:provider.stage}-${self:service}-custom-authorizer
    handler: src/custom_authorizer.main
    timeout: 30
    environment:
      API_AUTH_KEY: ${self:custom.confFile.${self:provider.stage}.api.auth_key}
      API_ALLOW_IP: ${self:custom.confFile.${self:provider.stage}.api.allow_ip_address}

CORS対応については、 cors: 以下にorigin(Access-Controll-Allow-Origin)とheader(Access-Controll-Allow-Headers)を書けばOKです。
なお、CORS対応を行うと、API Gatewayのリソースにpreflight用のOPTIONSメソッドが実装されます。これはMOCKで実装されているため、OPTIONSメソッド用のLambdaがデプロイされるようなことはありません。

Custom Authorizerについては、Functionにauthorizerの設定を記載し、別途authorizer用のFunctionもserverless.ymlに追加します。


import logging

from src.utils.env import get_secret_env

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

valid_token = get_secret_env('API_AUTH_KEY')
valid_ip_address = list(map(lambda ip: ip.strip(), get_secret_env('API_ALLOW_IP').split(',')))


def main(event, context):
    valid = False
    source_ip = event['headers']['X-Forwarded-For'].split(',')[0].strip()
    token = event['headers']['Authorization']

    if valid_token == token and source_ip in valid_ip_address:
        valid = True
        logger.info('request is valid')
    else:
        logger.info('request is not valid')

    if valid:
        return {
            'principalId': 1,
            'policyDocument': {
                'Version': '2012-10-17',
                'Statement': [
                    {
                        'Action': 'execute-api:Invoke',
                        'Effect': 'Allow',
                        'Resource': 'arn:aws:execute-api:*:*:*/*/*/*'
                    }
                ]
            }
        }
    else:
        return {
            'principalId': 1,
            'policyDocument': {
                'Version': '2012-10-17',
                'Statement': [
                    {
                        'Action': '*',
                        'Effect': 'Deny',
                        'Resource': 'arn:aws:execute-api:*:*:*/*/*/'
                    }
                ]
            }
        }

今回のサンプルでは、IP制限とアクセスキーの2つの要素で認証を実装してみました。なお、source_ipを代入するところで、 event['headers']['X-Forwarded-For'].split(',')[0].strip() としているのは、X-Forwarded-For にはClientのIPアドレスと、CloudFrontのIPアドレスの2つが入っているためです。

APIのテスト

※このテスト(リポジトリ)では access-control-allow-origin が * となっていますが、実際は環境に応じて適切なoriginに書き換えてください

まずはGET。問題ありません。

curl --request GET \
  --url 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/sample?param1=foo&param2=bar' \
  --header 'authorization: xxxauthxxx' \
  --dump-header -
HTTP/2 200 
content-type: application/json
content-length: 50
date: Thu, 29 Aug 2019 07:47:09 GMT
x-amzn-requestid: xxxxxxxxxxx
access-control-allow-origin: *
access-control-allow-headers: Authorization
x-amz-apigw-id: xxxxxxxx=
x-amzn-trace-id: Root=xxxxxxxxx;Sampled=0
x-cache: Miss from cloudfront
via: 1.1 xxxxxxxxxxx.cloudfront.net (CloudFront)
x-amz-cf-pop: xxxxx-xx
x-amz-cf-id: xxxxxxxxxxxxxxxxx==

{"result": "ok", "param1": "foo", "param2": "bar"}

次にPOST。ちゃんと返ってきてます。GETと違って、access-control-allow-headers にContent-Typeが入っています。(これは、Lambdaからレスポンスを返す時に指定しています。)

curl --request POST \
  --url https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/sample \
  --header 'authorization: xxxauthxxx' \
  --header 'content-type: application/json' \
  --data '{"param1": "foo","param2": "bar"}' \
  --dump-header -
HTTP/2 200 
content-type: application/json
content-length: 50
date: Thu, 29 Aug 2019 07:45:27 GMT
x-amzn-requestid: xxxxxx
access-control-allow-origin: *
access-control-allow-headers: Authorization, Content-Type
x-amz-apigw-id: xxxxxxxxxx=
access-control-allow-methods: POST
x-amzn-trace-id: Root=xxxxxxxxxxxxxxxxxxxxxx;Sampled=0
x-cache: Miss from cloudfront
via: 1.1 xxxxxxxxxxxx.cloudfront.net (CloudFront)
x-amz-cf-pop: xxxxx-xx
x-amz-cf-id: xxxxxxxxxxxxxx==

{"result": "ok", "param1": "foo", "param2": "bar"}

最後に、OPTIONSです。Webアプリケーションから行うリクエストの内容によっては、preflight requestというものをOPTIONSメソッドで直前に投げ、期待する結果が返れば本来のリクエストが投げる、という手順を踏む必要があります。preflight requestの詳細については、 こちら をご確認ください。

curl --request OPTIONS \
  --url https://hj78zzynmb.execute-api.ap-northeast-1.amazonaws.com/dev/sample \
  --dump-header -
HTTP/2 200 
content-type: application/json
content-length: 0
date: Fri, 23 Aug 2019 09:37:41 GMT
x-amzn-requestid: xxxx
access-control-allow-origin: *
access-control-allow-headers: Authorization,Content-Type
x-amz-apigw-id: xxx=
access-control-allow-methods: OPTIONS,POST,GET
access-control-allow-credentials: false
x-cache: Miss from cloudfront
via: 1.1 xxxx.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT51-C3
x-amz-cf-id: xxxx==]

最後に、認証失敗のケースです。ちゃんとレスポンスヘッダーに「access-control-allow-origin」と「access-control-allow-headers」が入っていることが確認できます。 これは、 apigw.yml にてAPI Gatewayのレスポンスタイプを定義しているため、これらのヘッダーがレスポンスに含まれています。レスポンスタイプを定義しない場合、400系や500系のエラーの際にレスポンスにCORS系のヘッダーが入らず、クライアント側での切り分けが難しくなります。(サーバー側は400系や500系を返しているが、CORSに準拠していないレスポンスをブラウザ側が受け取る際に拒否してしまうため)

curl --request POST \
  --url https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/sample \
  --header 'authorization: xxxauthxxxx' \
  --header 'content-type: application/json' \
  --data '{"param1": "foo","param2": "bar"}' \
> --dump-header -
HTTP/2 403 
content-type: application/json
content-length: 60
date: Fri, 30 Aug 2019 01:12:47 GMT
x-amzn-requestid: xxxxxxx
access-control-allow-origin: *
access-control-allow-headers: *
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: xxxxx=
x-cache: Error from cloudfront
via: 1.1 xxxxxx.cloudfront.net (CloudFront)
x-amz-cf-pop: xxxx-xx
x-amz-cf-id: xxxxxxxxxxxxxxx=

{"message":"User is not authorized to access this resource"}

まとめ

というわけで、Serverless FrameworkでCORSに対応しつつ、認証にCustom Authorizerを使ってみる話でした。
Serverless Frameworkはプラグインや設定が多く中々使いこなすのが大変ですが、その分いろいろな事ができるので便利ですね!
今後も色々な設定を試していってみたいと思います。