初めに
初めまして。2021年3月より株式会社カミナシにジョインすることとなりました、エンジニアの@Takuと申します。
業務とは直接関係ないのですが、API Gateway Lambda オーソライザー
とFirebaseAuth
を組み合わせた認証をやってみたので記載させていただきます。
概要
以下のチュートリアルを元に Amazon API Gateway Lambda オーソライザー
を利用した認証機能を作成しました。
docs.aws.amazon.com
Amazon API Gateway Lambda オーソライザーを利用することで、 認証・認可部分をAPI Gateway側で共通化できるため、
- マイクロサービス化(認証・認可と業務の責務分け)
- サービスを提供するサーバーの負荷軽減
などのメリットが見込めるのではと考えております。
その際チュートリアルから変更した点として、
としてますので、ご興味ある方はご覧いただけますと幸いです。
Amazon API Gateway Lambda オーソライザーとは
詳細な説明は公式へ役割をお譲りしますが、概要としては以下の通りです(公式より引用)
Lambda オーソライザー (以前のカスタムオーソライザー) は、Lambda 関数を使用して API へのアクセスを制御する API Gateway の機能です。
以下も公式より引用させていただいた図が分かり易いのですが、 API Gatewayが受けたリクエストをLambda関数で評価し、
- アクセスが拒否された場合は
403 ACCESS_DENIED
などのHTTPステータスコードを返す - アクセスが許可されている場合、メソッドを実行する
というワークフローとなっております。
(公式より引用)
開発環境
- AWS Lambda (Go 1.x)
- AWS API Gateway
- Firebase
- Firebase JavaScript SDK (8.2.4)
- Firebase JavaScript UI (4.7.3)
- Firebase Admin Go SDK (v4)
- Google Chrome (バージョン: 88.0.4324.182)
設計・アーキテクチャ
今回の設計は以下の通りとしました。
今回のOAuthプロバイダーには前述の通り、Firebaseを採用しております。
AWSにはAmazon Cognitoという認証のサービスがありますのでそちらを使う方がAWS内で完結して良い気もしますが、今回は電話認証以外の認証は無料で使え、私自身使ったことがあるFirebaseで試してみました。
Lambda オーソライザーには 以下の2 種類がありますが、Firebaseが認証後返すのはJWTトークンとなりますので、今回は トークンベース の Lambda オーソライザー
を利用することになります。
・トークンベース の Lambda オーソライザー (TOKEN オーソライザーとも呼ばれる) は、JSON ウェブトークン (JWT) や OAuth トークンなどのベアラートークンで発信者 ID を受け取ります。
・リクエストパラメータベースの Lambda オーソライザー (REQUEST オーソライザーとも呼ばれる) は、ヘッダー、クエリ文字列パラメータ、stageVariables、および $context 変数の組み合わせで、発信者 ID を受け取ります。WebSocket API では、リクエストパラメータベースのオーソライザーのみがサポートされています。
公式より引用
また、カミナシではバックエンドの開発にGolangを採用しておりますので、 Lambdaオーソライザーの関数の作成にはGolangを利用しました。
実装
認証の機能を実現するため、以下を実施します。
- Firebaseプロジェクトの作成
- Firebaseへログインし、JWTトークンを取得するクライアント(簡素なWebアプリケーション)の作成
- FirebaseのJWTトークンを検証するAWS Lambda関数の作成
- AWS API Gatewayの作成及び、でLambda Authorizerの設定
1. Firebaseプロジェクトの作成
Firebaseについては主題から外れるため深くは解説しませんが、簡単に設定だけ載せておきます。
(※ですのでFirebaseについてご存知な方は1. 2. を飛ばして3. から読み進めていただいても良いと思います)
まず、Firebaseコンソールを開き、新規プロジェクトを作成、 「Authentication」→「Sign-in method」のログイン プロバイダから、「メール / パスワード」を有効にします。
その後、今回Userの作成はコンソールで済ませたため、テスト用に適当なUserを作成してください。
今回は例として、
- 管理者ユーザー:
admin@example.com
- 一般ユーザー:
user@example.com
- アクセス権限なしユーザー:
deny@example.com
という3ユーザーを作成してみました。
2. Firebaseへログインし、JWTトークンを取得するクライアントの作成
Firebase認証を行い、トークンを取得するクライアントの情報は多くあるため詳細は省きます。
今回はFirebaseUIを利用したとても簡素なWebページを作成し、ログイン後取得したJWTトークンをヘッダーに付与してリクエストを送る形をしてます。
ログインページ
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Lambda Authorizer Sample</title> <script src="https://www.gstatic.com/firebasejs/ui/4.7.3/firebase-ui-auth.js"></script> <link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/4.7.3/firebase-ui-auth.css" /> <style>h1{text-align: center;}</style> </head> <body> <h1>Lambda Authorizer Sample</h1> <div id="firebaseui-auth-container"></div> <!-- FirebaseSDKのインポート --> <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-app.js"></script> <!-- FirebaseAuthのインポート --> <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-auth.js"></script> <!-- configファイルのインポート --> <script src="./js/config.js"></script> <script> // FirebaseUIの設定 var ui = new firebaseui.auth.AuthUI(firebase.auth()); ui.start('#firebaseui-auth-container', { // ログイン完了時のリダイレクト先 signInSuccessUrl: './auth.html', // 利用する認証機能 signInOptions: [ firebase.auth.EmailAuthProvider.PROVIDER_ID ], }); // User情報の表示 firebase.auth().onAuthStateChanged(function(user) { if (user) { console.log('user', user) user.getIdToken().then(function(accessToken) { console.log('accessToken', accessToken) }, null, ' '); } }); </script> </body> </html>
ログイン後ページ
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Lambda Authorizer Sample</title> </head> <body> <h1>...Please wait</h1> <div id="email"></div> <div id="token"></div> <div id="pets"></div> <!-- FirebaseSDKのインポート --> <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-app.js"></script> <!-- FirebaseAuthのインポート --> <script src="https://www.gstatic.com/firebasejs/8.2.6/firebase-auth.js"></script> <!-- configファイルのインポート --> <script src="./js/config.js"></script> <!-- axiosのインポート --> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js" integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ==" crossorigin="anonymous"></script> <script> // User情報の表示 firebase.auth().onAuthStateChanged(function(user) { let h1 = document.querySelector('h1'); let email = document.querySelector('#email'); let token = document.querySelector('#token'); let pets = document.querySelector('#pets'); // API Gatewayで作成したエンドポイントを設定 const url = "https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/pets" if (user) { console.log('user', user) h1.innerText = 'ログインしました'; email.innerHTML = `メールアドレス:${user.email}`; user.getIdToken().then(function(accessToken) { console.log('accessToken', accessToken) token.innerHTML = `JWTトークン:${accessToken}`; // リクエスト送信 axios .get(url, { headers: { 'AuthorizationToken': accessToken } }).then(res => { console.log('res', res) pets.innerHTML = `PetsType: ${res.data[1].type}`; }) .catch(error => { pets.innerHTML = `error: ${error}`; }); }, null, ' '); } }); </script> </body> </html>
※ログイン後にトークンの送り先のURLは後ほどAPI Gatewayで作成したエンドポイントを記載します。
上記HTMLでFirebaseログインを行うためにはFirebaseSDKの設定が必要なのですが、API Keyなど認証情報があるため、これだけ ./js/config.js
として別ファイルで置いてます。
こちらは、「プロジェクトの設定(プロジェクトの概要横の歯車)」→「全般」→「アプリを追加」することで表示された <script>~</script>
内の内容をまるっとコピーして貼り付けてください。
(認証情報が含まれるため一部隠してます)
// Your web app's Firebase configuration // For Firebase JS SDK v7.20.0 and later, measurementId is optional var firebaseConfig = { apiKey: "XXXXXXXXXXX-XXXXXXXXXXXXXXXXX-XXXXXXXXXX", authDomain: "XXXXXX.firebaseapp.com", projectId: "XXXXXX", storageBucket: "XXXXXX.appspot.com", messagingSenderId: "XXXXXXXXXXXX", appId: "1:XXXXXXXXXXXX:web:XXXXXXXXXXXXXXXXXXXXXXXX", measurementId: "G-XXXXXXXXXXXX" }; // Initialize Firebase firebase.initializeApp(firebaseConfig);
以下のようなページが出ればOKです
3. FirebaseのJWTトークンを検証するAWS Lambda関数
続いて、送られてくるJWTトークンを検証するLambda関数を作成します。
最初に記載したチュートリアルにもサンプル関数があるのですが、
Node.jsで記載されていたため、以下のサンプルを元に、FirebaseのJWTトークンを検証するように修正しました。
FirebaseのJWTトークンの検証には、公式のFirebase Admin Go SDKを利用しています。
PythonやNode.jsといったスクリプト言語はLambdaのコンソールからコードを入力できるのですが、Golangは非対応のため、zipファイル or ECR経由でのDockerコンテナでのアップロードになります。
今回はCLI経由でのzipファイルアップで実装しましたので、エディタでファイルを作成していきます。
サンプルのディレクトリ構成とコードは以下となります。
-> % tree . ├── config │ └── jwt-lambda-auth-firebase-adminsdk-xxxxx-xxxxxxxxxx.json ├── go.mod ├── go.sum ├── main └── main.go 1 directory, 5 files
package main import ( "context" "errors" "fmt" "log" firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/auth" "google.golang.org/api/option" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) // generatePolicy IAM policyを生成する func generatePolicy(principalId, effect, resource string) events.APIGatewayCustomAuthorizerResponse { authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalId} if effect != "" && resource != "" { authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{ Version: "2012-10-17", Statement: []events.IAMPolicyStatement{ { Action: []string{"execute-api:Invoke"}, Effect: effect, Resource: []string{resource}, }, }, } } return authResponse } // verifyFirebaseJwtToken JwtTokenを検証 func verifyFirebaseJwtToken(idToken string) (*auth.Token, error) { ctx := context.Background() opt := option.WithCredentialsFile("./config/jwt-lambda-auth-firebase-adminsdk-xxxx-xxxxxxxxxx.json") // コンソールからダウンロードしたjsonファイルのパスを指定 app, err := firebase.NewApp(ctx, nil, opt) if err != nil { return nil, err } client, err := app.Auth(ctx) if err != nil { return nil, err } token, err := client.VerifyIDToken(ctx, idToken) if err != nil { return nil, err } return token, err } func handleRequest(ctx context.Context, event events.APIGatewayCustomAuthorizerRequest) (events.APIGatewayCustomAuthorizerResponse, error) { // JWTトークンを検証する jwt := event.AuthorizationToken token, err := verifyFirebaseJwtToken(jwt) if err != nil { log.Fatalln("error decode jwt token: ", err) } fmt.Println("token", token) // メールアドレスを取得 email := token.Claims["email"] switch email { case "admin@example.com": return generatePolicy("admin", "Allow", event.MethodArn), nil case "user@example.com": return generatePolicy("user", "Allow", event.MethodArn), nil case "deny@example.com": return generatePolicy("deny", "Deny", event.MethodArn), nil case "unauthorized": return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Unauthorized") // Return a 401 Unauthorized response default: return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Error: Invalid token") } } func main() { lambda.Start(handleRequest) }
あと、LambdaでFirebaseのJWTトークンを評価する際に、Firebaseプロジェクトの「サービス アカウントの認証情報を含む構成ファイル」が必要となりますので、
「プロジェクトの設定(プロジェクトの概要横の歯車)」→「サービスアカウント」→「新しい秘密鍵の生成」からjsonファイルをダウンロードして ./config
置いてください。
こちらのキーを利用するとFirebaseプロジェクトにアクセスできてしまうため、 注意書きにある通り、絶対に外部に漏らさない(Githubの公開レポジトリにアップするなどしない)ように注意してください。
ファイルの作成ができましたら、以下コマンドでzip化→関数の作成をします。
-> % GOOS=linux go build main.go; zip -r ../function.zip ./ -> % aws lambda create-function --function-name api-gateway-authorizer-golang --runtime go1.x \ --zip-file fileb://../function.zip --handler main \ --role arn:aws:iam::000000000000:role/service-role/xxxxxx-role-xxxxxxxx // Lambda関数を作成する権限をもつIAMのARNを指定
なお、一度作成した関数のコードを更新する場合は以下になります。
-> % GOOS=linux go build main.go; zip -r ../function.zip ./ -> % aws lambda update-function-code --function-name api-gateway-authorizer-golang \ --zip-file fileb://../function.zip
(参考: LambdaのCLIコマンド) docs.aws.amazon.com
コマンド実行後、コンソールを作成し、以下の関数ができていると思います。
Lambdaはテストイベントを設定し、関数のテストができます。
右上の「テストイベントの設定」→イベントテンプレートで「Amazon API Gateway Authorizer」を選択し、イベント名に適当な名前を入力、 autorizationToken:
にFirebaseログイン後返却されたJWTトークンを入力してテストを実行してください。
なお、FirebaseのJWTトークンは、2. で作成したWebアプリでログインした後、トークンを画面に表示するようにしておりますので、そちらをコピーして使うことができます。
テストを実行して、実行結果が「成功」であればここまではOKです。
4. AWS API Gatewayの作成及び、でLambda Authorizerの設定
今回サンプルのAPIはAmazon API Gatewayのチュートリアルを利用しますので、以下の手順に従い、簡単なAPIを作成してください。(こちらについては説明は割愛させていただきます) docs.aws.amazon.com
チュートリアルが完了しましたら、以下のようなAPIが作成できているかと思います。
作成が完了しましたら、Lambda Authorizerの設定を行なっていきます。
設定は、APIを選択後、[Authorizers (オーソライザー)] →[新しいオーソライザーの作成]を選択し、 以下の通り入力してください。
[設定]
- 名前:任意
- タイプ:Lambdaを選択
- Lambda関数:3.で作成した関数を選択
- Lambda呼び出しロール:空白のまま
- Lambdaイベントペイロード:トークンを選択
- トークンのソース:Authorization
- トークンの検証:空白のまま
トークンのソースについては、最初に記載したLambdaオーソライザーのチュートリアルだと authorizationToken
と入力することとなってますが、これだと後で設定するCORSを有効化した際、デフォルトで許可されるヘッダーにならないため Authorization
とするのが無難と思います(手動で追記してもOK)。
オーソライザーの設定が完了しましたら、こちらもLambda関数と同様にテストができますので、Webアプリから取得したJWTトークンを「認可トークン」の欄に入力して実行し、レスポンスが 200
で返って来ればここまでは問題ないかと思います。
オーソライザーの設定が完了した時点で3.のLambda関数を見てみると、 以下のようにAPI Gatewayがトリガーとして追加されているかと思います。
次に、Webアプリからのトークンの送り先URLを変更したいと思いますので、
<script> // User情報の表示 firebase.auth().onAuthStateChanged(function(user) { let h1 = document.querySelector('h1'); ... // API Gatewayで作成したエンドポイントを設定 const url = "https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/pets" if (user) {
続いて、メソッドに作成したオーソライザーを紐づけていきます。
「リソース」→「/pets の GET」→「メソッドリクエスト」を選択します。
そして「設定」の「認可」のプルダウンから先ほど作成したオーソライザーを選択し、横のチェックマークをクリックしてください(わかりにくいですが、チェックマークを押さないと設定されません)
ここまでできましたら、2.で作成したWebアプリを利用してトークンを利用したリクエストを行えるのですが、このままですとCORSの制限に引っかかり、Webアプリ(ブラウザ)でAPI Gatewayからのリクエストを受け取ることができません。
ですので以下の手順でCORSを有効にします。
「リソース」→「/pets」を選択した後、「アクション」→「CORSの有効化」を選択してください。
設定につきましては今回はそのままデフォルトから変更せず、「CORSを有効にして既存のCORSヘッダーを置換」のボタンをクリックしてください。
今回は検証で、ローカルから実行することもありAccess-Control-Allow-Origin *
を許容しておりますが、本来はXSSやCSRFといった脆弱性の原因になるという点から望ましくないため、実際のプロダクトで利用する場合は避け、正しいOriginを設定するようにしてください。
また、前述のオーソライザー設定時に記載した通り、トークンのソースを Authorization
以外にした場合は、 Access-Control-Allow-headers
にそのトークン名を追記してください。
※しないと以下のように request header field is not allowed by access-control-allow-headers
といったエラーが出ます。
これにて実装は全て完了です。
のWebアプリから、
admin@example.com
とuser@example.com
のアクセス許可されたユーザーでログインするとPetsTypeが表示されるdeny@example.com
のアクセスが許可されていないユーザーでログインすると403のErrorが表示される
(簡易なページで申し訳ないです・・)
であればAPI Gateway Lambda オーソライザー
での認証およびユーザー別のアクセス制御ができたと思います!
やってみた感想
今回やってみた感想として、Firebaseでも簡単にLambda Authorizerを利用した認証ができるという印象でした。
この様子ですとAuth0やAmazon Cognitoといった他のOAuthプロバイダーでも問題なく実装できるかと思います。
なお、今回は私のスキルスタックからGolangでLambda関数を作成しましたが、コンソールでコード編集ができず、検証時は毎回zipのアップロードを行うのが手間でしたので、PythonやNode.jsが得意な方はそちらで実装した方が楽かと思いました。
今回はユーザー毎の認可部分の設計が深くできなかったため、次回はそのあたりも実装できればと考えております。(adminとuserで実行可能なリクエストを分けたりしたかった・・)
最後までご覧いただき、ありがとうございました。 コードの不備やご意見等あればコメントいただけますと幸いです。
最後に
弊社では一緒に開発してくれるエンジニア(正社員・副業)を絶賛募集しています! デスクレスSaaSのプロダクト開発に興味がある、話を聞いてみたい方のご応募をお待ちしております!