GolangでのAPI GatewayとLambdaを利用した認可設定をやってみる

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

こんにちは。エンジニアの @Taku です。

期間が空いてしまいましたが、過去にLambda オーソライザーを使った認証を作成した続きで、今回は認可の設定を行っていきたいと思います。

※本記事の内容はプロダクトと関係なく、個人的にやってみたものになります。

前回までにやったこと

前回までで、

API GatewayとLambdaオーソライザーを利用した認証の作成 kaminashi-developer.hatenablog.jp

をやった後、

② ①の認証をAWS Samを用いて構築 kaminashi-developer.hatenablog.jp をやってみました。

よろしければ上記もご参照いただけますと幸いです。

今回やること

今回は認可の設定として、ログイン後のリソースのアクセス制御を行っていきます。

認可の設計パターンにつきましては、以下記事を参考にさせていただきました。 please-sleep.cou929.nu

これまでの実装のパターンとしては、上記記事でのJWT 利用 + API Gateway パターンが近いかと思いまして、当てはめると - TokenStore = Firebase Auth - AuthService = Lambda Authorizer関数 - Service X = SAMで定義したLambda関数 になるかと思います。

認可の設計パターンについてはいくつか考えられますが例として、

  1. サービス側で権限を制御するパターン
  2. 中央管理するパターン

があり、これがベストという方式はないようなのですが、今回はAPI GatewayとLambdaオーソライザーを利用するため、2の中央管理するパターンで実装していきます。

認可の設定

アクセス制御のパターンは、以下3ユーザーで

  1. 管理者(admin) :全リソースにアクセスできる
  2. リーダー(leader):リーダー及び、他のユーザーのリソースにアクセスできる
  3. ユーザー(user) :自身の情報にのみアクセスできる

といった形で制御したいと思います。

API GatewayとLambda オーソライザーを利用した認可の方法

以後、過去からの続きで恐縮ですが、サンプルを元にGolangでLambdaオーソライザーの関数を作成した場合、 IAM policyを作成する箇所でユーザー毎にアクセスできるリソース(パス)を制御することができます。

具体的には events.APIGatewayCustomAuthorizerResponseを作成している箇所で、 events.APIGatewayCustomAuthorizerPolicyResource で、ポリシーを付与されたユーザーはここに記載されているパスにのみアクセスすることができます。

// generatePolicy IAM policyを生成する
func generatePolicy(principalId, effect string, resources []string) events.APIGatewayCustomAuthorizerResponse {
    authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalId}

    if effect != "" && len(resources) > 0 {
        authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
            Version: "2012-10-17",
            Statement: []events.IAMPolicyStatement{
                {
                    Action:   []string{"execute-api:Invoke"},
                    Effect:   effect,
                    Resource: resources, // アクセスOKなパス
                },
            },
        }
    }
        return authResponse
}

ですので、 generatePolicyを利用してポリシーを作成する際、各権限別にアクセス可能なパスを設定することでアクセス可能なリソースを制御することができます。

実装

続いて具体的な設定です。

API Gatewayの設定

本題から外れるため詳細は省く&設定も極小にしておりますが、前回のSAMのtemplate.yamlを修正し、

  • GET: /admin (管理者のページ)
  • GET: /leaders (リーダーのページ)
  • GET: /users/XXXXXXXX (一般ユーザー個人のページ)

というリソースを追加しました。

前回のAWA SAMを使った構築で作成した template.yamlを以下のように修正します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:  Sample API Gateway Lambda Authorizer by Golang.
Resources:
  SampleRestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      Description: "This is Sample Rest API"
      Cors:
        AllowMethods: "'GET, OPTIONS'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: MyLambdaAuthorizerFunction
        Authorizers:
          MyLambdaAuthorizerFunction:
            FunctionArn: !GetAtt LambdaAuthorizerFunction.Arn
  RootFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        def handler(event, context):
          return {
            'statusCode': 200,
          }
  # 追加ここから
  AdminFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /admin
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
          return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'admin情報',
            }),
          }
  LeaderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /leaders
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'leader情報',
            }),
          }
  UserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        ApiEvent:
          Type: Api
          Properties:
            Path: /users/XXXXXXXX # userのuid 
            Method: get
            RestApiId:
              Ref: SampleRestApi
      Runtime: python3.7
      Handler: index.handler
      InlineCode: |
        import json
        def handler(event, context):
            return {
            'statusCode': 200,
            'body': json.dumps({
              'message': 'user情報',
            }),
          }
  # 追加ここまで
  LambdaAuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: samAuth
      CodeUri: ./lambda_auth # buildされるが、configがアップされなかったので、`/build/関数名`のディレクトリに手動で配置
      Handler: app.lambda_handler
      Runtime: go1.x

Outputs:
  SampleRestApi:
    Description: "Sample API Gateway Lambda Authorizer by Golang."
    Value: !Sub "https://${SampleRestApi}.execute-api.${AWS::Region}.amazonaws.com/dev/"

Lambdaオーソライザー関数の設定

前々回のLambda fanctionを修正し、generetePolicyを呼んでいた箇所でリソースの設定を行います。

以前はgeneretePolicyの第3引数にevent.MethodArnを入れており、Lambdaオーソライザー関数を呼んだ際のパスが入るようになっていたため認証後はそのままアクセスできていたのですが、今回はユーザー単位でアクセスできるパスを設定します。

apiArn := "arn:aws:execute-api:ap-northeast-1:XXXXXXXXXXXX:XXXXXXXXXX/dev" // API Gatewayのパス

    switch email {
    case "admin@example.com":
        return generatePolicy("admin", "Allow", []string{apiArn + "/GET/*"}), nil // 全リソースにアクセス可能
    case "leader@example.com":
        return generatePolicy("leader", "Allow", []string{apiArn + "/GET/leaders", apiArn + "/GET/leaders*", apiArn + "/GET/users*"}), nil // leaderとuserの全リソースにアクセス可能
    case "user@example.com":
        userID := token.UID
        return generatePolicy("user", "Allow", []string{apiArn + "/GET/users/" + userID}), nil // 自分のリソースにのみアクセス可能
    case "deny@example.com":
        return generatePolicy("deny", "Deny", []string{}), nil
    case "unauthorized":
        return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Unauthorized") // Return a 401 Unauthorized response
    default:
                 return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Error: Invalid token")
    }

リソースに設定するパスは、コンソールでAPI Gatewayの設定を見ることで確認可能です。 f:id:kaminashi-developer:20210624212136p:plain

generetePolicyの第3引数はSliceで、複数パスを入れることができ、 *を入力して配下の全リソースにアクセス可能とすることもできます。

が、 leaders/*のように /を挟むと別リソースと判定されるようで、 leaders*とする必要があったためパス設計には注意が必要そうでした。

今回はメールアドレスでの判定ですが、Firebaseでアカウントを作成する際に情報を組み込むようにすれば、それらを基に制御をすることも可能かと思います。 (コンソールからだとカスタマイズができなかったため割愛)

検証

FirebaseAuthで以下の3ユーザーを作成し、アクセスを試みます

検証にはこれまた雑ですが、ログイン後、各API Gatewayのパスにリクエストを投げ、どのユーザーでどのレスポンスが返ってくるか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>
    <br>
    <div></div>
    <a id="admin"></a>
    <br>
    <div></div>
    <a id="leader"></a>
    <br>
    <div></div>
    <a id="users"></a>

    <!-- 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 admin  = document.querySelector('#admin');
        let leader  = document.querySelector('#leader');
        let users  = document.querySelector('#users');

        // API Gatewayで作成したエンドポイントを設定
        // const url = "http://localhost:3000"
        const adminUrl  = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/admin"
        const leaderUrl = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/leaders"
        const userUrl   = "https://x7ow8vkh09.execute-api.ap-northeast-1.amazonaws.com/dev/users"

        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(adminUrl, {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              admin.innerHTML = `管理者: ${res.data.message}`;
            }).catch(error => {
              admin.innerHTML = `管理者: ${error}`;
            });

            axios.get(leaderUrl, {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              leader.innerHTML = `リーダー: ${res.data.message}`;
            }).catch(error => {
              leader.innerHTML = `リーダー: ${error}`;
            });

            axios.get(userUrl + "/" + "8eMzOlFSDcVJU8U4zjwNA9pyMlr1", {
              headers: {
                'Authorization':accessToken,
              }
            }).then(res => {
              users.innerHTML = `ユーザー: ${res.data.message}`;
            }).catch(error => {
              users.innerHTML = `ユーザー: ${error}`;
            });
          }, null, ' ');
        }
      });
    </script>
  </body>
</html>

各ユーザーでログインしたところ、以下の通りアクセス可能となっているリソースのみ取得できている形になってます。

  • admin@example.com

    • 全情報が取得できている f:id:kaminashi-developer:20210624212203p:plain
  • leader@example.com

    • leader, user情報のみ取得できている f:id:kaminashi-developer:20210624212430p:plain
  • user@example.com

    • user情報のみ取得できている f:id:kaminashi-developer:20210624212408p:plain

終わりに

以上、簡単ではありますがAPI GatewayとLambdaを利用した認可設定のご紹介でした。 URLの設計が必要ですが、API GatewayとLambdaを利用することで、中央管理するパターンであれば簡単にロールベースのアクセス制御ができるように感じました。

少しでも参考になれば幸いです。