最近の砂場活動その9: Cognitoで認証済みのユーザーでAPI Gatewayを呼び出す

ML NewsのWeb UIにアノテーションボタンを付けたい、でもユーザー管理機能は作りたくない(アノテーションするのは自分だけなので)。AWSのCognitoとAPI Gatewayを組み合せると簡単にできると聞いたので、やってみました。色々てこずったので正直あまり簡単ではなかったけど、やりたかったことはできたのでメモとして書き残しておきます。

やりたいことは以下。

  • AWSの仕組みでいい感じにユーザー認証されたい
    • 認証の仕組みを自前でやりたくない
  • 認証されたユーザーのみが叩けるエンドポイントを工数少なく実現したい

API Gatewayで所望のエンドポイントを生やす

認証をどうやるかは後回しにして、ひとまずエンドポイントをAPI Gatewayで生やしていきます。API Gatewayに生やしておくと後々の認証(Cognito)との接続がよいので、これを使います。

API Gatewayが何かを一言で説明するのは難しいけど、今回の用途に限れば裏側のロジックをLambdaで書きつつ、それをhttpで叩けるエンドポイントになってくれる君と思っておけばよい。別にlambdaじゃなくてもよいけど、今回はlambda。その他、リクエストをキャッシュさせたりといったこともできるようだが、今回の用途にはあまり関係ない。

ロジックをLambdaに書く

処理の本体はLambdaに書きます。Lambdaは色んな言語で書けるけど、手慣れているGolangで書きます。

Lambdaに書くロジック

package main

import (
    "net/http"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-lambda-go/events"
    "os"
    "log"
    "fmt"
    "encoding/json"
)

type MyRequest struct {
    Url string `json:"url"`
    Label model.LabelType `json:"label"`
}

var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile)

func UpdateExampleLabel(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    myReq := new(MyRequest)
    if err := json.Unmarshal([]byte(req.Body), myReq); err != nil {
        return clientError(http.StatusUnprocessableEntity)
    }
    // ここにロジックを書いていく

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusOK,
        Body:       `{"hoge": 1}`,
        Headers: map[string]string{
            "Access-Control-Allow-Origin" : "*", // CORS対策
        },
    }, nil
}

func serverError(err error) (events.APIGatewayProxyResponse, error) {
    errorLogger.Println(err.Error())

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Body:       http.StatusText(http.StatusInternalServerError),
    }, nil
}

func clientError(status int) (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: status,
        Body:       http.StatusText(status),
    }, nil
}

func main() {
    lambda.Start(UpdateExampleLabel)
}

API Gatewayの設定

ロジックが書けたらaws-sam-cliを使って手元でテストしたりするとよい。sam local start-apiとかやると手元でサーバーが走ります。

動作確認ができたらsamを使ってLambdaにデプロイしていく。今回の用途ではprivate subnet内のRDSに接続する必要があったため、VPC内にLambdaを作った。samのテンプレートはこんな感じ。

samのテンプレート

  UpdateExampleLabel:
    Type: AWS::Serverless::Function
    Properties:
      Description: "事例のラベルを更新する"
      Runtime: go1.x
      Handler: update_example_label
      CodeUri: update_example_label/build # ビルドファイル設置ディレクトリ
      Timeout: 10
      Policies:
        - VPCAccessPolicy: {}
      VpcConfig:
        SecurityGroupIds:
          - Fn::ImportValue:
              !Sub "${VPCStackName}:BatchSecurityGroup"
        SubnetIds:
          - subnet-xxx
          - subnet-yyy
      Environment:
        Variables:
          POSTGRES_HOST: XXXXX.YYYYY.us-east-1.rds.amazonaws.com
      Events:
        GetEvent: # 任意のイベント名
          Type: Api
          Properties:
            Path: /update_example_label
            Method: post
  LambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName: !Ref UpdateExampleLabel
      Action: "lambda:InvokeFunction"
      Principal: "apigateway.amazonaws.com"

samをデプロイすると、API Gatewayのリソースも作られていると思う。

API Gatewayで作ったエンドポイントと元々のドメイン(www.machine-learning.news)は異なるため、CORS(Cross-Origin Resource Sharing)を有効にする必要がある。API Gatewayの画面から「CORSの有効化」を選択、200のレスポンスヘッダーに以下のものを追加しておく。

Cognitoの設定

ここここを参考にしながらAWSコンソールでボタンをポチポチ押していく。ユーザープールとIDプールを作成していく。注意するところとしてこんな感じかな。

  • 認証プロバイダはCognitoだけで特に問題ない
  • ユーザーの登録はcliでやっておく。そうしないとパスワード変更画面を作らないといけなくなるので、一手間増えてしまう
    • 新規ユーザーを受け付ける場合はちゃんと画面を作りましょう...
    • アカウントのステータスがCONFIRMEDになっていればok
  • 全般設定 => アプリクライアントからアプリクライアントを作っておく
    • アプリの統合 => アプリクライアントの設定から「有効なIDプロバイダ」でcognitoを選択しておく

Amplifyで認証されたユーザーのみにアノテーションボタンを見せる

作成したユーザーがサインイン/アウトする画面はぐぐると色々出てくると思うので、それを参考に作る。今回は新しくユーザーがサインアップできる必要はなく、自分用のユーザーはcliで作成しているのでサインアップ画面は特に作らない。currentSessionかどうかを見てisAdminをtrue or falseにして、それによって出し分けする。

ボタンの出し分けに必要なJSの断片

import Amplify, {
  Auth,
  API,
} from 'aws-amplify';

Amplify.Logger.LOG_LEVEL = 'DEBUG';
Amplify.configure({
  Auth: {
    region: 'us-east-XXX',
    identityPoolId: 'us-east-XXX:YYY',
    userPoolId: 'ZZZ',
    userPoolWebClientId: 'AAAAA',
  },
});

export function IsAdmin() {
  return Auth.currentSession().then(_ => true).catch(_ => false);
}

export default {
  data () {
    return {
      ...,
      isAdmin: false,
    }
  },
  mounted() {
    IsAdmin().then(isAdmin => this.isAdmin = isAdmin);
  },
}

Cognitoで認証されたユーザーのみAPI Gatewayを呼び出せるようにする

画面の上では認証されたユーザーのみにアノテーションボタンを見せられるようになったが、API Gateway自体はインターネットで公開されているエンドポイントになっているため、誰でもエンドポイントを叩けばアノテーションをいじれてしまう。それでは困るので、API自体もCognitoで認証済みのユーザーのみ叩けるようにする。

API Gatewayの設定

Cognitoを設定したときに自動的に作られてるIAM Role(arn:aws:iam::XXXXX:role/Cognito_YYYYYAuth_Roleのようなやつ)に追加で権限を付与する。invokeの権限を今回許可したいエンドポイントのみに与える。

{
    "Sid": "VisualEditor1",
    "Effect": "Allow",
    "Action": "execute-api:Invoke",
    "Resource": "arn:aws:execute-api:us-east-1:XXXXX:YYYYY/*/POST/update_example_label"
}

次にAPI Gatewayの画面のオーソライザーから「新しいオーソライザーの作成」を選択。自分が作成したユーザープールを指定する。

オーソライザーが作成できるとリソースの認証方法のCognitoが指定できるようになる。こんな感じ。

必須ではないと思うけど、「HTTPリクエストヘッダー」でAuthorizationを必須にしておくとよさそう。

ここまで設定したらAPIのデプロイを行なって変更点を反映する。

クライアント側の設定

クライアント側は簡単。localStorageから必要なtokenを取得してきて、リクエストヘッダーに付けるだけ。

idTokenを取得してAPI Gatewayを叩くクライアント側のコード

export default {
  props: ['example'],
  methods: {
    updateLabel(example, label) {
      let idToken = localStorage.getItem("CognitoIdentityServiceProvider.XXXXX.YYYYY.idToken");
      let headers = { headers: { 'Authorization': idToken } };
      axios.post(
        "https://ZZZZZ.execute-api.us-east-1.amazonaws.com/Prod/my_endpoint", 
        {
          url: example.Url,
          label: label,
        },
        headers
      ).then(response => {
        example.Label = label;
      }).catch(function (error) {
        alert(`Failed to annotate "${example.Title}"`);
      })
    },
  }
}

困ってるところ

めちゃくちゃ困ってるというわけでもないけど、しばらくLambdaを起動させていなかった後の初回起動に時間がかかる。VPC内にLambdaを作った関係でENI(Elastic Network Interface)を作成する必要があるからだそうな。回避策としては定期的にポーリングしておくとよいらしいが、そこまで困ってるわけでもないので困りだしたらやる。

参考