業務改善の中で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はプラグインや設定が多く中々使いこなすのが大変ですが、その分いろいろな事ができるので便利ですね!
今後も色々な設定を試していってみたいと思います。