Serverless Framework で Cognito を構築する

この記事は [CodeChrysalis Advent Calendar 2019](https://qiita.com/advent-calendar/2019/codechrysalis) の記事です。

[Serverless FrameworkでDynamoDBを構築する](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)ことについての記事もぜひ御覧ください。

# はじめに

[Serverless Framework](https://serverless.com/) はYAMLでサーバサイドの環境構築ができる、Infrastructure As Code の真骨頂です。確かにAWS等のコンソールでもポチポチクリックしながら環境構築はできますが、

1. 変更の管理ができない
2. 画面でポチポチクリックするのが本当に面倒（Lazy...）

なのでServerless Framework の CLI でコマンド一発で全てが構築されるのはとても気持ちが良い。今回はその中でもAWSのCognitoをどのように構築するかを紹介します。

この説明の前提は、認証プロバイダはCognito Identity Poolとし、Cognito User Poolと連携して、API GatewayにアクセスさせるときにデフォルトのAWS authorizerを使用したいという状況とします。

## まずはYAML

早速YAMLを紹介します。

### serverless.yml

```YAML
service: cc-cognito

custom:
  stage: ${opt:stage, self:provider.stage}

provider:
  name: aws
  stage: dev
  profile: cc-admin
  region: ap-northeast-1

  iamRoleStatements:
    - Effect: Allow
      Action:
        - cognito-idp:ListUsers
        - cognito-idp:AdminListGroupsForUser
      Resource: "arn:aws:cognito-idp:ap-northeast-1:*:*"

resources:
  # IAM
  - ${file(resources/iam-roles.yml)}
  # Cognito
  - ${file(resources/cognito-user-pool.yml)}
  - ${file(resources/cognito-identity-pool.yml)}
```

### cognito-user-pool.yml

```YAML
Resources:
  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      AutoVerifiedAttributes:
        - email
      EmailConfiguration:
        EmailSendingAccount: "DEVELOPER"
        SourceArn: "arn:aws:ses:[ここにArn]:identity/[ここにemailAddress]"
      EmailVerificationMessage: "任意のEmail本文"
      EmailVerificationSubject: "任意のEmailタイトル"
      MfaConfiguration: "OFF"
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
      UserPoolName: ${self:custom.stage}-CognitoUserPool
      UsernameAttributes:
        - email

  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: ${self:custom.stage}-CognitoUserPoolClient
      ExplicitAuthFlows:
        - ADMIN_USER_PASSWORD_AUTH
      GenerateSecret: false
      UserPoolId:
        Ref: CognitoUserPool

Outputs:
  UserPoolId:
    Value:
      Ref: CognitoUserPool
    Export:
      Name: ${self:custom.stage}-UserPoolId

  UserPoolClientId:
    Value:
      Ref: CognitoUserPoolClient
    Export:
      Name: ${self:custom.stage}-UserPoolClientId
```

### cognito-identity-pool.yml

```YAML
Resources:
  CognitoUserIdentityPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      IdentityPoolName: ${self:custom.stage}CognitoUserIdentityPool
      AllowUnauthenticatedIdentities: false
      CognitoIdentityProviders:
        - ClientId:
            Ref: CognitoUserPoolClient
          ProviderName:
            Fn::GetAtt: [ "CognitoUserPool", "ProviderName" ]

  CognitoUserIdentityPoolRoleAttachment:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId:
        Ref: CognitoUserIdentityPool
      Roles:
        authenticated:
          Fn::GetAtt: [CognitoUserAuthRole, Arn]

Outputs:
  CognitoUserIdentityPool:
    Value:
      Ref: CognitoUserIdentityPool
    Export:
      Name: ${self:custom.stage}-CognitoUserIdentityPool
```

### iam-roles.yml

```YAML
Resources:
  CognitoUserAuthRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Federated: "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud":
                  Ref: CognitoUserIdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": authenticated
      Policies:
        - PolicyName: "CognitoAuthorizedPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "mobileanalytics:PutEvents"
                  - "cognito-sync:*"
                  - "cognito-identity:*"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "execute-api:Invoke"
                Resource:
                  # Allow users to invoke hello GET API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/GET/hello"
                  # Allow users to invoke hello POST API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/POST/hello"
```

## 関係性を解説

### Cognito User Pool

ユーザーID（Emailや電話番号とすることもできる）とパスワードを保管して、サインアップやログイン等を司ることができます。MFA（多要素認証）も使うことができます。

ログインに成功すると、何に対してアクセスできるのかを後述するCognito Identity Poolに判断させます。

### Cognito Identity Provider

今回でいうとIdentityを提供してくれる、すなわちCognito Identity ProviderであるCognito User Poolから提供された情報に基づき、どのIAMロールを付加するかを判断します。

これによって例えばログインしてきたユーザーはどのエンドポイントにアクセスできるかを切り分けることができます。管理者とユーザーそれぞれでユーザープールを作り、それぞれで異なるIAMロールを割り当てることで実現できます。

### IAM

IAMはどんなAWS製品でも避けては通れない認識なので皆さんご存知なのではないでしょうか。権限を付与するためのものです。今回でいうと、Cognito Identity Poolによってログインしてきたユーザーに割り当てるIAMを作ります。

## YAMLの要素を解説

### serverless.yml

[Serverless Framework で DynamoDB を構築する
](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)の続きで、マイクロサービスを前提としますので、まずは`serverless.yml`です。

再掲

```YAML
service: cc-auth

custom:
  stage: ${opt:stage, self:provider.stage}

provider:
  name: aws
  stage: dev
  profile: cc-admin
  region: ap-northeast-1

  iamRoleStatements:
    - Effect: Allow
      Action:
        - cognito-idp:ListUsers
        - cognito-idp:AdminListGroupsForUser
      Resource: "arn:aws:cognito-idp:ap-northeast-1:*:*"

resources:
  # IAM
  - ${file(resources/iam-roles.yml)}
  # Cognito
  - ${file(resources/cognito-user-pool.yml)}
  - ${file(resources/cognito-identity-pool.yml)}
```

ここで`resources`に渡したのは3つのファイル参照です。Cognitoは認証を司るため、認証まわりを一つのサービスと見立ててマイクロサービス化しています。逆にCognito User Pool, Cognito Identity Pool, IAMは認証の部品なので一つのマイクロサービスに包みました。が、それぞれ別の製品なので別のファイルで管理するようにしました。

他のプロパティについては[Serverless Framework で DynamoDB を構築する
](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)を参照してください。

### Cognito User Pool

再掲

```YAML
Resources:
  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      AutoVerifiedAttributes:
        - email
      EmailConfiguration:
        EmailSendingAccount: "DEVELOPER"
        SourceArn: "arn:aws:ses:[ここにArn]:identity/[ここにemailAddress]"
      EmailVerificationMessage: "任意のEmail本文"
      EmailVerificationSubject: "任意のEmailタイトル"
      MfaConfiguration: "OFF"
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
      UserPoolName: ${self:custom.stage}-CognitoUserPool
      UsernameAttributes:
        - email

  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: ${self:custom.stage}-CognitoUserPoolClient
      ExplicitAuthFlows:
        - ADMIN_USER_PASSWORD_AUTH
      GenerateSecret: false
      UserPoolId:
        Ref: CognitoUserPool

Outputs:
  UserPoolId:
    Value:
      Ref: CognitoUserPool
    Export:
      Name: ${self:custom.stage}-UserPoolId

  UserPoolClientId:
    Value:
      Ref: CognitoUserPoolClient
    Export:
      Name: ${self:custom.stage}-UserPoolClientId
```

基本的にはAWS CloudFormationの文法に基づきます。

`Type: AWS::Cognito::UserPool` でこれからCognito User Poolを作るという宣言になります。

`AutoVerifiedAttributes`はユーザーがサインアップするときに何を以て確認するかです。選択肢はEmailかSMSがあります。今回はEmailを選択しました。

```
      EmailConfiguration:
        EmailSendingAccount: "DEVELOPER"
        SourceArn: "arn:aws:ses:[ここにArn]:identity/[ここにemailAddress]"
```

ここでは任意のEmailアドレスから確認のEmailを送信したいときの設定をしています。AWSのデフォルトのEmailアドレスから送信するのでよければこの設定は不要です。

もしこの機能を利用する場合は、事前にAWS SESで任意のEmailアドレスを登録し、[サンドボックスを解除](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/request-production-access.html)する必要があります。

`EmailVerificationMessage`は任意のEmail本文を使うための設定、`EmailVerificationSubject`は任意のタイトルを使うための設定です。

`MfaConfiguration`は多要素認証を利用するかどうかです。今回はOFFにしています。

```YAML
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
```

これはパスワードの強固さの規定です。上から順番に、最低何文字か、小文字を必要とするか、数字を必要とするか、記号を必要とするか、大文字を必要とするかの設定です。

`UserPoolName`はユーザープールの名前を何にするかです。[Serverless Framework で DynamoDB を構築する
](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)でご紹介した`custom`を用いることにより、開発環境と本番環境の名前を分けています。

```
      UsernameAttributes:
        - email
```

これはCognitoにどんなデータをもたせるかです。実はCognitoもデータベースのようにいろいろな情報をもたせることができます。が、Cognitoに持たせるかデータベースに持たせるかは設計次第です。

```YAML
  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: ${self:custom.stage}-CognitoUserPoolClient
      ExplicitAuthFlows:
        - ADMIN_USER_PASSWORD_AUTH
      UserPoolId:
        Ref: CognitoUserPool
```

Cognito User Pool Clientの設定です。`ClientName`は任意の名前を設定します。ここでも`custom`プロパティを使って本番環境と開発環境で名前を区別しています。

ExplicitAuthFlowsはCognito Clientでサポートする認証フローです。設定したい認証フローに応じて値を変えます。ドキュメントは[こちら](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_CreateUserPoolClient.html)にあります。

`UserPoolId` はどのPoolに帰属するClientかを設定します。Ref関数を使って先ほど設定したPoolと関連付けました。

### Cognito Identity Pool

再掲

```YAML
Resources:
  CognitoUserIdentityPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      IdentityPoolName: ${self:custom.stage}CognitoUserIdentityPool
      AllowUnauthenticatedIdentities: false
      CognitoIdentityProviders:
        - ClientId:
            Ref: CognitoUserPoolClient
          ProviderName:
            Fn::GetAtt: [ "CognitoUserPool", "ProviderName" ]

  CognitoUserIdentityPoolRoleAttachment:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId:
        Ref: CognitoUserIdentityPool
      Roles:
        authenticated:
          Fn::GetAtt: [CognitoUserAuthRole, Arn]

Outputs:
  CognitoUserIdentityPool:
    Value:
      Ref: CognitoUserIdentityPool
    Export:
      Name: ${self:custom.stage}-CognitoUserIdentityPool
```

Cognito Identity Poolを作成するときのTypeは`AWS::Cognito::IdentityPool`です。`IdentityPoolName`は名前なので任意の名前とします。

`AllowUnauthenticatedIdentities`は認証されていないユーザーを許可するかどうかですが、サインインしないとリソースにアクセスさせない場合は`false`とします。

`CognitoIdentityProviders`は認証情報は何が提供してくれるのかを定義するところです。ClientIdはRef関数で`CognitoUserPoolClient`を、`ProviderName`は`Fn::GetAtt`で`CognitoUserPool`を参照していますが、これは`cognito-user-pool.yml`でOutputsを設定しているからです。Outputsに設定したものは同じアカウントのCloudFormation上で作成されたものは読み取ることができます。

`CognitoUserIdentityPoolRoleAttachment`では、どのIdentity PoolでどのIAMロールを割り当てるかを決めるものです。IAMロールは後述しますが、ここではつい先程設定した`CognitoUserIdentityPool`には`CognitoUserAuthRole`のロールを割り当てる設定となっています。

### IAM

再掲

```YAML
Resources:
  CognitoUserAuthRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Federated: "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud":
                  Ref: CognitoUserIdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": authenticated
      Policies:
        - PolicyName: "CognitoAuthorizedPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "mobileanalytics:PutEvents"
                  - "cognito-sync:*"
                  - "cognito-identity:*"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "execute-api:Invoke"
                Resource:
                  # Allow users to invoke hello GET API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/GET/hello"
                  # Allow users to invoke hello POST API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/POST/hello"
```

まず`AssumeRolePolicyDocument`で誰がどのようにユーザーにIAMロールを割り当てるのかを設定します。`Principal`がAWSのリソース（この例では`Policies`に記載されているLambda）に対してアクセスするリクエストを発行する主体になります。ここではCognito Identity Poolとするため、`"cognito-identity.amazonaws.com"`としています。

```YAML
            Action:
              - "sts:AssumeRoleWithWebIdentity"
```

アプリにサインアップしたユーザーにIAMロールを割り当てるためにAWS STSの`sts:AssumeRoleWithWebIdentity`を使います。IAMユーザーに登録しなくともAWSのリソースに一時的にアクセスすることを許すためのものです。

```YAML
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud":
                  Ref: CognitoUserIdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": authenticated
```

これはIAMロールを割り当てるための条件です。認証を司るものが`CognitoUserIdentityPool`であって、かつその結果が`authenticated`のもの、つまりログインされているものだけとしています。

```YAML
      Policies:
        - PolicyName: "CognitoAuthorizedPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "mobileanalytics:PutEvents"
                  - "cognito-sync:*"
                  - "cognito-identity:*"
                Resource: "*"
              - Effect: "Allow"
                Action:
                  - "execute-api:Invoke"
                Resource:
                  # Allow users to invoke hello GET API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/GET/hello"
                  # Allow users to invoke hello POST API
                  - Fn::Join:
                      - ""
                      - - "arn:aws:execute-api:"
                        - Ref: AWS::Region
                        - ":"
                        - Ref: AWS::AccountId
                        - ":"
                        - "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
                        - "/*/POST/hello"
```

そして`Policies`には具体的にどんなAWSのリソースにアクセスすることを許すのかの設定です。今回はLambdaをAPI GATEWAYを介して呼び出すアーキテクチャのための認証であると仮定して、API GATEWAYのコールを許可しています。

`"Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId`の部分はすでにデプロイされているAPI Gatewayのリソースを取得していて、API Gatewayの当該パスにアクセスすることを許すという記載です。

## デプロイの順番

IAMでAPI Gatewayを参照していると書きましたが、IAMはCognito Identity Poolに参照されています。つまり少なくともIAMとCognito Identity PoolのデプロイはAPI Gatewayの後でなければならないということです。Serverless Frameworkでマイクロサービスをデプロイするときはその順番も大事になります。

# さいごに

Serverless Frameworkで[データベース](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)、認証を構築する紹介をしました。次はAPI GATEWAYを紹介したいと思います。