ServerlessFrameworkでBasic認証を実装する

はじめに

Serverless Frameworkを使用していく中で、検証環境には
簡易な認証(例えば基本認証など)を設けたい場合がありました。

Lambdaを用いるため、Apacheなどのミドルウェアレイヤーで基本認証が設定できないとすればどこで設定するのが良いか検討した。
今回はlambdaでFlaskのようなPythonフレームワーク(WSGIベース)を動かす場合を考える。

Basic認証の実装方式について

Lambdaを使用する場合、「API Gateway+Lambda」構成にするか、「ALB+Lambda」構成のいずれかが考えられる。
それぞれどこで認証を設定できるか、どこが良いのかという結論として以下のようになると考えた。

ALBを経由する場合について(評価△)

現在はWSGIベースのフレームワークはALB経由でうまく動かすのはなかなか難しい状況だと思う。
ざっと調べた中では、以下で出来るかもしれない。
apig-wsgi https://pypi.org/project/apig-wsgi/

また、Serverless FrameworkでALBを設定すればうまくいくかもしれないが、そこまで試し切れていないので今期は△としました。
ざっと見た感じ、なかなか手順等を見つけられなかったのでやるとすれば一つ一つ試しながら検証していく形になると思う。(やり方等ご存じの方がいればコメントいただけますと助かります。)

この後記載するが、APIGatewayは特定のヘッダを変換してしまったりするため、EC2でやってた時のようにlambdaを使うならALB経由で出来るとすごく良いと感じた。

ALB経由の場合、下記のようなアプリモジュール内のBasic認証設定やCloudFrontを経由すれば、lambda@edgeをつかったBasic認証を実装できるハズ。

アプリモジュール内でのBasic認証実装例(flask-httpauthを使用)

from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {
    "user1": "pass1"
}

###
@app.before_request
@auth.login_required
def before_request():
    pass

@auth.verify_password
def verify_password(username, password):
    if username in users and \
            check_password_hash(users.get(username), password):
        return username

@app.route("/")
def hello():
    return "Hello, Flask!"

if __name__ == "__main__":
    app.run()

参考:https://flask-httpauth.readthedocs.io/en/latest/
参考:https://qiita.com/msrks/items/7de68cde6c3ab9d5e177

API Gateway経由でアプリモジュール内で実装する場合について(評価×)

上記で挙げたようなフレームワークやライブラリで用意されているBasic認証をAPI Gatewayで使えれば素早く実装できると考えた。
しかし、API Gatewayを経由する場合、APIGatewayから返ってくるレスポンスヘッダで一部のヘッダが置換されてしまうことが分かった。
Basic認証のポップアップを表示するためには、レスポンスヘッダで「WWW-Authenticate: ‘Basic realm=\”Enter username and password.\”‘」みたいな内容を返す必要がある。
ところが、実際には「WWW-Authenticate」ヘッダではなく、置換された「X-Amzn-Remapped-WWW-Authenticate」として返されていることを確認した。
そのため、Basic認証の入力ポップアップが表示されず、ブラウザから使用するにはとても面倒なことがわかった。

置換されたWWW-Authenticateヘッダ

このことはAWSも「Amazon API Gateway の重要な注意点」の中でも記載があった。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-known-issues.html

上記から、アプリモジュール内で実装されたBasic認証は現状難しい状況だと考えた。

API Gateway経由でオーソライザ(Cognito)で実装する場合について(評価△)

上記のようにAPI Gateway経由の場合、アプリモジュール内でBasic認証を実装するのは難しいとわかったが、
API Gatewayにはオーソライザという機能があり、ここで簡易な認証を行うことができる。
オーソライザで選択できるのは「Cognito」と「Lambda」の2択である。

では、Cognitoを使った認証でなぜ△にしたかといえば。
Cognitoでは今回のような簡易な認証で使用するような用途を想定していないと感じたためである。

Cognitoオーソライザが簡易でない理由

Cognitoを用いたオーソライザではCognitoで認証した際に発行されるトークンを使用して認証する。
(下記、オーソライザ設定画面。トークンの検証以外に選択肢はない。)

では、Cognitoのトークンをどのように取得するかだが、ここが手間がかかる部分になる。
具体的には「aws-sdk」などを使用してログイン情報(ID,Pass)をAWSに送信し返却されるトークンを使うことになる。
ただし、ログイン情報(ID,Pass)を入力するためにフォームなどは自分で用意しなければならない。
また、Cognitoのユーザは登録後にパスワード変更を行う必要があるなど、簡易認証で用いるにしては手順がかかりすぎると思った。

API Gateway経由でオーソライザ(lambda)で実装する場合について(評価〇)

こちらの方法については比較的容易に設定できると思う。(ソースをコピペすれば所要時間10分くらい)

手順1、lambdaファンクションを作成する

Rubyによる実装

ソースを検討している中で以下の記事を見つけました。
https://qiita.com/diaphragm/items/b87d700ef36fa62c1aa8

この記事に乗っているソースを一部変更すればすぐに使用できました。

■変更点

  • authorization ⇒ Authorization
  • ID/PAssをソースにべた書きする(原文ではKMSを使用してlambdaの環境変数に登録する仕様)
  • 原文のコメントは消しているのでそれぞれソースの意味を確認したい場合は原文参照
require 'json'
require 'base64'

def lambda_handler(event:, context:)
  if basic_auth(event)
    {
      'principalId': 'user',
      'policyDocument': {
        'Version': '2012-10-17',
        'Statement': [
          {
            'Action': 'execute-api:Invoke',
            'Effect': 'Allow',
            'Resource': event['methodArn']
          }
        ]
      }
    }
  else
    raise 'Unauthorized'
  end
end

def basic_auth(event)
  user = "user1"
  password = "pass1"

  auth_header = event['headers']['Authorization']
  auth_str = 'Basic ' + Base64.strict_encode64("#{user}:#{password}")
  auth_header == auth_str
end
Pythonによる実装

上記のソースと下記の記事を参考にオーソライザ用のpythonソースを書きました。
https://qiita.com/ijufumi/items/7a141a6528a28f180fb1

上記の記事からオーソライザ用のjsonをreturnするように変更しています。
また、Authorizationヘッダの取得方法も変更しています。
オーソライザ用のjson返却については、AWS公式を参考にしています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "execute-api:Invoke",
      "Effect": "Allow",
      "Resource": "arn:aws:execute-api:us-east-1:123456789012:ivdtdhp7b5/ESTestInvoke-stage/GET/"
    }
  ]
}

参考:https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html#api-gateway-lambda-authorizer-lambda-function-create

import json
import base64

accounts = [
    {
        "user": "user1",
        "pass": "pass1"
    },
    {
        "user": "user2",
        "pass": "pass2"
    }
    ]

def lambda_handler(event, context):

    authorization_header = event['headers']['Authorization']

    if check_authorization_header(authorization_header):
        return {
            'principalId': 'user',
            'policyDocument': {
                'Version': '2012-10-17',
                'Statement': [
                    {
                        'Action': 'execute-api:Invoke',
                        'Effect': 'Allow',
                        'Resource': event['methodArn']
                    }
                ]
            }
        }
    else:
        return 'Unauthorized'

def check_authorization_header(authorization_header: list) -> bool:
    if not authorization_header:
        return False

    for account in accounts:
        encoded_value = base64.b64encode("{}:{}".format(account.get("user"), account.get("pass")).encode('utf-8'))
        check_value = "Basic {}".format(encoded_value.decode(encoding='utf-8'))

        if authorization_header == check_value:
            return True

    return False

手順2、serverless.ymlを編集する

API Gatewayの認証にカスタムオーソライザを使用するようにserverless.ymlを変更します。
function:部分とresources:を編集/追記すればよい。

functions:
  app:
    handler: wsgi.handler
    events:
      - http:
          path: '/'
          method: any
⇒        authorizer:
⇒          name: basic-authentication
⇒          arn: <上記で作成したlambdaのarn>
⇒          type: request
      - http:
          path: '{proxy+}'
          method: any
⇒        authorizer:
⇒          name: basic-authentication
⇒          arn: <上記で作成したlambdaのarn>
⇒          type: request

resources:
  Resources:
    GatewayResponse:
      Type: 'AWS::ApiGateway::GatewayResponse'
      Properties:
        ResponseParameters:
          gatewayresponse.header.WWW-Authenticate: "'Basic realm=\"Enter username and password.\"'"
        ResponseType: UNAUTHORIZED
        RestApiId:
          Ref: 'ApiGatewayRestApi'
        StatusCode: '401'

function:部分ではAPI Gatewayのオーソライザを設定している部分です。

resources:部分ではオーソライザが401を返したときに「WWW-Authenticate」ヘッダを返却させるための設定です。ここで設定した項目は、デプロイ後にコンソール上(API Gateway > ゲートウェイのレスポンス > 権限がありません)から確認することができます。
この方法が現状API Gatewayで「WWW-Authenticate」ヘッダを返却するための唯一の方法だと思っています。

手順3、Serverless Frameworkでデプロイ

デプロイを実行すると、コンソール上から「オーソライザ」と「ゲートウェイのレスポンス」が設定されていることを確認できます。

おわりに

Severless FrameworkでFlaskのようなwsgiアプリケーションを開発する際の、Basic認証の実装を検討した。
本当ならALB経由でEC2と同じように使えればわかりやすいが、API Gatewayは少し癖があって実装までにいろいろ調べる必要があった。
結論としては今のところ、lambdaのオーソライザを用いるのが良いと感じた。

Spread the love