🐨

M1 MacでAWS Lambda と Auth0 を使用したカスタム認証

2023/01/09に公開

AWS Lambda と Auth0 を使用した APIGatewayのカスタム認証を実装しました。
全体のソースコードはgithubに載せています。
M1チップのMacで作業するため、Arm64で動かせる形で作成しました。
カスタム認証について以下の記事を参考にさせていただきました。
API Gateway + Web Socket で Auth0 の認証をするカスタムオーソライザーをつくってみた

記事の概要

  • M1 MacでServerless Framework を使って AWS Lambda をデプロイする
  • API GatewayにAuth0のJWT(JSON Web Token)を使用したカスタム認証を設定する

Auth0 の概要

Auth0はJWT(JSON Web Token)を使用した認証をサポートしたクラウドベースの認証プラットフォームです。
https://auth0.com/

事前準備

準備として、以下のものが必要です。

  • Node.js: Serverless Framework を使用するには、Node.js が必要です。
  • AWS アカウント: Serverless Framework を使用するには、AWS のアカウントが必要です。
  • AWS アクセスキーとシークレットアクセスキー: Serverless Framework から AWS のサービスを呼び出すには、アクセスキーとシークレットアクセスキーが必要です。
    • IAMでロールを作成し、アクセスキーとシークレットアクセスキーを控えます。
      AWS CLI を設定する環境変数
    • コンソール上で以下のコマンドを実行することで環境変数の設定が可能です。
export AWS_ACCESS_KEY_ID=xxxxx
export AWS_SECRET_ACCESS_KEY=xxxxx
export AWS_DEFAULT_REGION=ap-northeast-1
  • Auth0のアカウント: 認証にAuth0を使用します

  • Serverless Framework のインストール

    • Serverless Framework を使用するには、まず、Node.js をインストールし、その上で、以下のコマンドを実行して、Serverless Framework をグローバルインストールします。
npm install -g serverless

プロジェクトの作成

Serverless Framework では、プロジェクトを作成するとともに、必要なリソースを自動で構築することができます。以下のコマンドを実行することで、新しいプロジェクトを作成できます。

sls create --template aws-python3 --path my-project

このコマンドを実行すると、my-project という名前のディレクトリが作成され、その中に、サンプルの AWS Lambda 関数のファイルhandler.pyや、serverlessの設定ファイルserverless.ymlなどが配置されます。

Lambda 関数のコード

Auth0 の JWT トークンを検証する関数や、API Gateway のポリシーを生成する関数、テーブルのデータを操作する関数を作成します。
Dockerで動かすため、handler/auth,handler/test等のフォルダに分けて作成します。

handler/auth/auth.py

import json
import os

import jwt
import requests
from cryptography.hazmat.primitives import serialization
from jwt.algorithms import RSAAlgorithm

AUTH0_AUDIENCE = os.getenv('AUTH0_AUDIENCE')
AUTH0_JWKS_URL = os.getenv('AUTH0_JWKS_URL')

jwks_json_str = json.dumps(json.loads(requests.get(AUTH0_JWKS_URL).text)['keys'][0])
public_key = RSAAlgorithm.from_jwk(jwks_json_str)
pem = public_key.public_bytes(encoding=serialization.Encoding.PEM,
                              format=serialization.PublicFormat.SubjectPublicKeyInfo)


def auth_handler(event, context):
    print(event)
    auth_token = event["headers"]["Authorization"].replace("Bearer ", "")

    try:
        principal_id = jwt_verify(auth_token, pem)
        policy = generate_policy(principal_id, 'Allow', "arn:aws:execute-api:*:*:*/*/*/*")
        return policy
    except Exception as e:
        raise Exception('Unauthorized')


def jwt_verify(auth_token, pub_key):
    payload = jwt.decode(auth_token, pub_key, algorithms=['RS256'], audience=AUTH0_AUDIENCE)
    return payload['sub']
def generate_policy(principal_id, effect, resource):
    return {
        'principalId': principal_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    "Action": "execute-api:Invoke",
                    "Effect": effect,
                    "Resource": resource
                }
            ]
        }
    }
  1. リクエストから、Authorization ヘッダーを取得し、Bearer スキームを除いた JWT トークンを取得します。
  2. JWKS URL を使用して、Auth0 の公開鍵を取得します。
  3. 取得した公開鍵を使用して、JWT トークンを検証します。
  4. JWT トークンの検証が成功すると、トークンのペイロードから sub フィールドの値を取得し(ユーザ識別子)、これを使用して、Amazon API Gateway のカスタムオーソライザー用のポリシーを生成します。

generate_policyに渡されるresourceにはアクションを許可・拒否する対象のリソースを指定することができます。
generate_policy関数で返されるpolicyDocumentのStatementオブジェクトは、Action オプションで API Gateway で実行可能なアクションを、Effect オプションでそのアクションを許可するか拒否するかを、Resource オプションでどのリソースに対して適用するかを指定しています。

  • /handler/auth/Dockerfile
FROM public.ecr.aws/lambda/python:3.8-arm64
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY *.py ./
  • /handler/auth/requirements.txt
requests
pyjwt
cryptography

Arm64でビルドするためには、FROM public.ecr.aws/lambda/python:3.8-arm64の記載が必要です。

handler/test/test.py

テーブルのデータを作成・更新・削除・読み取りをする関数です。
テーブルに登録する、データを取得する際に使用するuser_idを認証情報から取得しています。
Dockerで動かすため、Dockerfile等の作成が必要です。
こちらを参考にしてください

import json
import boto3
import os
from decimal import Decimal

from boto3.dynamodb.conditions import Key, Attr	##Keyオブジェクトを利用できるようにする

#Dynamodbアクセスのためのオブジェクト取得
dynamodb = boto3.resource(
    "dynamodb",
    region_name="ap-northeast-1",
    endpoint_url=os.environ.get("DYNAMODB_ENDPOINT_URL", "https://dynamodb.ap-northeast-1.amazonaws.com"),
)
table = dynamodb.Table("test_table")	##指定テーブルのアクセスオブジェクト取得


LPI_COMMON_HEADER = {
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "OPTIONS,POST,GET,DELETE",
}


def test_handler(event, context):
    # TODO implement
    try:
        print(event)
        user_id = event["requestContext"]["authorizer"]["principalId"]
        if event["body"] :
            body = json.loads(event["body"])
        if event['httpMethod']=='GET' :
            # scanData = table.scan()
            response = table.query(
            KeyConditionExpression=Key('user_id').eq(user_id)
            )
            items=response['Items']
            print(items)
            return {
                'statusCode': 200,
                'body': json.dumps(items ,default=decimal_to_int),
                "headers": LPI_COMMON_HEADER,

            }
        if event['httpMethod']=='POST' :
            putResponse = table.put_item(	##put_item()メソッドで追加・更新レコードを設定
            Item={	##追加・更新対象レコードのカラムリストを設定
                'user_id': user_id,
                'memo': body["memo"],
                'date': body["date"],
                'star': body["star"],
            }
            )
            return {
            'statusCode': 200,
            'body': json.dumps('Hello from PostLambda!'),
            "headers": LPI_COMMON_HEADER,
            }
            
        if event["httpMethod"] == "DELETE":
            delResponse = table.delete_item(
                Key={
                    'user_id': user_id,
                    'date': body["date"],

                }
            )
            return {
                'body': json.dumps('Hello from Lambda!'),
                'statusCode': 200,
                "headers": LPI_COMMON_HEADER,
            }
    except Exception as e:
        return {
            'statusCode': 500,
            'body': json.dumps(str(e)),
            "headers": LPI_COMMON_HEADER,
        }
    return {
        'statusCode': 404,
        'body': json.dumps('function not found!'),
        "headers": LPI_COMMON_HEADER,
    }

def decimal_to_int(obj):
    if isinstance(obj, Decimal):
        return int(obj)

serverless.yaml ファイルの設定


service: test
useDotenv: true

frameworkVersion: "3.26.0"

provider:
  name: aws
  architecture: arm64  # デフォルトはx86_64
  lambdaHashingVersion: 20201221
  # you can overwrite defaults here
  #  stage: dev
  region: ap-northeast-1
  iam:
    role:
      statements:
        - Effect: "Allow"
          Action:
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
            - dynamodb:DeleteItem
            - dynamodb:Query
          Resource: "arn:aws:dynamodb:*:*:table/test_table"
  environment:
    AUTH0_AUDIENCE: ${env:AUTH0_AUDIENCE}
    AUTH0_JWKS_URL: ${env:AUTH0_JWKS_URL}
  ecr:
    images:
      auth:
        file: Dockerfile
        path: handler/auth
        platform: linux/arm64
      test:
        file: Dockerfile
        path: handler/test
        platform: linux/arm64

resources:
  Resources:
    test:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: test_table
        AttributeDefinitions:
          - AttributeName: user_id
            AttributeType: S
          - AttributeName: date
            AttributeType: N
        KeySchema: [
            {
              KeyType: "HASH",
              AttributeName: "user_id",
            },
            {
              KeyType: "RANGE",
              AttributeName: "date",
            }]
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

functions:
  auth:
    image:
      name: auth
      command: auth.auth_handler

  test:
    image:
      name: test
      command: test.test_handler

    events:
      - http:
          path: /test
          method: get
          integration: lambda-proxy
          cors: true
          authorizer:
            name: auth
            identitySource: method.request.header.Authorization
            type: request

      - http:
          path: /test
          method: post
          integration: lambda-proxy
          cors: true
          authorizer:
            name: auth
            identitySource: method.request.header.Authorization
            type: request

      - http:
          path: /test
          method: delete
          integration: lambda-proxy
          cors: true
          authorizer:
            name: auth
            identitySource: method.request.header.Authorization
            type: request
  • 環境変数を使用するため、useDotenv: trueの指定、environment:のフィールドで変数の設定をしています。
  • ecr:Docker イメージを AWS Elastic Container Registry (ECR) にプッシュするための設定を行います。
  • functions:ではLambda関数の設定しており、ecr:で設定したimageのnameを指定し、commandには実行する関数を設定します。
  • events:のフィールドでは、httpリクエストのメソッド毎にfunctions:で設定したauthの関数がカスタムオーソライザーとして呼び出される指定をしています。
    • Authorization ヘッダーで検証を行うため、identitySource: method.request.header.Authorizationを指定しています。

slsのデプロイ

Auth0の認証情報を確認する際に環境変数を使用しているため、.envファイルを作成します。
Auth0の設定を行う必要がありますが、Auth0の設定時にAPIのエンドポイントのURLを使用したいため、serverless.yamlと同じ階層にひとまず定義だけ記述したファイルを作成しslsのデプロイを行います。
.env

AUTH0_AUDIENCE=
AUTH0_JWKS_URL=
sls deploy

デプロイ時にターミナルに表示されるエンドポイントを確認します。

Auth0の設定

APIの作成

  1. Auth0 のアカウントを作成するか、ログインします。
  2. Auth0 管理画面の [Applications]>[APIs] メニューから、[Create API] ボタンをクリックします。
  3. API 名を入力し、[Identifier] フィールドに API のエンドポイントのURLを設定します。
    デプロイした際に表示されたAPIのEndpointを設定します。
  4. [Create] ボタンをクリックして、API を作成します。
    作成するとApplicationsにAPIのテスト用のM2Mアプリケーションが追加されます。

Auth0関連の環境変数を設定

  • AUTH0_AUDIENCE: APIの作成時に設定したIdentifierの値を設定します
    • デプロイされたAPI Gatewayのエンドポイント
  • AUTH0_JWKS_URL: テストアプリケーションの[Settings]>[Advanced Settings]>[Endpoints]のJSON Web Key Setに記載のURLです。
    基本的には以下のようなURLとなっています。
    https://{Auth0のアプリケーションのドメイン名}/.well-known/jwks.json
    設定し終えたら、再度sls deployで反映します。

カスタム認証ができているか確認する

curlコマンドで、認証情報をヘッダーにつけずにAPIのエンドポイントにリクエストすると、未認証となることが確認できます。

cd my-project 
curl 'APIGatewayのエンドポイント' -XGET
{"message":"Unauthorized"}                                                      

ヘッダーに認証済みTokenを付与することで認証が通り関数が動くことが確認できます。
Auth0ではTokenを取得できるテスト用のcurlコマンド等が用意されています。
Auth0の[Applications]>[APIs]で作成したAPIをクリックし、[Test]のタブを表示することで確認することができます。

TEST用のTokenを環境変数に入れることで確認しやすくすることができます。
以下ではTest用のToken取得用のcurlコマンドの末尾にjqコマンドを使ってレスポンスからaccess_tokenのみを取得し、環境変数TOKENに格納しています。
jqがインストールされていない場合は、Brewでインストールすることが必要です。

cd my-project 
export TOKEN=$(curl -s --request POST --url 'Token取得用のURL' --header 'content-type: application/json' --data '{"client_id":"テスト用M2MアプリケーションのClient ID" ,"audience":"APIのaudience(APIGatewayのエンドポイント)" ,"grant_type":"client_credentials"}'|jq .access_token -r)

[Applications]>[APIs]>作成したAPI>[Test]のタブからcurlコマンドをコピーし、末尾に|jq .access_token -r)を接続するとスムーズです。

cd my-project 
curl 'APIGatewayのエンドポイント' -XGET -H "Authorization : $TOKEN" -H "Content-Type: application/json"

参考
API Gateway + Web Socket で Auth0 の認証をするカスタムオーソライザーをつくってみた

Discussion