【Golang】で【Amazon API Gateway Lambda オーソライザー】と【FirebaseAuth】を利用しての認証をやってみた

f:id:kaminashi-developer:20210224144112j:plain

初めに

初めまして。2021年3月より株式会社カミナシにジョインすることとなりました、エンジニアの@Takuと申します。

業務とは直接関係ないのですが、API Gateway Lambda オーソライザーFirebaseAuthを組み合わせた認証をやってみたので記載させていただきます。

概要

以下のチュートリアルを元に Amazon API Gateway Lambda オーソライザーを利用した認証機能を作成しました。 docs.aws.amazon.com

Amazon API Gateway Lambda オーソライザーを利用することで、 認証・認可部分をAPI Gateway側で共通化できるため、

  • マイクロサービス化(認証・認可と業務の責務分け)
  • サービスを提供するサーバーの負荷軽減

などのメリットが見込めるのではと考えております。

その際チュートリアルから変更した点として、

  • OAuth アクセストークンを発行するOAuthプロバイダーに Firebaseを利用
  • API Gateway Lambda オーソライザー関数Golangで作成

としてますので、ご興味ある方はご覧いただけますと幸いです。

Amazon API Gateway Lambda オーソライザーとは

詳細な説明は公式へ役割をお譲りしますが、概要としては以下の通りです(公式より引用)

Lambda オーソライザー (以前のカスタムオーソライザー) は、Lambda 関数を使用して API へのアクセスを制御する API Gateway の機能です。

以下も公式より引用させていただいた図が分かり易いのですが、 API Gatewayが受けたリクエストをLambda関数で評価し、

  • アクセスが拒否された場合は 403 ACCESS_DENIED などのHTTPステータスコードを返す
  • アクセスが許可されている場合、メソッドを実行する

というワークフローとなっております。

f:id:kaminashi-developer:20210224130804p:plain (公式より引用)

開発環境

設計・アーキテクチャ

今回の設計は以下の通りとしました。 f:id:kaminashi-developer:20210224131025p:plain

今回の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を利用しました。

実装

認証の機能を実現するため、以下を実施します。

  1. Firebaseプロジェクトの作成
  2. Firebaseへログインし、JWTトークンを取得するクライアント(簡素なWebアプリケーション)の作成
  3. FirebaseのJWTトークンを検証するAWS Lambda関数の作成
  4. AWS API Gatewayの作成及び、でLambda Authorizerの設定

1. Firebaseプロジェクトの作成

Firebaseについては主題から外れるため深くは解説しませんが、簡単に設定だけ載せておきます。

(※ですのでFirebaseについてご存知な方は1. 2. を飛ばして3. から読み進めていただいても良いと思います)

まず、Firebaseコンソールを開き、新規プロジェクトを作成、 「Authentication」→「Sign-in method」のログイン プロバイダから、「メール / パスワード」を有効にします。 f:id:kaminashi-developer:20210224131056p:plain

その後、今回Userの作成はコンソールで済ませたため、テスト用に適当なUserを作成してください。

今回は例として、

  • 管理者ユーザー:admin@example.com
  • 一般ユーザー:user@example.com
  • アクセス権限なしユーザー:deny@example.com

という3ユーザーを作成してみました。 f:id:kaminashi-developer:20210224131149p:plain

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> 内の内容をまるっとコピーして貼り付けてください。 f:id:kaminashi-developer:20210224131230p:plain (認証情報が含まれるため一部隠してます)

// 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です f:id:kaminashi-developer:20210224131506p:plain

3. FirebaseのJWTトークンを検証するAWS Lambda関数

続いて、送られてくるJWTトークンを検証するLambda関数を作成します。

最初に記載したチュートリアルにもサンプル関数があるのですが、

Node.jsで記載されていたため、以下のサンプルを元に、FirebaseのJWTトークンを検証するように修正しました。

github.com

FirebaseのJWTトークンの検証には、公式のFirebase Admin Go SDKを利用しています。

firebase.google.com

pkg.go.dev

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 置いてください。 f:id:kaminashi-developer:20210224131619p:plain

こちらのキーを利用すると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

docs.aws.amazon.com

コマンド実行後、コンソールを作成し、以下の関数ができていると思います。 f:id:kaminashi-developer:20210224131708p:plain

Lambdaはテストイベントを設定し、関数のテストができます。

右上の「テストイベントの設定」→イベントテンプレートで「Amazon API Gateway Authorizer」を選択し、イベント名に適当な名前を入力、 autorizationToken: にFirebaseログイン後返却されたJWTトークンを入力してテストを実行してください。 f:id:kaminashi-developer:20210224131807p:plain

f:id:kaminashi-developer:20210224131823p:plain

なお、FirebaseのJWTトークンは、2. で作成したWebアプリでログインした後、トークンを画面に表示するようにしておりますので、そちらをコピーして使うことができます。 f:id:kaminashi-developer:20210224131849p:plain

テストを実行して、実行結果が「成功」であればここまではOKです。 f:id:kaminashi-developer:20210224131938p:plain

4. AWS API Gatewayの作成及び、でLambda Authorizerの設定

最後に、API Gatewayの設定を行います。

今回サンプルのAPIAmazon API Gatewayチュートリアルを利用しますので、以下の手順に従い、簡単なAPIを作成してください。(こちらについては説明は割愛させていただきます) docs.aws.amazon.com

チュートリアルが完了しましたら、以下のようなAPIが作成できているかと思います。 f:id:kaminashi-developer:20210224132043p:plain

作成が完了しましたら、Lambda Authorizerの設定を行なっていきます。

設定は、APIを選択後、[Authorizers (オーソライザー)] →[新しいオーソライザーの作成]を選択し、 以下の通り入力してください。 f:id:kaminashi-developer:20210224132123p:plain

[設定]

  • 名前:任意
  • タイプ:Lambdaを選択
  • Lambda関数:3.で作成した関数を選択
  • Lambda呼び出しロール:空白のまま
  • Lambdaイベントペイロードトークンを選択
  • トークンのソース:Authorization
  • トークンの検証:空白のまま

トークンのソースについては、最初に記載したLambdaオーソライザーのチュートリアルだと authorizationTokenと入力することとなってますが、これだと後で設定するCORSを有効化した際、デフォルトで許可されるヘッダーにならないため Authorizationとするのが無難と思います(手動で追記してもOK)。

オーソライザーの設定が完了しましたら、こちらもLambda関数と同様にテストができますので、Webアプリから取得したJWTトークンを「認可トークン」の欄に入力して実行し、レスポンスが 200で返って来ればここまでは問題ないかと思います。 f:id:kaminashi-developer:20210224132144p:plain

オーソライザーの設定が完了した時点で3.のLambda関数を見てみると、 以下のようにAPI Gatewayがトリガーとして追加されているかと思います。 f:id:kaminashi-developer:20210224132221p:plain

次に、Webアプリからのトークンの送り先URLを変更したいと思いますので、

  1. で作成したログイン後のページに記載のエンドポイントを、API Gatewayで作成したAPIの「ステージ」→対象のステージを選択し、「URLの呼び出し」の横にある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) {

f:id:kaminashi-developer:20210224132250p:plain

続いて、メソッドに作成したオーソライザーを紐づけていきます。

「リソース」→「/pets の GET」→「メソッドリクエスト」を選択します。

そして「設定」の「認可」のプルダウンから先ほど作成したオーソライザーを選択し、横のチェックマークをクリックしてください(わかりにくいですが、チェックマークを押さないと設定されません) f:id:kaminashi-developer:20210224132329p:plain

ここまでできましたら、2.で作成したWebアプリを利用してトークンを利用したリクエストを行えるのですが、このままですとCORSの制限に引っかかり、Webアプリ(ブラウザ)でAPI Gatewayからのリクエストを受け取ることができません。

ですので以下の手順でCORSを有効にします。

docs.aws.amazon.com

「リソース」→「/pets」を選択した後、「アクション」→「CORSの有効化」を選択してください。

f:id:kaminashi-developer:20210224132417p:plain

設定につきましては今回はそのままデフォルトから変更せず、「CORSを有効にして既存のCORSヘッダーを置換」のボタンをクリックしてください。 f:id:kaminashi-developer:20210224132459p:plain

今回は検証で、ローカルから実行することもありAccess-Control-Allow-Origin * を許容しておりますが、本来はXSSCSRFといった脆弱性の原因になるという点から望ましくないため、実際のプロダクトで利用する場合は避け、正しいOriginを設定するようにしてください。

また、前述のオーソライザー設定時に記載した通り、トークンのソースを Authorization以外にした場合は、 Access-Control-Allow-headersにそのトークン名を追記してください。

※しないと以下のように request header field is not allowed by access-control-allow-headersといったエラーが出ます。 f:id:kaminashi-developer:20210224132523p:plain

これにて実装は全て完了です。

  1. のWebアプリから、

  2. admin@example.comuser@example.comのアクセス許可されたユーザーでログインするとPetsTypeが表示される f:id:kaminashi-developer:20210224132559p:plain

  3. deny@example.comのアクセスが許可されていないユーザーでログインすると403のErrorが表示される f:id:kaminashi-developer:20210224132615p:plain

(簡易なページで申し訳ないです・・)

であればAPI Gateway Lambda オーソライザーでの認証およびユーザー別のアクセス制御ができたと思います!

やってみた感想

今回やってみた感想として、Firebaseでも簡単にLambda Authorizerを利用した認証ができるという印象でした。

この様子ですとAuth0やAmazon Cognitoといった他のOAuthプロバイダーでも問題なく実装できるかと思います。

なお、今回は私のスキルスタックからGolangでLambda関数を作成しましたが、コンソールでコード編集ができず、検証時は毎回zipのアップロードを行うのが手間でしたので、PythonやNode.jsが得意な方はそちらで実装した方が楽かと思いました。

今回はユーザー毎の認可部分の設計が深くできなかったため、次回はそのあたりも実装できればと考えております。(adminとuserで実行可能なリクエストを分けたりしたかった・・)

最後までご覧いただき、ありがとうございました。 コードの不備やご意見等あればコメントいただけますと幸いです。

最後に

弊社では一緒に開発してくれるエンジニア(正社員・副業)を絶賛募集しています! デスクレスSaaSのプロダクト開発に興味がある、話を聞いてみたい方のご応募をお待ちしております!

open.talentio.com