あけおめ☆ 年末年始を長期休暇にしたせいで、起ち上がりが悪い。エンジニアたるもの、そんな時はコーディングだ。Pythonでモチベーションを取り戻すんだ!
その気持ちだけでクールなシステムを考案・構築してしまったので、書いていきたいんだけど、物事には順序がある。まずは HTTP API Gateway に認証をつけるとこから、ブログの暖機運転なのだ。
おさらいと公式
前に、API Gateway の基本的な部分は↓↓に書きました。認証の仕組みがなかったので、まぁこんなこともできるよねって意味で、オレオレ認証も書いたんだけど、その後すぐに公式で対応されたわけです。
- API Gateway HTTP API が Lambda および IAM 承認オプションのサポートを開始
- HTTP API の AWS Lambda オーソライザーの使用 – Amazon API Gateway
社内用とはいえ、適当にやると負債になるので、まずはこの辺から整備していきます。
HTTP APIのAWS Lambdaオーソライザー とは
認証はいくつか方法があるのですが、ここではシンプルなLambda Authorizer を適用していきます。リリース情報とかドキュメントをパッと見、全っ然 意味わかんねーけど、実現する機能は至極簡単です。『HTTPヘッダの Authorization: $AuthValue が合致したらOKとする』
ただそれだけなのですが、任意で決めるヘッダの Key/Value や、その一致チェックをどこでやるかというと、Lambdaが行う、というのが特徴です。
処理順序としてはこんな感じ。
認証が通っていないリクエストがきたら、API G/W は HTTPリクエストの内容を含む情報を丸っと Lambda Authorizer に投げつけます。で、そのLambdaが任意で決めたヘッダの KeyとValue をチェックして、OK or NG のレスポンスを返します。OKならメインのLambdaを実行し、NGならそのまま401を返して終了です。
Lambdaの構成
つまり、2つのLambdaが出てくることになります。しかし、1つでやりくりすることも可能です。独立した Authorizer としての Lambda が認証結果を返してもいいし、認証とメイン処理の両役をこなす1つの Lambda にしても問題はありません。認証の仕組み自体は至極簡単なので、独立させるほどでもない── 邪魔だなと私は感じたため、その機能は class に落とし込み、Lambdaの最初に数行のコードと、環境変数を用いることで、1Lambdaで対応することにしました。
それが適正かどうかは、ぶっちゃけわかりません:-)
キャッシュ
認証結果は指定秒数キャッシュされるので、毎回 Authorizer としての処理が行われるわけではありません。通常はこれについて特になにかあるわけではないのですが、開発していて1Lambda構成だとちょっと面倒になることがありました。それは、メイン処理のコードに Syntax Error のようなエラーを入れてしまったときにアクセスすると、当然、認証もエラーになり、その結果がキャッシュされてしまいます。1800秒とかにしていると、わざわざそれを小さくして解く必要が出るわけです。
対策としては開発中は認証OFFにするか、秒数を小さめにするか── コードのフレームワークとして、よく変更していく部分はメインファイルと分けるか。そういう意味では、2つのLambdaでやることの方が正着かもしれません。
認証としての弱点
この Lambda Authorizer に限った話ではなく、Basic認証等と同じような弱点ですが── ヘッダで判別するだけなので、リクエストヘッダを知っているだけで認証を通れることになります。具体的には、URLのFQDN, GET/POST の内容ですね。HTTPS通信のみなので、通信途中で漏れることはないですが、社内の人間はアクセス方法として知るので、退職者になると部外者としてアクセスできてしまいます。
それに対しては、まずはそのシステムの重要性に対してセキュリティ強度が適正かちゃんと考えることです。重要度が低いならヘッダ認証かつ、要所で認証値変更をする程度でよいかもですし、高いならきっちりユーザーと紐付けた認証をすることになるでしょう。
そもそも認証ナシだとしても、URLのFQDNだけでも知ってる人しかアクセスできないわけで(DNSサーバーがガバガバだと抜かれやすくなるけど)、でもそれだけだとシンプルすぎるからヘッダというオマケをつけた程度と考えれば、そんなにセキュリティ強度としては高くないことはわかるでしょう。
Terraform
いつもどおり、Terraform で認証の設定をします。API G/W のコードは AWS API Gateway v2 の Terraform構成 にあるので、そこに1つだけ追加する感じです。aws_apigatewayv2_authorizer
authorizer_uri に認証用のLambdaを指定します。今回の場合は、二役こなすので新規Lambdaはナシです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
resource "aws_apigatewayv2_authorizer" "example" { name = "example" api_id = aws_apigatewayv2_api.example.id authorizer_type = "REQUEST" authorizer_uri = aws_lambda_function.example.invoke_arn authorizer_payload_format_version = "2.0" identity_sources = ["$request.header.Authorization"] enable_simple_responses = true authorizer_result_ttl_in_seconds = 300 # update default route, because $default is quick_create. provisioner "local-exec" { command = "aws apigatewayv2 update-route --authorization-type CUSTOM --api-id ${self.api_id} --authorizer-id ${self.id} --route-id $(aws apigatewayv2 get-ro utes --api-id ${self.api_id} --region ${local.region} | jq '.Items[] | select(.RouteKey == \"$default\").RouteId' -r) --region ${local.region}" } } |
local-exec でこねくり回しているのは、デフォルトルートに自動的に適用するためです。本当は、aws_apigatewayv2_route でルート追加時に authorizer_id を指定して紐付けるのですが、$default というルートは最初から自動作成されているので、Already exists になってしまいます。
なので、Authorizer 作成時にCLIを実行してアップデートしています。ルートをいっぱいイジる構成で $default を無視できるなら、普通に route 作成でやるべきでしょう。
私の場合は、開発中にリクエストパス関連でPythonとTerraform両方扱うのが嫌だったので、こっちは $default のみでやりくりする構成にしています。
functionの環境変数
既存functionに認証機能をつけるので、environment に認証値を追加します。AUTH_KEY は入れなければデフォルトの Authorization です。
1 2 3 4 5 6 7 8 9 10 11 12 |
locals { example_auth_value = "ExampleAuthValue" } resource "aws_lambda_function" "example" { function_name = "example" ~snip~ environment { variables = { AUTH_VALUE = local.example_auth_value } } } |
仮に2つのLambdaに分けるとしても、認証用の方で環境変数によって変えられるようにしておけば、複数のAPI G/Wで再利用することができます。
Python
lambda_handler
認証処理を受ける lambda_handler の最初で、こんなのを仕込むだけで認証対応できるようにします。
1 2 3 4 5 6 7 |
from modules.ApiGatewayV2Example import ApiGatewayV2Example def lambda_handler(event, context): api = ApiGatewayV2Example(event) auth = api.authorizer() if auth: return auth |
Python3.8 なら、代入式にして1行減らせますね。
Class
適当に必要な箇所をコピペしてきましたけど、認証処理はこんな感じ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
class ApiGatewayV2Example(): event = None def __init__(self, event=None): if event is not None: self.setEvent(event) def setEvent(self, event): if self.event is not None: return self.event = event ~snip~ def authorizer(self): AUTH_KEY = os.environ['AUTH_KEY'] if os.environ.get('AUTH_KEY') else "Authorization" AUTH_VALUE = os.environ['AUTH_VALUE'] if os.environ.get('AUTH_VALUE') else None auth_type = self.event.get("type") if auth_type != "REQUEST": return None header_key = AUTH_KEY.lower() header_value = self.event["headers"].get(header_key) if AUTH_VALUE is None or header_value is None: return self.responseUnauthorized() if AUTH_VALUE != header_value: return self.responseUnauthorized() return self.responseAuthorized() def responseAuthorized(self): res = { "isAuthorized": True, "context": {}, } return res def responseUnauthorized(self): res = { "isAuthorized": False, "context": {}, } return res |
まず、Authorizer としてのリクエストの場合、event.get(“type”) が REQUEST でくるので、そうじゃなければ認証処理に入らないようにします。
認証リクエストの場合、任意のKey/Value をヘッダのKey/Value と照らし合わせて、OK or NG のJSONを返します。
ね、簡単でしょ?
テスト用event
(2022/01/22 追記)ここ見直したら、別にテストイベントなくても認証ナシになるだけでした(テヘペロ)
前のオレオレ認証と混同した模様。
1Lambdaかつ管理画面でポチリたい時は、こんなテストイベントにしてあげるだけですが、少し面倒と感じるかも。
JSON自体は多分、あとで自分がコピペして使うので残しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ "version": "2.0", "routeKey": "$default", "rawPath": "/", "rawQueryString": "param1=value1¶m2=value2", "queryStringParameters": { "param1": "value1", "param2": "value2" }, "headers": { "accept-encoding": "gzip, deflate, br", "accept-language": "ja,en-US;q=0.9,en;q=0.8", "authorization": "ExampleAuthValue" } } |
管理画面の時だけスルーするような仕込みするとか、人それぞれ好きに仕込めばいいところではあります。
アクセス
このLambda Authorizer を仕込んだ API G/W に対して、どうやってアクセスするかですが──curl
curl だと簡単にヘッダを送れるので、こう。
1 |
curl -s https://api.example.com/ -H "Authorization: ExampleAuthValue" |
なにかのプログラムでAPIとして叩くなら、同じようにヘッダを付与してあげるだけです。
Chrome
今回はちょっとしたWEBサイトを作り込んでいく目的だったので、ブラウザでアクセスしたかったのです。WEBサーバーのBasic認証なら、User/Password を聞き返してきますが、これはそうはいかないため、どうするかというと──拡張機能 ModHeader を使うことにしました。
同様のヘッダを送るようにし、かつ、
Filter で URL Pattern = https:\/\/console.example.com
を追加することで、このURLにのみ任意のヘッダが送れるようになります。
ちょっとしたAPIとか管理画面のために、いちいちT系インスタンスを立ち上げたりするのはもうダルいんで、できるだけAPI G/W + Python3 でやっていくための下ごしらえでした。
ことよろ:-)