KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

Google + Cognitoでユーザーグループ単位の認証認可

こんにちは、あるいはこんばんは(アーニャ声)。 突然ですが認証認可周りの設計・実装は毎回全然違った要件に沿って違った感じでやっていく感じになるのでおもしろいですよね。 この記事では、先日Musubi Insightチームが社内向けに作成したコンソールの設計・実装について認証認可周りに絞ってまとめたいと思います。 認証認可はCognito User Groupベースで実装したのですが、意外とネットの情報が少なかったので参考になれば幸いです! なおブログ中に登場する画面のスクショについてなのですが、普段フロントエンドをほぼ触らない筆者がサクッと作ったものですので、デザインなどあれな部分もあるかと思います。ご容赦くださいませ(アーニャ声でごまかす)

背景

この社内コンソール(以下、Kizuki Consoleとよびます。Kizukiはチームの名前です。)が作られた理由は、Musubi Insightの急激な成長です。 いくつか従前よりエンジニアが手動で対応していた作業があったのですが、それらにかかる負担がコミュニケーションコストなども含めるとかなり肥大化してきており、事業のスケーリングにおける障害になり始めていました。そのような状況を受けて、非エンジニアだけでもそういった作業を巻き取れるようにKizuki Consoleが爆誕する運びとなりました(Slack Ops的な選択肢も検討したのですがその辺りの話は本記事では割愛します)。 さて、認証・認可周りの具体的な要件は以下です: - KAKEHASHI社内のGoogle IDを持っていない人はログイン不可 - KAKEHASHI社内のGoogle IDを持っている人は - Kizuki memberグループに属していればKizuki memberグループの権限に応じて利用可能 - Kizuki supporterグループに属していればKizuki supporterグループの権限に応じて利用可能 - いずれのグループにも属していなければ利用不可

ユーザーグループを分けている理由としては、普段コンソールを使うメンバー(Kizuki member)とSREなどの全社的な管理ユーザー的なメンバー(Kizuki supporter)で将来権限に違いを出したくなるかもだから分けておこうという感じです。 以下の章で、具体的な設計と実装について触れていきます。

設計と実装

ざっくりした全体像

overview

認証認可の設計の話に入る前に、周辺のスタックについて整理します。まずフロントエンドはSvelteで実装し、S3 + Cloudfrontの横綱構成でホスティングしました。フロントのCI/CDはAWS Amplifyでやっています。一方のバックエンドはAPIGateway + Lambdaの構成で、LambdaにはRDS proxyを挟んでAuroraにアクセスさせます。スタック全体はSAMで管理していて(Auroraは既存)CI/CDはGithub Actionsでやっています。上のポンチ絵のようなイメージです。

認証認可

さて、認証認可ですが、フロントエンドとバックエンドの両方にCognito User Pool(ID providerにGoogleを設定) + Cognito ID Poolによる認証認可の制限がかかっています。

バックエンド

Cognito User Pool(id providerはGoogle)に認証を任せてCognito ID Poolに認可を任せる、基本的な形が下敷きになっています(公式doc)。その基本形に、Cognito User GroupとIAM Roleを紐付けて各User Group特有の認可を与える要素が加わった感じになっています。頑張って再度ポンチ絵を作成したのでご査収ください。 auth_overview これ以上リソース同士の関連を細かく書き下すと大変なことになるので、代わりにSAM templateのメイン部分を以下に示します。似たようなことをするときはこちらを元にすれば大体再現できるかと思います。(早口のアーニャ声でごまかす) Google側の設定についてはこの記事などを参考にしてください。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
  Stage:
    Default: dev
    Type: String
    AllowedValues:
      - local
      - dev
      - prod
  RdsSecretName:
    Type: String
  RdsSecretArn:
    Type: String
  SecurityGroups:
    Type: CommaDelimitedList
  Subnets:
    Type: CommaDelimitedList

Globals:
  Function:
    Timeout: 60
    MemorySize: 512
    Runtime: python3.9
    Tracing: Active
    CodeUri: functions/
    Environment:
      Variables:
        rdsSecretName: !Ref RdsSecretName
        stage: !Ref Stage

Resources:
  ListSuperusersFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: list_superusers.lambda_handler
      VpcConfig:
        SecurityGroupIds: !Ref SecurityGroups
        SubnetIds: !Ref Subnets
      Policies:
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretArn: !Ref RdsSecretArn
      Events:
        ListSuperusers:
          Type: Api
          Properties:
            Path: /list/superusers
            Method: get
            RestApiId: !Ref KizukiConsoleApi
            Auth:
              Authorizer: null

  # API Gateway
  KizukiConsoleApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      Cors: 
        AllowMethods: "'OPTIONS,POST,GET'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: AWS_IAM
        AddDefaultAuthorizerToCorsPreflight: false

  # IAM: Role for Kizuki members
  KizukiMemberRole:
    Type: AWS::IAM::Role
    Properties: 
      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 CognitoIdPool
              ForAnyValue:StringLike:
                cognito-identity.amazonaws.com:amr:
                - "authenticated"
      Policies: 
          - 
            PolicyDocument: 
              Version: "2012-10-17"
              Statement: 
                - 
                  Effect: "Allow"
                  Action: "execute-api:Invoke"
                  Resource: 
                  - !Join [ "/", [!Join [ ":", [ "arn:aws:execute-api",!Ref 'AWS::Region',!Ref 'AWS::AccountId',!Ref KizukiConsoleApi]], "*"]]

  # IAM: Role for Kizuki supporters
  # 一旦メンバーと同じく全てのエンドポイントにアクセス可能にしておく
  KizukiSupporterRole:
    Type: AWS::IAM::Role
    Properties: 
      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 CognitoIdPool
              ForAnyValue:StringLike:
                cognito-identity.amazonaws.com:amr:
                - "authenticated"
      Policies: 
          - 
            PolicyDocument: 
              Version: "2012-10-17"
              Statement: 
                - 
                  Effect: "Allow"
                  Action: "execute-api:Invoke"
                  Resource: 
                  - !Join [ "/", [!Join [ ":", [ "arn:aws:execute-api",!Ref 'AWS::Region',!Ref 'AWS::AccountId',!Ref KizukiConsoleApi]], "*"]]
  # Cognito 
  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Ref AWS::StackName
  
  UserPoolDomain:
      Type: AWS::Cognito::UserPoolDomain
      Properties:
        UserPoolId: !Ref CognitoUserPool
        Domain: !Sub "kizuki-console-${Stage}"

  CognitoClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref CognitoUserPool
      GenerateSecret: false
      PreventUserExistenceErrors: ENABLED

  CognitoIdPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      IdentityPoolName: !Sub "kizuki_console_id_pool_${Stage}"
      CognitoIdentityProviders: 
        -
          ClientId: !Ref CognitoClient
          ProviderName: !GetAtt CognitoUserPool.ProviderName
          ServerSideTokenCheck: true
      AllowUnauthenticatedIdentities: false
  
  # User Group for Kizuki Members
  CognitoKizukiMemberGroup:
    Type: AWS::Cognito::UserPoolGroup
    Properties: 
      Description: Kizuki members' group
      GroupName: kizuki-members
      RoleArn: !GetAtt KizukiMemberRole.Arn
      UserPoolId: !Ref CognitoUserPool
  
  # User Group for Kizuki Supporters
  CognitoKizukiSupporterGroup:
    Type: AWS::Cognito::UserPoolGroup
    Properties: 
      Description: Kizuki supporters' group
      GroupName: kizuki-supporters
      RoleArn: !GetAtt KizukiSupporterRole.Arn
      UserPoolId: !Ref CognitoUserPool

フロントエンド

まずログインページ。ログインボタンを押すとaws-amplifyAuth.federatedSignIn({provider: CognitoHostedUIIdentityProvider.Google,})}が走ります。ログインが成功するとルートページに飛ぶようCognito UserPoolの設定がしてあります。コードとスクショのイメージは以下です。

<script>
  import { CognitoHostedUIIdentityProvider } from "@aws-amplify/auth";
  import { Auth } from "aws-amplify";
  import Button, { Label } from "@smui/button";
  import Card, { Content, Actions, ActionButtons } from "@smui/card";
  import Header from "../components/layout/Header.svelte";
</script>

<Header />

<div class="login-card-container">
  <Card>
    <Content style="margin:0 auto;">
      <h3>ログイン</h3>
    </Content>
    <Actions>
      <ActionButtons
        style="display: flex;
      width: 100%;
      align-items: center;
      flex-direction: column;"
      >
        <Button
          on:click={() =>
            Auth.federatedSignIn({
              provider: CognitoHostedUIIdentityProvider.Google,
            })}
          variant="raised"
        >
          <Label>Googleでログインする</Label>
        </Button>
      </ActionButtons>
    </Actions>
  </Card>
</div>

<style>
  .login-card-container {
    margin: 80px auto 0 auto;
    width: 50%;
  }
</style>

login トップのSvelteコンポーネント(App.svelte)では都度Auth.currentAuthenticatedUser()を呼び、認証情報を取得します。ユーザーがどのユーザーグループにも属していない場合以下のような画面に飛び、Cognito User Groupへの加入を促します。加入依頼を受けたらAWSのコンソールからCognitoの画面に行き、先に示したSAMテンプレートで作成済みのCognitoユーザーグループに依頼者を追加します。 その後依頼者が再ログインすると、ユーザーグループに紐づけてあるIAMの一時クレデンシャルをSTSが発行してくれるようになるので定義済みの認可ルールが適用されるようになります。(上のSAMテンプレートでは、作成したAPIの全エンドポイントを両方のユーザーグループのロールでinvokeできるようにしていますが、/member_only/のようなエンドポイントを作ってPolicy documentの中でそれを指定することで叩けるエンドポイントを制限できたりします) kizuki_console_onboard_page ログインが完了し、ユーザーグループへの追加も完了すると晴れて以下のようなメインページに遷移することができます。 kizuki_console_main_page

終わりに

本記事では意外とネット上に情報の少ない(?)Cognito User Groupベースの認証認可について、フロントや運用周りも含めて頑張って解説を試みましたがいかがでしたでしょうか。 記事の詳細度の塩梅が幼児には難しかったです(アーニャ声でごまかす)。 どなたかの参考になれば嬉しいです。以上です!