Table of Contents
このチュートリアルでは、『証明書に紐付いたアクセストークン』を活用して Amazon API Gateway 上に構築した API をこれまで以上に安全に保護する方法を紹介します。
OAuth アクセストークンが一度漏洩すると、攻撃者はそのアクセストークンをもって API にアクセスできます。従来のアクセストークンは電車の切符のようなもので、一度盗まれたら誰でも使えてしまいます。
この脆弱性はアクセストークンと同時にアクセストークンの正当な保有者である証拠も併せて提示することを API 呼出者に要求することで軽減することができます。その証拠は proof of possession と呼ばれ、よく PoP と短縮されます。使用時に PoP を必要とするアクセストークンは、搭乗時にパスポートの提示も併せて要求される国際線の航空券に似ています。
RFC 8705 (OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens) は PoP の仕組みを標準化しました。OAuth コミュニティーではその仕組みは MTLS と呼ばれていますが、混乱を避けるため個人的には『証明書バインディング』と呼んでいます。いずれにしても、簡潔に述べますと、その仕組みは、トークンエンドポイントからアクセストークンの発行を受ける際に使用した **X.509 証明書**と同じ証明書をアクセストークンと併せて提示することを API 呼出者に要求します。次の図は証明書バインディングの概念を示しています。
金融グレード API (英語名称 Financial-grade API)、通称 FAPI (ファピ) をご存知でしょうか。FAPI は、より高度な API セキュリティのために OAuth 2.0 と OpenID Connect の上に作られた技術仕様です。英国オープンバンキングはオープンバンキングプロファイルの基盤として FAPI を採用し、今では他国も追随しつつあります。ここで注目していただきたいのは、証明書バインディングが FAPI の必須技術要素となっている点です。
証明書バイディングの大前提は、API とクライアントアプリケーション間の接続が相互 TLS (Mutual TLS) を用いて確立されることです。相互 TLS では、TLS ハンドシェイク中にクライアントアプリケーション側も X.509 クライアント証明書の提示を要求されます。2020 年 9 月 17 日、AWS は "Introducing mutual TLS authentication for Amazon API Gateway" と題したブログで Amazon API Gateway が相互 TLS に対応したことを発表しました。Amazon API Gateway は金融グレード API セキュリティへの扉を開けました。
Amazon API Gateway は、API 保護の独自ロジックを開発者自ら実装する手段として **Lambda オーソライザー**という仕組みを提供しています。OAuth ベースの API 保護を提供する Lambda オーソライザーは、API コールからアクセストークンとクライアント証明書を取り出し、次の事項を確認します。
その後、オーソライザーは確認結果に応じて次のいずれかの処理をおこないます。
'Unauthorized'
を持つ例外を投げる。この後すぐにお見せすることになる Lambda オーソライザーの実装では、アクセストークンの検証処理を Authlete (オースリート) のイントロスペクション API (/api/auth/introspection
) に委譲します。そのため、そのオーソライザーの実装は複雑なロジックを含みません。次の図は Amazon API Gateway、Lambda オーソライザー、Authlete の関係を示しています。
これに加え、Authlete の Python ライブラリ内にある Authorizer クラスが必要な作業のほとんどを行うため、実装は非常に小さくできます。実際に、次のコードは証明書バインディングをサポートする Lambda オーソライザーの完全な実装例です。
from authlete.aws.apigateway.authorizer import Authorizer
authorizer = Authorizer()
def lambda_handler(event, context):
return authorizer.handle(event, context)
しかしながら、実際には、どのリソースがどのスコープを要求するかを設定する必要が出てくるでしょう。これは、(1)Authorizer
の handle()
メソッドに関数を渡す、もしくは(2)Authorizer
のサブクラスを作り determine_scopes()
メソッドをオーバーライドする、という方法で実現することができます。
関数とメソッドの双方とも、下表に挙げられている 4 つの引数を取り、リソースアクセスに必要となるスコープの名前のリストを返すことが求められます。
引数 | 説明 |
---|---|
event |
オーソライザーに渡されたイベント |
context |
オーソライザーに渡されたコンテキスト |
method |
リソースアクセスの HTTP メソッド |
path |
リソースのパス |
次の 2 つの例はどちらも同じ効果を持ち、time
リソースに HTTP GET
メソッドでアクセスする際には time:query
スコープが必要であると言っています。
from authlete.aws.apigateway.authorizer import Authorizer
authorizer = Authorizer()
def determine_scopes(event, context, method, path):
if method == 'GET' and path == 'time':
return ['time:query']
return None
def lambda_handler(event, context):
return authorizer.handle(event, context, determine_scopes)
from authlete.aws.apigateway.authorizer import Authorizer
class CustomAuthorizer(Authorizer):
def determine_scopes(self, event, context, method, path):
if method == 'GET' and path == 'time':
return ['time:query']
return None
authorizer = CustomAuthorizer()
def lambda_handler(event, context):
return authorizer.handle(event, context)
OAuth 2.0 関連の仕様に準拠するためには、場合によっては (例えば提示されたアクセストークンの有効期限が切れているとき) “401 Unauthorized” を API 呼出者に返すよう、Lambda オーソライザーから Amazon API Gateway に伝えなければなりません。AWS の技術文書やサンプルプログラムによれば、これを行うためにオーソライザーは 'Unauthorized'
というメッセージを持つ例外を投げなければなりません。しかし、このような簡素な例外は “Unauthorized” レスポンスに関する全ての貴重な情報を捨ててしまい、デバッグ作業をとても難しくしてしまいます。
そこで、デフォルトでは、別の言い方をすると policy
プロパティーが True
(デフォルト) の場合、Authorizer
の handle()
メソッドは、'Unauthorized'
例外を投げるべきである場合を含め、常に許可 (“Allow”) もしくは拒否 (“Deny”) を示す IAM ポリシーを返します。
もしも ”Unauthorized" や “Internal Server Error” の際に Authorizer
に例外を投げさせたければ、policy
プロパティーに False
を設定してください。Authorizer
のコンストラクターに policy=False
を渡すことで実現できます。
authorizer = Authorizer(policy=False)
Authorizer
の handle()
メソッドは IAM ポリシーを表す dict
インスタンスを返します。その辞書内には context
があり、その値も辞書となっています。Authorizer
はそこに幾つかの情報を埋め込みます。下表は、context
辞書に含まれる可能性のあるプロパティーの一覧です。
プロパティー | 説明 |
---|---|
introspection_request |
Authlete のイントロスペクション API へのリクエストを表す JSON 文字列 |
introspection_response |
Authlete のイントロスペクション API からのレスポンスを表す JSON 文字列 |
introspection_exception |
Authlete のイントロスペクション API コール時に発生した例外を表す文字列 |
scope |
提示されたアクセストークンがカバーするスコープ群をスペース区切りで列挙した文字列 |
client_id |
アクセストークンの発行対象のクライアントアプリケーションのクライアント ID |
sub |
クライアントアプリケーションへのアクセストークンの発行を許可したリソースオーナーのサブジェクト (主体識別子) を表す文字列 |
exp |
Unix エポック (1970 年 1 月 1 日) からの経過秒数で表現した、アクセストークンの有効期限終了日時 |
challenge |
エラー時に WWW-Authenticate HTTP ヘッダーの値として用いるべき値 |
action |
Authlete のイントロスペクション API のレスポンスに含まれる action の値 |
resultMessage |
Authlete のイントロスペクション API のレスポンスに含まれる resultMessage の値 |
Authorizer
のサブクラスで update_policy_context()
をオーバーライドすることにより、context
にエントリーを追加することができます。ただし、値として JSON オブジェクトや配列は使えないので注意してください。これは AWS の技術的制限事項です。
class CustomAuthorizer(Authorizer):
def update_policy_context(self, event, context, request, response, exception, ctx):
ctx.update({
'my_key': 'my_value'
})
Lambda オーソライザーが返すポリシーの context
に含まれるエントリー群は、後ほど他の場所で使うことができます。詳細は『Amazon API Gateway Lambda オーソライザーからの出力』を参照してください。
Authorizer
クラスはサブクラスの実装のために幾つかのフックを提供します。必要に応じてオーバーライドしてください。
メソッド | 説明 |
---|---|
determine_scopes() |
リソースアクセスに要求されるスコープ群を決定する |
update_policy_context() |
ポリシーに埋め込む context を更新する |
on_enter() |
handle() メソッド開始時に呼ばれる |
on_introspection_error() |
Authlete イントロスペクション API 失敗時に呼ばれる |
on_introspection() |
Authlete イントロスペクション API 成功時に呼ばれる |
on_allow() |
許可 (Allow) を表すポリシー生成時に呼ばれる |
on_deny() |
拒否 (Deny) を表すポリシー生成時に呼ばれる |
on_unauthorized() |
“Unauthorized” 用に例外が投げられる際に呼ばれる |
on_internal_server_error() |
“Internal Server Error” 用に例外が投げられる際に呼ばれる |
Lambda オーソライザーを作成するにあたり最も重要な点は、Lambda イベントペイロードで『リクエスト』を選ぶことです。さもないと、オーソライザーはクライアント証明書の情報にアクセスできません。それはアクセストークンがクライアント証明書と紐付いているかどうかをオーソライザーがチェックできなくなることを意味します。
Lambda イベントペイロードの選択によりオーソライザーへの入力がどのように変化するかの詳細については『Amazon API Gateway Lambda オーソライザーへの入力』を参照してください。
AuthleteApi のインスタンスが渡されない場合、Authorizer
のコンストラクターは内部で AuthleteApiImpl(AuthleteEnvConfiguration())
を実行してインスタンスを生成し、それを Authlete API へのアクセス時に使用します。そこで使用されている AuthleteEnvConfiguration が Authlete の設定情報を環境変数経由で取得できることを想定しているので、オーソライザーの実装として用いる Lambda 関数に次の環境変数群を設定しておく必要があります。
環境変数 | 説明 |
---|---|
AUTHLETE_BASE_URL |
Authlete サーバーのベース URL |
AUTHLETE_SERVICE_APIKEY |
サービスに割り当てられた API キー |
AUTHLETE_SERVICE_APISECRET |
サービスに割り当てられた API シークレット |
Lambda 関数のタイムアウトをデフォルト値よりも大きくしておくことをお勧めします。様々な条件が重なることにより、Authlete のイントロスペクション API の応答に時間がかかることがありうるためです。
Lambda 関数の ZIP パッケージの生成とアップロードの方法は『Python の AWS Lambda デプロイパッケージ』の『追加の依存関係を使用して関数を更新する』で説明されています。
下記は、authlete パッケージを含む Lambda オーソライザーの ZIP ファイルを生成してアップロードする例です。
~$ mkdir authorizer
~$ cd authorizer
~/authorizer$ vi lambda_function.py
~/authorizer$ pip install --target ./package authlete
~/authorizer$ (cd package; zip -r9 ../function.zip .)
~/authorizer$ zip -g function.zip lambda_function.py
~/authorizer$ aws lambda update-function-code --function-name authorizer --zip-file fileb://function.zip
このチュートリアルにおいて、テストが一番大変な箇所かもしれません。というのは、下図に示すテスト環境を構築するために多くの手順が必要だからです。
次の手順を一つずつ一緒に踏んでいきましょう。
認可サーバーとして java-oauth-server を使うことにしましょう。これは、Authlete をバックエンドサービスとして用いる Java で書かれた認可サーバーです。
$ git clone https://github.com/authlete/java-oauth-server
$ cd java-oauth-server
$ vi authlete.properties
$ docker-compose up
上記のコマンド群を実行すると、ローカルマシン上の http://localhost:8080
で認可サーバー (java-oauth-server) が起動します。
それから、管理者コンソールにログインし、認可サーバーに対応するサービスの設定を証明書バインディングをサポートするように変更します。
認可サーバー用の秘密鍵および自己署名証明書を作成します。
$ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 > server_private_key.pem
$ openssl req -x509 -key server_private_key.pem -subj /CN=localhost > server_certificate.pem
このチュートリアルで使用している openssl
コマンドは OpenSSL のものであることに注意してください。High Sierra 以降、macOS にインストールされている openssl
は LibraSSL のものなので、このチュートリアルのコマンドラインをそのまま試すためには OpenSSL の openssl
をインストールする必要があります。
$ /usr/bin/openssl version -a
LibreSSL 2.6.5
......
$ brew install openssl
$ /usr/local/opt/openssl/bin/openssl version -a
OpenSSL 1.1.1g 21 Apr 2020
......
java-oauth-server 自身は自分のエンドポイント群を TLS で保護していないので、TLS 接続を受けるためには java-oauth-server の前にリバースプロキシを置く必要があります。次のものは Nginx をリバースプロキシとして動かすための設定ファイルの例です。
events {}
http {
server {
# TSL 接続をポート番号 8443 で受け付けます。
listen 8443 ssl;
# PEM フォーマットのサーバー証明書
ssl_certificate /path/to/server_certificate.pem;
# PEM フォーマットのサーバー秘密鍵
ssl_certificate_key /path/to/server_private_key.pem;
# 相互 TLS を有効にします。optional_no_ca はクライアント証明書を要求するものの、
# それが信頼済み CA 証明書で署名されていることを要求はしません。このチュートリアルの
# 用途としてはこれで十分です。詳細については ngx_http_ssl_module のドキュメントを
# 参照してください: http://nginx.org/en/docs/http/ngx_http_ssl_module.html
ssl_verify_client optional_no_ca;
# 相互 TLS 接続のクライアント証明書を、背後にあるサーバー (java-oauth-server) に
# 'X-Ssl-Cert' HTTP ヘッダーの値として渡します。背後にあるサーバーのこの HTTP
# ヘッダーを認識できる必要があり、java-oauth-server は認識できます。正確には、
# java-oauth-server が利用している authlete-java-jaxrs ライブラリが認識します。
proxy_set_header X-Ssl-Cert $ssl_client_escaped_cert;
# Nginx が 'https://localhost:8443/token' で受けたリクエストを
# 'http://localhost:8080/api/token' (java-oauth-server の '/api/token') に
# 転送します。TLS はここで終端されます。
location = /token {
proxy_pass http://localhost:8080/api/token;
}
}
}
この設定により、Nginx は https://localhost:8443
で起動し、/token
で受け付けたリクエストを http://localhost:8080/api/token
に転送します。
この設定ファイルの名前が nginx.conf
の場合、次のコマンドにより Nginx を起動することができます。
$ nginx -c $PWD/nginx.conf
Nginx を止めるときは次のコマンドを入力してください。
$ nginx -s stop
開発者コンソールにログインし、テストで使用する予定のクライアントアプリケーションの証明書バインディングの設定を有効にしてください。
クライアントアプリケーション用の秘密鍵および自己署名証明書を作成します。
$ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 > client_private_key.pem
$ openssl req -x509 -key client_private_key.pem -subj /CN=client.example.com > client_certificate.pem
後ほどテストで使うので、秘密鍵と証明書をもう一組作成してください。コモンネーム (/CN=
) の値には別の値を指定するようにしてください。というのは、次のセクションで説明しますが、Amazon API Gateway のカスタムドメインの設定が同じサブジェクトの証明書を複数含むトラストストアを拒否するからです。
$ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 > client_private_key_2.pem
$ openssl req -x509 -key client_private_key_2.pem -subj /CN=client2.example.com > client_certificate_2.pem
本記事執筆時点では、Amazon API Gateway で相互 TLS を有効にするためには API にカスタムドメイン (例 api.example.com
) を割り当てなければなりません。
Amazon API Gateway のコンソールには『カスタムドメイン名』というメニューがあります。そこでのカスタムドメイン設定をスムーズにおこなうため、事前に次のものを用意しておいたほうがよいでしょう。
Amazon API Gateway のカスタムドメイン用のサーバ証明書は AWS Certificate Manager (ACM) の管理下に置かなければなりません。ACM コンソールでは、既存の証明書をインポートしたり新しく証明書を作成したりすることができます。しかし、相互 TLS が有効になっている場合、インポートした証明書は Amazon API Gateway のカスタムドメイン用としては使えないので、新しく作成してください。
相互 TLS を設定する際、信頼するクライアント証明書群を含むファイルの場所を入力するよう求められます。そのファイルはトラストストアと呼ばれます。Amazon API Gateway の相互 TLS の実装は、TLS ハンドシェイク中に提示されたクライアント証明書がトラストストア内に含まれているかチェックします。もしも含まれていなければ、Amazon API Gateway は Lambda オーソライザーを呼ぶことなく、その接続を拒否します。
トラストファイルは、PEM フォーマットのクライアント証明書群を下記のように列挙するテキストファイルです。
-----BEGIN CERTIFICATE-----
<証明書の内容>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<証明書の内容>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<証明書の内容>
-----END CERTIFICATE-----
...
このチュートリアル用のトラストストアは次のコマンドで作成できます。
$ cat client_certificate.pem > truststore.pem
$ cat client_certificate_2.pem >> truststore.pem
Amazon API Gateway が参照できるよう、トラストストアは S3 にアップロードする必要があります。
$ aws s3 cp truststore.pem s3://{あなたのS3バケット}
上記手順は『REST API の相互 TLS 認証の設定』に記載されています。詳細はそちらのドキュメントを参照してください。
Amazon API Gateway にカスタムドメインを登録するための準備が整いました。
『ドメインの詳細』で相互 TLS 認証を有効にしてください。そうすると、トラストストア URI を入力するフィールドが表示されます。あなたのトラストストアの S3 URI をそのフィールドに入力してください。
それから、『エンドポイント設定』でカスタムドメイン用のサーバ証明書を選択してください。
カスタムドメイン設定後、APIとステージの組をカスタムドメイン下のパスにどのようにマッピングするかを設定することができます。次のスクリーンショットでは、“Example” API の dev
ステージをカスタムドメインのパス dev
にマッピングしています。
カスタムドメイン設定の最後の手順は、カスタムドメインが『API Gateway ドメイン名』にルーティングされるよう、DNS サーバーの CNAME レコードを追加することです。
全ての準備が済みました。認可コードフローを用いて証明書に紐付くアクセストークンを発行してみましょう。
認可コードフローの最初のステップは、認可サーバーの**認可エンドポイントに Web ブラウザ経由で認可リクエスト**を送ることです。このチュートリアルでは、認可エンドポイントは java-oauth-server が提供する http://localhost:8080/api/authorization
です。認可リクエストを表す下記の URL の ${クライアントID}
をあなたのクライアントアプリケーションの実際のクライアント ID で置き換え、Web ブラウザを使ってその URL にアクセスしてください。
http://localhost:8080/api/authorization?response_type=code&client_id=${クライアントID}&scope=profile+email&state=123
Web ブラウザには認可サーバーが生成した認可ページが表示されます。次のように見えるでしょう。
ページにはログイン ID とパスワードを入力するフィールドがあります。そこに john
と john
を入力し Authorize ボタンを押してください。Web ブラウザはあなたのクライアントアプリケーションのリダクレクトエンドポイントにリダイレクトされます。
クライアントアプリケーションのリダイレクト URI をデフォルト値から変更していなければ、リダイレクトエンドポイントの URL は https://{Authleteサーバー}//api/mock/redirection/{サービスAPIキー}
です。
ブラウザのアドレスバーに表示されているリダイレクトエンドポイントの URL には、次のように code
レスポンスパラメーターが含まれています。
https://{Authleteサーバー}/api/mock/redirection/{サービスAPIキー}?state=123&code=RwRq2Lp0bJVMiLPKAFz4qB1hxieBD1X5HKuv8EPkJeM
code
レスポンスパラメーターの値は、あなたのクライアントアプリケーションに対して認可サーバーから発行された認可コードです。この認可コードはクライアントアプリケーションがトークンリクエストを投げる際に必要となります。
認可コード取得後、クライアントアプリケーションは認可サーバーの**トークンエンドポイントにトークンリクエスト**を投げます。このチュートリアルでは、トークンエンドポイントは Nginx が提供する https://localhost:8443/token
です。
トークンリクエストはシェル端末で curl
コマンドを用いて投げることができます。下記はトークンリクエストの例です。タイプする前に、${クライアントID}
と ${認可コード}
を実際のクライアント ID と認可コードに忘れずに置き換えてください。
$ curl -k --key client_private_key.pem --cert client_certificate.pem https://localhost:8443/token -d grant_type=authorization_code -d client_id=${クライアントID} -d code=${認可コード}
引数 | 説明 |
---|---|
-k |
サーバー証明書の検証をしない。このチュートリアルでは自己署名サーバー証明書を使っているので、このオプションが必要。 |
--key client_private_key.pem |
クライアントの秘密鍵を指定する。 |
--cert client_certificate.pem |
クライアントの証明書を指定する。 |
https://localhost:8443/token |
トークンエンドポイントの URL。 |
-d grant_type=authorization_code |
認可コードフローであることを示す。 |
-d client_id=${クライアントID} |
クライアント ID を指定する。${クライアントID} を実際のクライアント ID で置き換えること。 |
-d code=${認可コード} |
認可コードを指定する。${認可コード} を実際の認可コードで置き換えること。 |
ここでポイントとなるのは、--key
オプションと --cert
オプションを用いてクライアントの秘密鍵と証明書を curl
コマンドに渡すことです。トークンエンドポイントが相互 TLS を要求するため、すなわち TLS ハンドシェイク中にクライアント証明書を要求するためです。トークンエンドポイントから発行されるアクセストークンは、トークンリクエストで使用されたクライアント証明書に紐付けられます。
トークンリクエストが成功すると、トークンエンドポイントは access_token
を含む JSON を返します。
{
"access_token": "b5qgqkXpzObRyceBqKeGPDCT9NX9GGXSt_oSYBSj7GQ",
"refresh_token": "1iSpvpeznTzwdUJzwRbt-abqE4znWn_yhN5PbBKV9zw",
"scope": "email profile",
"token_type": "Bearer",
"expires_in": 86400
}
access_token
プロパティーの値が発行されたアクセストークンです。クライアントアプリケーションは API コール時にこのアクセストークンを使います。
ついに、証明書に紐付くアクセストークンで保護される Amazon API Gateway 上のリソース (API) にアクセスする準備が整いました。
最初に、アクセストークンと正しいクライアント証明書でリソースにアクセスしてください。下記の例の ${アクセストークン}
、${カスタムドメイン}
、${リソース}
は実際の値で置き換えてください。全てが全て正しく設定されていれば、ブロックされることなくリソースの取得に成功するでしょう。
$ curl --key client_private_key.pem --cert client_certificate.pem -H "Authorization: Bearer ${アクセストークン}" https://${カスタムドメイン}/${リソース}
次に、同じアクセストークンと不正なクライアント証明書 (このチュートリアルでは client_certificate_2.pem
) でリソースにアクセスしてください。
$ curl --key client_private_key_2.pem --cert client_certificate_2.pem -H "Authorization: Bearer ${アクセストークン}" https://${カスタムドメイン}/${リソース}
次のエラーレスポンスを受け取ることでしょう。
{"Message":"User is not authorized to access this resource with an explicit deny"}
これは Amazon API Gateway がリソースアクセスを拒否したことを示しています。ここで最も重要なのは、「同時に提示したクライアント証明書がアクセストークンに紐付くものとは異なるため、アクセストークンが正当であるにもかかわらずリソースアクセスが拒否された」ということです。これが証明書バインディングです。
あなたはこのチュートリアルを完了し、証明書バインディング (RFC 8705) を活用してこれまで以上に安全に Amazon API Gateway 上の API を保護することができるようになりました!
サポートが必要であれば**お問い合わせ**ください。いつでも歓迎します!