😽

Cognitoのユーザー登録をトリガーに、APIキーの発行とDynamoDBへの登録を行う

2022/04/22に公開

(2022年11月25日追記)
私の本が株式会社インプレス R&Dさんより出版されました。この記事の内容も含まれています。イラストは鍋料理さんの作品です。猫のモデルはなんとうちのコです!

https://www.amazon.co.jp/dp/B0BMPZW444/

感想を書いていただけるととても嬉しいです!

(2022年8月3日追記)この記事の内容はこちらの本でも読めます。

https://zenn.dev/sikkim/books/how_to_create_api_sales_service

はじめに

現在、APIの販売プラットフォームを作成中です。Cognitoで新規ユーザーを登録したら自動的にAPIキーを発行できるようになったので、方法を書き留めておきます。

今回実現したのは以下の内容です。

  • Cognitoのユーザー登録にともなって、ユーザーに紐づくAPIキーを発行する
  • APIキーに使用量プランを自動で適用する
  • 生成したAPIキーをDynamoDBに自動で登録する
  • Cognitoの認証情報を用いて、DynamoDBから該当ユーザーのAPIキーを取得する

事前準備

APIの実装

API本体の実装方法はこちらの記事を御覧ください。

使用量プランの準備

APIの月間使用回数の上限を設定します。月100回までのFREEプランと月100,000回までのPROプランを用意しました。使用量プランは「API Gateway→使用量プラン」で作成します。

使用量プランの設定例

Cognitoユーザープールの準備とクライアント側の実装

Cognitoユーザープールの準備はこちらの記事を、ReactでCognitoを利用する方法についてはこちらの記事をそれぞれ参照してください。なお、フェデレーティッドIDを作成する必要はありません。

DynamoDBテーブルの準備

「DynamoDB→テーブル→テーブルの作成」でDynamoDBのテーブルを新規作成します。パーティションキーはUserID、ソートキーはType(利用プランの種別)にしてみました。

DynamoDBテーブルの作成

以上で事前準備は完了です。

実装方法

ユーザープールのプロパティでLambdaトリガーを登録する

Cognitoのユーザープール設定画面で「ユーザープールのプロパティ」を選択し、Lambdaトリガーを追加します。

Lambdaトリガーを追加

「Lambdaトリガーを追加」で「サインアップ」と「確認後トリガー」を選択します。下図は設定済みの画面を開いたので確認後トリガーがグレーアウトされています。

サインアップトリガーを選択

登録するLambda関数は以下の通りです。

import boto3
import os

# 環境変数
REGION_NAME = os.environ['REGION_NAME']
DYNAMODB_TABLE = os.environ['DYNAMODB_TABLE']
REST_API_ID = os.environ['REST_API_ID']
USAGE_PLAN_ID = os.environ['USAGE_PLAN_ID']

dynamodb = boto3.resource('dynamodb', region_name=REGION_NAME)
table = dynamodb.Table(DYNAMODB_TABLE)
apigateway_cli = boto3.client('apigateway')


def lambda_handler(event, context):
    # ユーザー名をキーとする
    user_name = event['userName']

    # APIキーを発行
    result = apigateway_cli.create_api_key(
        name='fm_mail_free_' + user_name,
        enabled=True,
        stageKeys=[
            {
                'restApiId': REST_API_ID,
                'stageName': 'api'
            }
        ]
    )

    # 発行したAPIキーの値とIDを取得
    api_key = result['value']
    api_key_id = result['id']

    # APIキーに使用量プランを適用
    apigateway_cli.create_usage_plan_key(
        usagePlanId=USAGE_PLAN_ID,
        keyId=api_key_id,
        keyType='API_KEY'
    )

    # DynamoDBにAPIキーを登録
    with table.batch_writer() as batch:
        batch.put_item(Item={"UserID": user_name, "Type": "FREE", "ApiKey": api_key})

    # eventを返さないとCognito側で「Unrecognizable lambda output」というエラーになる
    return event

コメントを読めば何をしているかだいたいわかると思いますが、簡単に説明しておきます。

# 環境変数
REGION_NAME = os.environ['REGION_NAME']
DYNAMODB_TABLE = os.environ['DYNAMODB_TABLE']
REST_API_ID = os.environ['REST_API_ID']
USAGE_PLAN_ID = os.environ['USAGE_PLAN_ID']

事前に準備したAPIやDynamoDBの情報を環境変数から読み込みます。
環境変数はLambdaの「設定→環境変数」で設定できます。

環境変数の設定

    # APIキーを発行
    result = apigateway_cli.create_api_key(
        name='fm_mail_free_' + user_name,
        enabled=True,
        stageKeys=[
            {
                'restApiId': REST_API_ID,
                'stageName': 'api'
            }
        ]
    )

APIキーの発行処理です。Boto3のドキュメントを参考にして書きました。

    # APIキーに使用量プランを適用
    apigateway_cli.create_usage_plan_key(
        usagePlanId=USAGE_PLAN_ID,
        keyId=api_key_id,
        keyType='API_KEY'
    )

使用量プランはAPIキー作成時には指定できないので、あとから適用します。ドキュメントはこちらです。しかしcreate_usage_plan_keyというのはひどい命名だと思います。なぜapply_*attach_*にしなかったんでしょうね。おかげで調べるのが大変でした。

    # DynamoDBにAPIキーを登録
    with table.batch_writer() as batch:
        batch.put_item(Item={"UserID": user_name, "Type": "FREE", "ApiKey": api_key})

DynamoDBのプライマリキーはCognitoのユーザー名にしました。CognitoはGoogle認証のみにしたので、ユーザー名は「google_」で始まる一意のキーになります。実験したところ、ユーザーを削除して再登録しても生成されるユーザー名は変わらないようです。

    # eventを返さないとCognito側で「Unrecognizable lambda output」というエラーになる
    return event

戻り値を返すところです。最初は適当な値を返していたのですが、動かしてみると新規登録したときだけCognito認証がエラーになることが判明し、いろいろ調べたところeventを返す必要があるとわかりました。クラスメソッドさんの記事で解決しました。AWSで開発するようになってから、クラスメソッドさんには何度お世話になったことか数え切れません。

これでAPIキーの発行と使用量プランの適用、DynamoDBへの登録まで実装できました。

Cognitoの認証情報を用いてDynamoDBから該当ユーザーの情報だけを取得する

DynamoDBからデータを取り出すのは簡単ですが、以下の条件が加わると途端に実装は難しくなります。

  1. Cognito認証済みの場合のみアクセスできること
  2. 認証済みのユーザーに紐づく情報を取得できること
  3. 認証済みのユーザーが別ユーザーの情報を取得できないこと

ここを適当に実装するとセキュリティ事故が起きてしまいます。2番目まではすぐ実現できましたが、最後の条件を厳密に実装するのは大変でした。いろいろ調べた結果、Lambda関数の中でCognito認証の情報を取得できることがわかったので、最終的に以下のような実装に落ち着きました。外部からアクセスするので、素のLambdaではなくChaliceを利用しています。

import boto3
from boto3.dynamodb.conditions import Key
import json
import os
from chalice import Chalice, CognitoUserPoolAuthorizer

app = Chalice(app_name='dynamodb_api')

# 環境変数
USER_POOL_ARN = os.environ.get('USER_POOL_ARN')
USER_POOL_NAME = os.environ.get('USER_POOL_NAME')
DYNAMODB_TABLE = os.environ.get('DYNAMODB_TABLE')
REGION_NAME = os.environ.get('REGION_NAME')

# Cognitoで認証する
authorizer = CognitoUserPoolAuthorizer(
    USER_POOL_NAME,
    provider_arns=[USER_POOL_ARN]
)

# DynamoDBに接続
dynamodb = boto3.resource('dynamodb', region_name=REGION_NAME)
table = dynamodb.Table(DYNAMODB_TABLE)

@app.route('/apikey', authorizer=authorizer, cors=True)
def get_my_api_key():
    # 認証情報からUserNameを取り出す
    context = app.current_request.context
    user_name = context['authorizer']['claims']['cognito:username']

    # DynamoDBからユーザーに紐づくAPIキーの情報を取り出す
    result = table.query(KeyConditionExpression=Key('UserID').eq(user_name))

    resp = {
        'status': 'OK',
        'result': result,
        'UserName': user_name
    }

    # 結果を返す
    return json.dumps(resp, ensure_ascii=False)

いちばん重要なポイントはここです。

    # 認証情報からUserNameを取り出す
    context = app.current_request.context
    user_name = context['authorizer']['claims']['cognito:username']

    # DynamoDBからユーザーに紐づくAPIキーの情報を取り出す
    result = table.query(KeyConditionExpression=Key('UserID').eq(user_name))

Cognitoの認証情報からユーザー名を取り出してDynamoDBのキーとして用いています。リクエストパラメーターが存在しないのでなりすましの余地がありません。もしクライアント側でidTokenを偽装したとしても、ユーザー名を変えた時点で認証エラーとなりアクセスできなくなります。

(2022年5月15日追記)

usernameをユーザーID代わりに使うのは気持ち悪いと思う方もいるかもしれません。Cognitoのユーザープール内においてusernameは一意かつ変更不可なので、ユーザーIDとして利用しても問題ありません。それでもユーザーIDを使いたい場合は、下記のようにすれば取得できます。

    # 認証情報からUserIDを取り出す
    context = app.current_request.context
    user_id = context['authorizer']['claims']['identities'][0]['userId']

(追記ここまで)

~/.aws/credentialsファイルを適切に設定していれば、次のコマンドでAWSにデプロイできます。

chalice deploy

APIゲートウェイとIAMおよびLambdaが自動で設定されるので、とても楽です。本来ならIAMの権限も自動で設定されるはずですが、DynamoDBへのアクセス権限は自動で付与されなかったので手動でポリシーをアタッチする必要がありました。

クライアント側の実装

上記のAPIにアクセスするには次のようにします。まずcurlコマンドでアクセスする場合はこうです。

curl -X GET \
-H 'Content-Type:application/json' \
-H 'Authorization:Bearer {CognitoのidToken}' \
https://xxxxxxxxxxxxxxxxxxxxxxxx.amazonaws.com/api/apikey

Reactの実装は今のところ次のようにしています。まだ改良の余地が多いです。

src/components/APIKey.tsx
import { useEffect, useState, VFC } from 'react';
import { useAtom } from 'jotai';
import ky from 'ky';
import stateCurrentUser from '../atom/User';
import Spinner from './Spinner';
import Image1 from '../svg/undraw_content_team_3epn.svg';
import Image2 from '../svg/undraw_real_time_analytics_re_yliv.svg';
import Image3 from '../svg/undraw_react_y-7-wq.svg';
import Image4 from '../svg/undraw_remotely_-2-j6y.svg';
import APIKeyList from './APIKeyList';

type ApiKeyInfo = {
  Type: string;
  ApiKey: string;
};
type Result = { Items: ApiKeyInfo[] };
type Resp = { result: Result };

// 表示するコンテンツが少ないので画像を表示
// 同じ画像ではつまらないので、表示するたびにランダムに切り替える
const randomImage = [Image1, Image2, Image3, Image4][
  Math.floor(Math.random() * 4)
];

const APIKey: VFC = () => {
  // サインイン中のユーザー情報
  const [user] = useAtom(stateCurrentUser);

  // 読込中フラグ
  const [isLoading, setIsLoading] = useState<boolean>(true);

  // APIキー
  const [apiKeys, setApiKeys] = useState<ApiKeyInfo[] | null>(null);

  // DynamoDBからAPIキーを取得する
  useEffect(() => {
    // awaitを扱うため、いったん非同期関数を作ってから呼び出している
    const getApiKeys = async () => {
      try {
        if (user) {
          // Lambda経由でDynamoDBにアクセスする
          const res: Resp = await ky
            .get(
              ' https://xxxxxxxxxxxxxxxxxxxxxxxx.amazonaws.com/api/apikey',
              {
                headers: {
                  Authorization: `Bearer ${user.signInUserSession.idToken.jwtToken}`,
                },
              },
            )
            .json();
          if (res.result.Items) setApiKeys(res.result.Items);
        }
      } catch (e) {
        // APIキー取得に失敗したらnullをセット
        setApiKeys(null);
      }
    };

    // Promiseを無視して呼び出すことを明示するためvoidを付けている
    void getApiKeys();
  }, [user]);

  // APIキーを取得できたらローディング表示をやめる
  useEffect(() => {
    if (apiKeys) setIsLoading(false);
  }, [apiKeys]);

  // ローディング表示
  if (isLoading) {
    return (
      <section className="bg-white py-6 sm:py-8 lg:py-12">
        <div className="mx-auto max-w-screen-md px-4 md:px-8">
          <h1 className="mb-8 text-4xl font-bold">APIキーの確認</h1>
          <p className="mb-4">ご利用可能なAPIキーはこちらです。</p>
          <Spinner />
          <div className="w-5/6 md:w-1/2 lg:w-full lg:max-w-lg">
            <img
              className="rounded object-cover object-center"
              src={randomImage}
              alt="APIキーの確認"
            />
          </div>
        </div>
      </section>
    );
  }

  return (
    <section className="bg-white py-6 sm:py-8 lg:py-12">
      <div className="mx-auto max-w-screen-md px-4 md:px-8">
        <h1 className="mb-8 text-4xl font-bold">APIキーの確認</h1>
        <p className="mb-4">ご利用可能なAPIキーはこちらです。</p>
        <table className="mb-8 rounded-lg bg-white p-4 shadow">
          <thead>
            <tr>
              <th className="dark:border-dark-5 whitespace-nowrap border-b-2 p-4 font-normal text-gray-900">
                No.
              </th>
              <th className="dark:border-dark-5 whitespace-nowrap border-b-2 p-4 font-normal text-gray-900">
                プラン種別
              </th>
              <th className="dark:border-dark-5 whitespace-nowrap border-b-2 p-4 font-normal text-gray-900">
                APIキー
              </th>
              <th className="dark:border-dark-5 whitespace-nowrap border-b-2 p-4 font-normal text-gray-900">
                コピー
              </th>
            </tr>
          </thead>
          <tbody>
            {apiKeys &&
              apiKeys.map((item: ApiKeyInfo, index: number) => (
                <APIKeyList item={item} index={index} key={item.ApiKey} />
              ))}
          </tbody>
        </table>
        <div className="w-5/6 md:w-1/2 lg:w-full lg:max-w-lg">
          <img
            className="rounded object-cover object-center"
            src={randomImage}
            alt="APIキーの確認"
          />
        </div>
      </div>
    </section>
  );
};
export default APIKey;

HTTPクライアントはkyを使用しています。

https://github.com/sindresorhus/ky

よく利用されているaxiosは古いXMLHttpRequestで実装されているためService Workerで扱うことができません。kyはfetchで実装されているため、Service Workerに対応しています。使うかどうかは別として、axiosではPWA対応するときに困るので、kyを使っておいた方が無難だと判断しました。

子コンポーネントの方は次のように実装しました。

src/components/APIKeyList.tsx
import { useState, VFC } from 'react';

type ApiKeyInfo = {
  Type: string;
  ApiKey: string;
};
type Props = {
  index: number;
  item: ApiKeyInfo;
};

const APIKeyList: VFC<Props> = ({ index, item }) => {
  // コピー済みツールチップの表示制御用
  const [isCopied, setIsCopied] = useState<boolean>(false);

  // 引数をクリップボードにコピーする
  const copyTextToClipboard = (text: string) => {
    void navigator.clipboard.writeText(text);
    // 2秒間ツールチップを表示する
    setIsCopied(true);
    setTimeout(() => setIsCopied(false), 2000);
  };

  return (
    <tr className="text-gray-700">
      <td className="dark:border-dark-5 border-b-2 p-4">{index + 1}</td>
      <td className="dark:border-dark-5 border-b-2 p-4">{item.Type}</td>
      <td className="dark:border-dark-5 border-b-2 p-4">{item.ApiKey}</td>
      <td className="dark:border-dark-5 border-b-2 p-4">
        <button
          className="hover:opacity-75"
          data-tooltip-target="tooltip-default"
          type="button"
          onClick={() => copyTextToClipboard(item.ApiKey)}
        >
          <div className="group relative flex flex-col items-center">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="24"
              height="24"
              viewBox="0 0 24 24"
              fill="none"
              stroke="#000000"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
              <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
            </svg>
            <div
              className={`absolute bottom-0 mb-6 flex flex-col items-center ${
                isCopied ? '' : 'hidden'
              }`}
            >
              <span className="whitespace-no-wrap relative z-10 bg-gray-600 p-2 text-xs leading-none text-white shadow-lg">
                coppied
              </span>
              <div className="-mt-2 h-3 w-3 rotate-45 bg-gray-600" />
            </div>
          </div>
        </button>
      </td>
    </tr>
  );
};
export default APIKeyList;

表示は下図のようになります。まだ有償版は実装していないので、DynamoDBに手動でType="PRO"のデータも作成してみました。

APIキーの確認画面

まとめ

Cognitoのユーザー登録にともなって自動的にAPIキーを発行し、DynamoDBに登録する方法と、DynamoDBから安全に情報を取り出す方法を説明しました。

ここまで来ればゴールは見えてきました。まだStripeとの連携が残っていますが、有償版のAPIキー発行処理は今回の方式を微修正すればできそうですし、残りの課題も小さなものばかりです。完成が楽しみです。

Discussion