🦊

[Amplify] Lambda認証 × VTL でAppsyncをカスタマイズする

2022/12/24に公開約7,900字

こんにちは @kita3222 です👋
本記事は AWS AmplifyとAWS×フロントエンド Advent Calendar 2022、24日目の記事です。

はじめに

Appsyncに認証を設定する際、以下5つの方法が存在します。

  1. API_KEY 認証
  2. AWS_LAMBDA 認証
  3. AWS_IAM 認証
  4. OPENID_CONNECT 認証
  5. AMAZON_COGNITO_USER_POOLS 認証

この中でも、外部のシステムを用いて認証を行いたい場合や、DynamoDBに認証情報を持たせてそれを使って認証を行いたい場合など、柔軟な認証に対応するにはLambda認証が使えます。

今回はAmplifyで、Lambda認証とVTLを使ったAppsync実装を行なっていこうと思います。

AppsyncにLambda認証を適用する

Appsyncを追加もしくは更新する

  • amplify add apiを実施して、Graphqlを選択します。Graphqlを既に作成している場合はamplify update apiを実施してください。
  • 既存の Lambda を認証用 Lambda として選択することも可能です。
amplify add api  

? Select from one of the below mentioned services: GraphQL
? Here is the GraphQL API that we will create. Select a setting to edit or continue Authorization modes: API key (default, expira
tion time: 7 days from now)
// API Lambdaを選択
? Choose the default authorization type for the API Lambda
// カスタム認証を実施する Lambda を作成する
? Choose a Lambda authorization function Create a new Lambda function
? Do you want to edit the local lambda function now? Yes
Edit the file in your editor: /<project-name>/amplify/backend/function/<lambda-name>/src/index.js
? Press enter to continue 
Successfully added <lambda-name> function locally
// 一度認可された場合のキャッシュ時間の設定。ここで設定された時間の間は認証Lambdaは再度実行されない。デフォルトは300秒。
? How long should the authorization response be cached in seconds? 300
// Lambda認証以外の認証方法を使用する場合はここでYesを選択して追加する
? Configure additional auth types? No
? Here is the GraphQL API that we will create. Select a setting to edit or continue Continue
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)

作成したLambdaに認証用ロジックを追加する

amplify/backend/function/<lambda-name>/src/index.jsに認証ロジックを追加していきます。

export const handler = async (event) => {
  const { authorizationToken } = event;
  
  // authorizationTokenを使って認証を行う
  
  // 認証に成功した場合は isAuthorized をtrueで返す
  if (isAuthorized) {
      return {
        isAuthorized: true,
	// amplify cli で設定したキャッシュ時間をここで上書きできる
        ttlOverride: 300,
      };
   // 認証に失敗した場合は isAuthorized をfalseで返す
   } else {
      return {
        isAuthorized: false,
        ttlOverride: 0,
      };
   }
}; 

ここで押さえておくべきポイントは以下です。

  • event に格納されている authorizationToken は Authorizationヘッダーの値です。
  • 関数の戻り値のisAuthorizedのブール値で認証されるかどうかを判定します。
  • amplify cliで設定したキャッシュ時間を関数の戻り値のttlOverrideの値で上書きが可能です。

amplify cliで設定したキャッシュ時間を忘れてしまった場合は、amplify/backend/backend-config.jsonにて確認できます。


  "api": {
    "api-name": {
      "service": "AppSync",
      "providerPlugin": "awscloudformation",
      "dependsOn": [],
      "output": {
        "authConfig": {
          "defaultAuthentication": {
            "authenticationType": "AWS_LAMBDA",
            "lambdaAuthorizerConfig": {
              "lambdaFunction": "lambda-name",
	      // 設定したキャッシュ時間はここを参照
              "ttlSeconds": "300"
            }
          },
          "additionalAuthenticationProviders": []
        }
      }
    }
  }

スキーマを修正する

作成した認証用LambdaをAppsyncに割り当てていきます。amplify/backend/api/<api-name>/schema.graphqlを以下のように修正してきます。

  • Lambda認証を割り当てたいモデルに対して@authディレクティブを以下のように追加する
type Todo @model @auth(rules: [{ allow: custom }]) {
  id: ID!
  name: String!
  description: String
}

これでバックエンドの準備は完了したので、amplify pushを実行してデプロイします。

Amplify Librariesを使ってGraphqlを実行してみる

Amplify LibrariesはAmplify CLIとセットで使われることが多いと思いますので、Amplify Librariesを使ったフロントエンド実装もご紹介していこうと思います。

以下がJavascriptのサンプルコードです。

// 認証用Lambdaに渡すトークンを用意
const getAuthToken = () => 'myAuthToken';
const lambdaAuthToken = getAuthToken();

const getTodo = await API.graphql({
  query: queries.getTodo,
  variables: {input: todoDetails},
  // authModeに AWS_LAMBDA を設定
  authMode: 'AWS_LAMBDA',
  // トークンはここに設定するか直接Authorizationヘッダーに格納する
  authToken: lambdaAuthToken
});

実際に動作確認してみようと思います。

正しいトークンを渡すとステータスコード200と共に、以下のようにレスポンスが返ってきました。

無事認証に成功して、データの取得ができました。

では次に、間違ったトークンを渡した場合はどうなるでしょうか。
ステータスコード401と共に、以下のようにレスポンスが返ってきました。

意図した通り、トークンが一致しない場合はクエリーが実行できないようになっています。

VTLを使ってよりセキュアにGraphQLクエリーを実行する

ここまでで、AppsyncのLambda認証の基本的な実装を紹介してきました。

しかし、今の状態ですとモデル単位(DynamoDBで言うテーブル単位)での認証はされていても、データ単位(DynamoDBで言うレコード単位)での認証はされないため、トークンが正しければレコードの所有者に関わらずテーブル内の全てのデータを取得できてしまいます。

そこで、AppSync のリゾルバーを編集することで、クエリー実行時にレコード単位での認証チェックを行って所有者のレコードのみ取得するようフィルタリングします。

スキーマにowner属性を追加

レコードの所有者を示すowner属性を先程作成したTodoモデルに追加していきます。

type Todo @model @auth(rules: [{ allow: custom }]) {
  id: ID!
  name: String!
  description: String
+ owner: String! ## レコードの所有者のアカウントidが入ります
}

Amplifyで生成されたリゾルバーを上書きする

Amplifyはschema.graphqlの定義に基づいて、@model ディレクティブをつけたtypeについては全てリゾルバーを自動生成します。生成されたものはamplify/backend/api/<api-name>/build/resolversに定義されています。

この自動生成されたファイルの内容を上書きしていきます。今回は、先程作成したTodoモデルのレスポンスのVTLファイルを上書きしようと思います。

上書きしたいVTLファイルをコピペ

amplify/backend/api/<api-name>/build/resolversにあるQuery.listTodos.res.vtlファイルをコピーし、<project-dir>/amplify/backend/api/<api-name>/resolvers/にペーストする。

VTLファイルを編集

VTLファイルを編集していきます。
VTLのプログラミングガイドに則って、listTodosクエリーを実行した際に、ownerフィールドと認証用Lambdaから渡されたアカウントIDが一致するレコードにフィルタリングされるよう以下のように修正します。


## [Start] ResponseTemplate. **
- #if( $ctx.error )
-   $util.error($ctx.error.message, $ctx.error.type)
- #else
-   $util.toJson($ctx.result)
- #end

+ #set( $items = [] )
+ #foreach( $item in $ctx.result.items)
+   #if ($item["owner"] == $ctx.identity.resolverContext["accountID"])
+     $util.qr($items.add($item))
+   #end
+ #end
+ #set( $ctx.result.items = $items )
+ #if( $ctx.error )
+   $util.error($ctx.error.message, $ctx.error.type)
+ #else
+   $util.toJson($ctx.result)
+ #end

## [End] ResponseTemplate. **

認証用Lambdaのレスポンスを修正する

レスポンスの中にresolverContextを追加し、リゾルバーに渡すアカウントIDを定義する。

export const handler = async (event) => {
  const { authorizationToken } = event;
  
  // authorizationTokenを使って認証を行う
  // 外部認証システムで作成されたアカウントIDなど、アカウントごとに一意に発行される値を使用
  const getMyAccountId = () => 'myAccountId';
  const myAccountId = getMyAccountId();
  
  // 認証に成功した場合は isAuthorized をtrueで返す
  if (isAuthorized) {
      // リゾルバーに渡すaccountIDを定義する
      return {
        isAuthorized: true,
        ttlOverride: 300,
+	resolverContext: {
+	  accountID: myAccountId
+	}
      };
   // 認証に失敗した場合は isAuthorized をfalseで返す
   } else {
      return {
        isAuthorized: false,
        ttlOverride: 0,
      };
   }
}; 

amplify pushを実行してデプロイします。

動作確認

DynamoDBのTodoテーブルに以下3つのレコードを用意しました。

idが3のレコードのみ、ownerが異なります。

id name owner
1 test1 1234567890
2 test2 1234567890
3 test3 9999999999

そして、accountIDが123456789のユーザーがクエリーを実行すると以下のようになりました。

アカウントIDに一致するデータだけ取得するようフィルタリングされていました。

最後に

AmplifyでLambda認証とVTLのカスタムによってセキュアにGraphQLクエリーを実行してみました。ただ、Cognito認証を使うことができればこんな面倒くさいことをせずとも簡単にできてしまうのでCognito認証が使える場合はなるべくそちらをお勧めします。

参考

https://docs.amplify.aws/cli/graphql/authorization-rules/#custom-authorization-rule
https://docs.amplify.aws/lib/graphqlapi/authz/q/platform/js/#aws-lambda
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/security-authorization-use-cases.html
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/security-authz.html#aws-lambda-authorization
https://docs.amplify.aws/cli/graphql/custom-business-logic/#override-amplify-generated-resolvers
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-programming-guide.html

Discussion

ログインするとコメントできます