LoginSignup
0
3

More than 1 year has passed since last update.

[amplify]Cognito認証付きReactWebページのCICD

Last updated at Posted at 2021-12-19

Cognitoを使った認証付きのWebページをreactで作る場合、amplify使うと認証まわりをいい感じにしてくれるのでとても便利だが、Cognitoだけ他のAWSリソースと切り離してamplifyで管理としたくないのでCDKで構成し、importさせる。
その上でCICDパイプラインにのせた以下の構成を考える。

image.png

0. 環境構築

必要なリソースはCDKで構築。
以下参照。CodeCommitは誤って消えたりすると困るので別で手動作成。

Cognito-Stack CDKコード
cognito-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';

export class CognitoStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const userPoolName = "hogehogeuserpool";
    const userPoolAppClientName = "hogehogeappclient";

    // == Cognito ==
    const userpool = new cognito.UserPool(this, userPoolName, {
      userPoolName: userPoolName,
      standardAttributes: {
        email: {required: true, mutable: true},
      },
      selfSignUpEnabled: true,
      signInCaseSensitive: false,
      autoVerify: {email: true},
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    userpool.addClient(userPoolAppClientName, {
      oAuth: {
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],
        flows: {
          authorizationCodeGrant: true,
        },
      },
      authFlows: {
        adminUserPassword: true, // ALLOW_ADMIN_USER_PASSWORD_AUTH
        custom: true, // ALLOW_CUSTOM_AUTH
        userPassword: false, // ALLOW_USER_PASSWORD_AUTH
        userSrp: true, // ALLOW_USER_SRP_AUTH
      }
    });
  }
}

Hosting-Stack CDKコード
hosting-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins';

export class HostingStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const hostingBucketName = "hogehogehostingbucket";
    const oaiName = "hogehogeoai";
    const cloudfrontDistributionName = "hogehogeDistribution";

    // == S3 ==
    // Hosting Bucket
    const hostingBucket = new s3.Bucket(this, hostingBucketName, {
      bucketName: hostingBucketName,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false
      }),
      versioned: false,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Bucket Contents Upload
    new s3deploy.BucketDeployment(this, "deployToS3", {
      destinationBucket: hostingBucket,
      sources: [s3deploy.Source.asset(`src/testpage/index.html`)], // 任意のhtmlをテスト用に用意すること!!
    })

    // == CloudFront ==
    const oai = new cloudfront.OriginAccessIdentity(this, oaiName, {
      comment: `s3-bucket-${hostingBucketName}`
    });

    const cloudfrontDistribution = new cloudfront.Distribution(this, cloudfrontDistributionName, {
      enabled: true,
      defaultRootObject: "index.html",
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        origin: new cloudfront_origins.S3Origin(
          hostingBucket, {
            originAccessIdentity: oai
          }
        )
      }
    });

    hostingBucket.addToResourcePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["s3:GetObject"],
      principals: [
        new iam.CanonicalUserPrincipal(oai.cloudFrontOriginAccessIdentityS3CanonicalUserId)
      ],
      resources: [
        `${hostingBucket.bucketArn}/*`
      ]
    }));
  }
}

Pipeline-Stack CDKコード
pipeline-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as codecommit from 'aws-cdk-lib/aws-codecommit';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codepipeline_actions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as iam from 'aws-cdk-lib/aws-iam';

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const codecommitRepName = "hogehoge";
    const hostingBucketName = "hogehogehostingbucket";
    const snsTopicName = "hogehogeTopicForApproval";
    const approvalEmailAddress = "************@gmail.com";
    const buildProjectName = "hogehogeBuildProj";
    const buildProjectRoleName = "hogehogeBuildProjRole";
    const pipelineName = "hogehogePipeline";

    // == S3 ==
    const dist_bucket = s3.Bucket.fromBucketAttributes(this, hostingBucketName, {
      bucketName: hostingBucketName,
    });

    // == Artifact ==
    const sourceOutput = new codepipeline.Artifact('sourceOutput');
    const buildOutput = new codepipeline.Artifact("buildOutput");

    // == SNS ==
    const snsTopic = new sns.Topic(this, snsTopicName, {
      topicName: snsTopicName,
    })

    snsTopic.addSubscription(new subscriptions.EmailSubscription(approvalEmailAddress))

    // == CodeCommit ==
    const repository = codecommit.Repository.fromRepositoryName(this, codecommitRepName, codecommitRepName);

    // == CodeBuild ==
    const buildProjectRole = new iam.Role(this, buildProjectRoleName, {
      roleName: buildProjectRoleName,
      assumedBy: new iam.ServicePrincipal("codebuild.amazonaws.com"),
    });
    buildProjectRole.addToPolicy(
      new iam.PolicyStatement({
        resources: ["*"],
        actions: [
          "ssm:GetParameters",
        ],
      })
    );
    const buildProject = new codebuild.PipelineProject(this, buildProjectName, {
        projectName: buildProjectName,
        environment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        },
        buildSpec: codebuild.BuildSpec.fromSourceFilename("buildspec.yml"),
        role: buildProjectRole,
        environmentVariables: {
          ENV: { value: "dev", type: codebuild.BuildEnvironmentVariableType.PLAINTEXT},
        },
    });

    // == CodePipeline ==
    const pipeline = new codepipeline.Pipeline(this, pipelineName, {
        pipelineName: pipelineName,
    });

    // == CodePipeline Actions == 
    const sourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: "Suorce",
      repository: repository,
      branch: "master",
      trigger: codepipeline_actions.CodeCommitTrigger.EVENTS,
      output: sourceOutput,
    });
    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: "Build",
      project: buildProject,
      input: sourceOutput,
      outputs: [buildOutput],
    });
    const approvalAction = new codepipeline_actions.ManualApprovalAction({
      actionName: "Approval",
      notificationTopic: snsTopic,
    })
    const deployAction = new codepipeline_actions.S3DeployAction({
      actionName: "Deploy",
      bucket: dist_bucket,
      input: buildOutput,
      extract: true,
    })

    pipeline.addStage({
      stageName: "Source",
      actions: [sourceAction]
    });
    pipeline.addStage({
      stageName: "Build",
      actions: [buildAction]
    });
    pipeline.addStage({
      stageName: "Approval",
      actions: [approvalAction]
    })
    pipeline.addStage({
      stageName: "Deploy",
      actions: [deployAction]
    });
  }
}

main.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { CognitoStack } from 'cognito-stack';
import { HostingStack } from 'hosting-stack';
import { PipelineStack } from 'pipeline-stack';

const app = new cdk.App();
new HostingStack(app, 'HostingStack');
new CognitoStack(app, 'CognitoStack');
new PipelineStack(app, 'PipelineStack');
$ npx cdk deploy --all

CloudFrontコンソールからURLを確認しアクセスしてtest用ページが見れることを確認。

1. reactアプリ作成

CDKのディレクトリとは別に任意のディレクトリを作成し、以下reactアプリの雛形を作成。(これが環境構築で作成したCodeCommitで管理する対象。)

$ npx create-react-app react-frontui-cognito --template typescript 

2. amplify用のユーザを作成

途中マネジメントコンソールに飛ぶ。
実行前にAWSコンソールにログインしていたケースでしたやったことがない(未ログインだとログイン求められるのでは?)
デフォルトでAdmin権限が付与されるが、大きいのでAdministratorAccess-Amplifyを選び直して作成。

$ amplify configure
Follow these steps to set up access to your AWS account:

Sign in to your AWS administrator account:
https://console.aws.amazon.com/
Press Enter to continue
Specify the AWS Region
? region:  ap-northeast-1
Specify the username of the new IAM user:
? user name:  ****
Complete the user creation using the AWS console
https://console.aws.amazon.com/iam/home?region=ap-northeast-1#/users$new?step=final&accessKey&userNames=****&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess
Press Enter to continue

Enter the access key of the newly created user:
? accessKeyId:  ****
? secretAccessKey:  ****
This would update/create the AWS Profile in your local machine
? Profile Name:  ****

Successfully set up the new user.

3. amplifyの設定・初期化

$ cd react-frontui-cognito

$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project reactfrontuicognito
The following configuration will be applied:

Project information
| Name: ****
| Environment: dev
| Default editor: Visual Studio Code
| App type: javascript
| Javascript framework: react
| Source Directory Path: src
| Distribution Directory Path: build
| Build Command: npm run-script build
| Start Command: npm run-script start

? Initialize the project with the above configuration? Yes
Using default provider  awscloudformation
? Select the authentication method you want to use: AWS profile

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

? Please choose the profile you want to use ****

4. 既存Cognitoの取り込み

$ amplify import auth
Using service: Cognito, provided by: awscloudformation
✔ What type of auth resource do you want to import? · Cognito User Pool only
✔ Select the User Pool you want to import: · ap-northeast-1_*********

5. 設定の反映・aws-exports.jsの作成

$ amplify push

6. packageの追加

$ npm install aws-amplify @aws-amplify/ui-react

7. コード編集

src/App.tsxをまるごと編集。

import { Amplify } from 'aws-amplify';
import { Authenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

import awsExports from './aws-exports';
Amplify.configure(awsExports);

export default function App() {
  return (
    <Authenticator variation="modal">
      {({ signOut, user }) => (
        <main>
          <h1>Hello {user.username}</h1>
          <button onClick={signOut}>Sign out</button>
        </main>
      )}
    </Authenticator>
  );
}

参考
https://ui.docs.amplify.aws/components/authenticator

8. ローカルで画面の確認

$ npm start

9. テストユーザの作成

9-1) 画面から作成する。

→ "Create Account"をクリックして作成。

9-2) CLIから作成する。 (主に"Create Account"を用意しない環境での確認用)

$ aws cognito-idp admin-create-user --user-pool-id <USER_POOL_ID> --username <USER_NAME> --user-attributes Name=email,Value=<E_MAIL_ADDRESS> Name=email_verified,Value=true --temporary-password <PASSWORD>

# https://dev.classmethod.jp/articles/cognito-admin-set-user-password/
$ aws cognito-idp admin-set-user-password --user-pool-id <USER_POOL_ID> --username <USER_NAME> --password <PASSWORD> --permanent 

# get IdToken (確認用)
$ aws cognito-idp admin-initiate-auth --user-pool-id <USER_POOL_ID> --client-id <CLIENT_ID> --auth-flow "ADMIN_USER_PASSWORD_AUTH" --auth-parameters USERNAME=<USERNAME>,PASSWORD=<PASSWORD>

参考
https://dev.classmethod.jp/articles/obtain-access-tokens-for-cognito-users-using-aws-cli/

10. build環境の設定ファイル作成

10-1) buildspec.ymlを作成。

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 14
  pre_build:
    commands:
      - npm update -g npm
      - npm install -g @aws-amplify/cli
      - npm install --production
      - chmod u+x ./amplify_init.sh
      - ./amplify_init.sh

  build:
    commands:
      - npm run build
artifacts:
  files:
    - '**/*'
  base-directory: 'build'

10-2) amplify_init.shを作成。

AUTHCONFIGにCognitoの情報を書き込む。(tesm-provider-info.jsonを参照するといい。)

以下を参考。amplifyをCodeBuildで使うにはscriptを書く必要があるみたい。わりと多くの人がそうしている。これらをもとにcognito部分を追加させてもらう。

#!/bin/bash
set -eu

echo "[amplify_init.sh] start"
# SSMからクレデンシャル取得
access_key_id=$(aws ssm get-parameters --names '/AmplifyCICD/AccessKeyID' --with-decryption --query Parameters[].Value --output text)
secret_access_key=$(aws ssm get-parameters --names '/AmplifyCICD/SecretAccessKey' --with-decryption --query Parameters[].Value --output text)

# 環境変数取得
env=$1

# クレデンシャルの設定
aws configure set aws_access_key_id $access_key_id
aws configure set aws_secret_access_key $secret_access_key
aws configure set default.region ap-northeast-1

# Amplifyの設定
AWSCLOUDFORMATIONCONFIG="{\
\"configLevel\":\"project\",\
\"useProfile\":true,\
\"profileName\":\"default\"\
}"
AMPLIFY="{\
\"projectName\":\"sample_app\",\
\"envName\":\"$env\",\
\"defaultEditor\":\"code\"\
}"
PROVIDERS="{\
\"awscloudformation\":$AWSCLOUDFORMATIONCONFIG\
}"
AUTHCONFIG="{\
\"userPoolId\":\"ap-northeast-1_*********\",\
\"webClientId\":\"**************************\",\
\"nativeClientId\":\"**************************\"\
}"
CATEGORIES="{\
\"auth\":$AUTHCONFIG\
}"                      

amplify init \
--amplify $AMPLIFY \
--providers $PROVIDERS \
--categories $CATEGORIES \
--yes

echo "[amplify_init.sh] finish"

CodeBuildの環境変数から直接SSMパラメータストアを指定することも可能だが、SecureStringを復号化してくれるわけではなさそうなので(要出典)、CLIで取得した。
なお、CodeBuildのbuildspecからSSMとかに取得に行く場合の注意点とかをまとめてくれてるページがあった。
https://dk521123.hatenablog.com/entry/2020/02/18/230358

11. amplify用のユーザのクレデンシャルをcodebuildから利用できるようにSSMに登録。

$ aws ssm put-parameter --name "/AmplifyCICD/AccessKeyID" --value "****" --type SecureString
$ aws ssm put-parameter --name "/AmplifyCICD/SecretAccessKey" --value "****" --type SecureString

12. パイプラインの実行。

$ git remote add origin https://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/******
$ git commit --allow-empty -m "init empty commit"
$ git add .  
$ git commit -m "add basic project code"
$ git push --set-upstream origin master

※ 前述のようにtesm-provider-info.jsonにあまり外に出したくないID類が載ってるのでPublicなリポジトリにpushする場合は注意。
https://www.bioerrorlog.work/entry/public-amplify-project

13. 別環境への展開/移植

dev環境で構築したのちに同じ構成をstaging/production環境に構築する際、同じようにパイプラインを走らせてもCodeBuildでエラー終了する。
以下2つの問題を解消する必要がある。

1つ目の問題

Could not initialize 'dev': Access Denied

ここの"dev"はamplify init時にEnvironmentに対応するところで設定した環境名であり、team-provider-info.jsonに記述されている詳細情報との紐づけに利用されている。
ここに記載のリソースにアクセスできないとのことだが、それもそうで、開発環境で生成したteam-provider-info.jsonには開発環境のAWSアカウントのARNやらリソースIDが記載されているので他環境からはアクセスできない。
→ 他環境ではその環境に対応するteam-provider-info.jsonを用意しておいてあげる必要がある。

適切な値で記述するのもいいが、CFnで生成しないといけないリソースなどもあり正直面倒なので一度amplify initを実行してteam-provider-info.jsonを出力してもらい、コピペする方が楽かもしれない。ここではその方法をとる。

team-provider-info.jsonを削除し、その上でbuildspec.ymlteam-provider-info.jsonをコンソールに出力するようにコードを差し込む。

buildspec.yml
   (略)
  pre_build:
    commands:
      - npm update -g npm
      - npm install -g @aws-amplify/cli
      - npm install --production
      - chmod u+x ./amplify_init.sh
      - ./amplify_init.sh
      - cat ./amplify/team-provider-info.json  # <<<追加
   (略)

git pushする。
こうすると、CodeBuild上でCFnが実行され、team-provider-info.jsonを生成し出力してくれる。
出力されたteam-provider-info.jsonamplify/以下に配置する。

このファイルがある状態だとamplify initしても新しくCFnが実行されることはないので大丈夫。

2つ目の問題

上記のまま実行してもaws-exports.jsに必要な項目が全然含まれていない(aws-project_regionしかない)。
→ 一度amplify pushする必要がある。

amplify_init.sh
amplify init \
--amplify $AMPLIFY \
--providers $PROVIDERS \
--categories $CATEGORIES \
--yes

amplify push --yes  # <<<<追加

echo "[amplify_init.sh] finish"

この状態で再度git pushすると以降aws-exports.jsが正しく生成されるようになる。

以上の編集後、これらに追加した項目を削除し、再度pushしてビルドが通ることを確認する。

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3