新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

AWS Amplify + React で必要最小限の認証サイトを作る方法メモ

認証機能付きReactサイトを手軽に作りたかったのですが、都度バックエンドを構築するのが面倒で諦めてました。

しかし、AWS Amplifyを使えば、手軽にバックエンドを構築できるのではと試してみました。

一通り欲しかった機能は試したので、再利用可能なように、ざっくり作り方をメモしておこうと思います。

公開サイトじゃないので、凝ったものではなく、必要最小限の下記機能を盛り込んだものになります。

  • ホスティング
    • Basic認証
  • 認証
    • 認証者によるDynamoDBアクセス
    • 認証者によるLambdaアクセス

ドキュメント

ドキュメントが古いこともあるので、コマンドのヘルプも参照する。

amplify --help
amplify [command] --help

Amplify CLIインストール

Amplify CLIを使って作業するので、まずインストールと初期設定をする。

  • AWS IAMでAmplify用のユーザーを作成する
    • ユーザーは管理者権限を付与する必要がある
  • AWS CLIのプロファイルにAmplify用ユーザーを追加する
    • AWS CLIのプロファイル設定は別記事参照
  • Amplify CLIインストール
npm install -g @aws-amplify/cli
  • Amplify CLI初期設定
amplify configure

プロジェクト作成

  • Reactアプリ作成
npx create-react-app my-app --template typescript
  • Amplifyプロジェクト作成
    • Reactディレクトリ内でコマンドを実行
    • amplifyディレクトリが作られ、Amplify関連のファイルは全てこのディレクトリ配下に作成される
amplify init
  • 作成済みプロジェクトの設定変更
amplify configure project
  • プロジェクト削除
    • 依存関係で削除に失敗する時がある。その場合、最終手段としてAmplify Consoleから削除する
amplify delete

補足説明

  • プロジェクトには必ず1つアプリが必要
  • 1つのプロジェクトに複数のReactサイトを作ることはできない(1プロジェクトにつきホスティングは1つのみのため)
  • アプリのルートディレクトリと、プロジェクトのルートディレクトリは同じでなければいけない

ホスティング

  • ホスティング追加
    • Amplify専用のホスティングと、CloudFrontとS3を使ったホスティングを選択できるが、Amplify専用のホスティングを選択する
    • Amplify専用のホスティングの詳細は隠蔽されていて、ユーザーがいじることはできない
amplify hosting add
  • 追加したバックエンド(ホスティング)を、実際のAWSサービスにデプロイ
amplify push
  • Reactのbuildディレクトリ以下のサイトコンテンツをホスティングにアップロード
    • 裏でamplify pushもやってくれる
amplify publish
  • ホスティング先のURLが表示されるので、そのURLからサイトにアクセスできるようになる。

  • ホスティングにBasic認証をかける
    • Amplify Console
      • [アプリの設定]-[アクセスコントロール]-[アクセスの管理]

Amplifyのコマンド説明

backend

  • amplify [category] add
    • 各種バックエンド設定をローカルに追加
  • amplify status
    • ローカルのバックエンド設定の状態を表示
  • amplify push
    • ローカルに追加したバックエンド設定を元に、実際のAWSサービスにバックエンドをデプロイする。
    • ローカルのバックエンド設定内容は、S3にデータとしてpushされる
    • 常にS3のデータと、現在実際にAWSにデプロイされているバックエンドの状態が一致するようになっている
  • amplify [category] remove
    • ローカルに追加した各種バックエンド設定を削除する
    • ローカルで削除したバックエンド設定を、実際のAWSサービスにデプロイしたバックエンドからも削除するにはamplify pushする
  • amplify pull
    • ローカルのバックエンド設定を、実際のAWSに構築されたバックエンドの状態で上書き

env

dev・prodなど、バックエンドを環境に分けて構築することができる。

  • amplify env add
    • 新しい環境を追加
  • amplify env checkout [env_name]
    • 環境切り替え
  • amplify env list
    • 環境一覧
  • amplify env remove [env_name]
    • 環境削除

認証

  • 認証追加
    • amplify auth importで、Cognitoを新規作成せず、既存のCognitoを使うこともできる。既存のCognitoを使う場合でも、環境によってCognitoを変えることができる
amplify auth add
  • Reactコード
    • ログインユーザーしかサイトアクセスできないようにする
    • <AmplifyAuthenticator>だけで使用すると、「Loading...」が出て止まることがあるので、ログイン状態を見て表示出し分けする
import React, { useEffect, useState } from 'react';
import Amplify, { Auth } from 'aws-amplify';
import { AmplifyAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react";
import awsconfig from './aws-exports';
Amplify.configure(awsconfig);

function App() {
    const [username, setUsername] = useState<any>();

    useEffect(() => {
        Auth.currentAuthenticatedUser().then(user => {
            setUsername(user.username);
        }).catch(err => { });
    }, []);

    if (!username) {
        return (<AmplifyAuthenticator />);
    }

    return (
        <div>
            <div>{username}</div>
            <AmplifySignOut />
        </div>
    );
}

export default App;

GraphQL

AmplifyはREST APIも使えるが、GraphQLからLambda Functionも呼び出せるので、サーバーへのアクセスはGraphQLに集約する。

GraphQLをハブに「DynamoDB」「Lambda」「S3」等バックエンドにアクセスできる。

  • GraphQL追加
    • 認証はCognitoを使用する。すると、GraphQLへのアクセスはログインユーザーしか行えなくできる
amplify api add
  • スキーマ編集
    • amplify/backend/api/[api_name]/schema.graphqlを編集する
  • スキーマ反映
amplify api gql-compile

補足説明

  • ここでいうGraphQLとはAppSyncのこと
  • AppSyncとは、GraphQLのクエリを受け取って、DynamoDB、Lambda、S3など、各種AWSサービスを呼び出すゲートウェイサービス
  • amplify/backend/api/[api_name]/schema.graphqlはテンプレートとなるスキーマ
  • amplify api gql-compileコマンドで、テンプレートから実際のGraphQLスキーマを生成する
  • amplify/backend/api/[api_name]/build/schema.graphqlがテンプレートから生成された実際のGraphQLスキーマ
    • GraphQLのクエリを書く時は、このスキーマを見ながら書くことになる

DynamoDB

GraphQLのスキーマを書くだけで、GraphQLのクエリで、DynamoDBのテーブルのデータの読み書きができるようになる。(便利!)

  • スキーマ編集
    • amplify/backend/api/[api_name]/schema.graphqlを編集

スキーマ書き方(基本)

シンプル

type Todo @model
{
  id: ID!
  name: String!
  description: String
}
  • @modelでDynamoDBにテーブルが作成される
  • プライマリーキーとしてidカラムは必須。省略しても自動で付与される
    • プライマリーソートキーはなし
  • 作成日時createdAtと更新日時updatedAtカラムは自動付与される
  • 誰でも作成・編集・削除ができる

サブスクリプションなし

type Todo @model(subscriptions: null)
{
  id: ID!
  name: String!
  description: String
}
  • サブスクリプションのスキーマを生成しない

スキーマ書き方(所有者)

所有者しかRead・Edit・Deleteできなくする

type Todo @model
    @auth(rules: [{ allow: owner }])
{
  id: ID!
  name: String!
  description: String
}
  • @authをつけると、所有者情報(owner)カラムが自動付与され、アクセス制御ができるようになる
  • allowで対象者を指定し、operationsでその対象者しかできない行為を指定する
  • operationsで指定されなかった行為は他の人もできるようになる
  • operationsを指定しないと、全ての行為が対象となる。つまり、allowで指定した人しかできなくなる

所有者情報を持たせるが、全員がRead・Edit・Deleteできるようにする

type Todo @model
    @auth(rules: [{ allow: owner, operations: [create] }])
{
  id: ID!
  name: String!
  description: String
}
  • 所有者(owner)に制限されているのは作成(create)のみ。つまり、他のユーザーもRead・Edit・Deleteできる

所有者とAdminグループしかRead・Edit・Deleteできなくする

type Todo @model
    @auth(rules: [{ allow: owner }])
    @auth(rules: [{ allow: groups, groups: ["Admin"]}])
{
  id: ID!
  name: String!
  description: String
}
  • @authは複数書くことができる
  • groupsでグループの権限を設定できる

スキーマ書き方(インデックス)

@keyでDynamoDBのインデックスを作成できる。

  • 指定したカラムでの値取得ができるようになる
  • 指定したカラムでのソートができるようなる
type Todo @model
    @auth(rules: [{allow: owner}])
    @key(name: "byOwner", fields: ["owner", "createdAt"], queryField: "listTodoByOwner")
{
  id: ID!
  name: String!
  description: String
  owner: String
  createdAt: AWSDateTime!
}
  • nameはインデックス名
  • fields配列は、1つ目がパーティションキー、2つ目がソートキー。ソートキーは省略可能
  • queryFieldはqueryする時の関数名。省略するとqueryは作成されない

GraphQL(クライアント)

  • API.graphql({query, variables})で呼び出す
  • クエリを直接記述しても構わない

型定義

apiをpushした後、amplify codegenすると

  • ./APIにTypeScriptのスキーマ型定義が生成される
  • ./graphql/[queries|mutations|subscriptions]にサンプルクエリが生成される
  • GraphQLのスキーマを編集した時はamplify codegenで型定義でクエリを更新するようにする
    • ただし、スキーマ情報は実際のAppSyncから取得するので、amplify pushした後に行うこと

使用例

import { API } from 'aws-amplify';
import { getTodo } from './graphql/queries';

const result: any = await API.graphql({
    query: getTodo,
    variables: { id: '123' }
});
console.log(result.data.getTodo);

使用例(クエリを直接記述)

const rawQuery = /* GraphQL */ `
  query ($id: ID!) {
    getTodo(id: $id) {
      id
      name
    }
  }
`;

const result: any = await API.graphql({
    query: rawQuery,
    variables: { id: '123' }
});
console.log(result.data.getTodo);

使用例(ToDo アプリ)

schema.graphql

type Todo @model
    @auth(rules: [{ allow: owner }])
    @key(name: "byOwner", fields: ["owner", "createdAt"], queryField: "listTodosByOwner")
{
  id: ID!
  name: String!
  owner: String!
  createdAt: AWSDateTime!
}

App.tsx

import React, { useEffect, useState } from 'react';
import { Box, Button, Input, Flex, Spacer } from "@chakra-ui/react"
import { atom, CallbackInterface, useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';

import Amplify, { API, Auth } from 'aws-amplify';
import { AmplifyAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react";
import awsconfig from './aws-exports';

import { listTodosByOwner } from './graphql/queries';
import { createTodo, deleteTodo } from './graphql/mutations';
import { Todo, ListTodosByOwnerQuery } from './API';
import { GraphQLResult } from '@aws-amplify/api-graphql';

Amplify.configure(awsconfig);

const usernameAtom = atom<string>({
    key: `usernameAtom`,
    default: ''
});

const todoListAtom = atom<Array<Todo>>({
    key: `todoListAtom`,
    default: []
});

const loadTodoListAtom: (a: CallbackInterface) => () => void = ({ snapshot, set }) => async () => {
    const username = await snapshot.getPromise(usernameAtom);
    if (username) {
        const result = (await API.graphql({
            query: listTodosByOwner,
            variables: { owner: username, sortDirection: "DESC" }
        }) as GraphQLResult<ListTodosByOwnerQuery>);

        const items = result.data?.listTodosByOwner?.items;
        if (items) {
            set(todoListAtom, (items as Array<Todo>));
        }
    }
};

function App() {
    const [username, setUsername] = useRecoilState(usernameAtom);
    const todoList = useRecoilValue(todoListAtom);
    const loadTodoList = useRecoilCallback(loadTodoListAtom);
    const [inputText, setInputText] = useState<string>('');

    useEffect(() => {
        Auth.currentAuthenticatedUser().then((user) => {
            if (username !== user.username) {
                setUsername(user.username);
            }
            loadTodoList();
        }).catch(err => { });
    }, [username]);

    if (!username) {
        return (<AmplifyAuthenticator />);
    }

    const todoListDom = todoList.map((v) => {
        return (
            <Flex key={v.id} my="2" border="1px" borderColor="gray.300" p="2" borderRadius="md">
                <Box>
                    <Box fontWeight="bold">{v.name}</Box>
                    <Box>{v.createdAt}</Box>
                </Box>
                <Spacer />
                <Button onClick={async () => {
                    await API.graphql({
                        query: deleteTodo,
                        variables: { input: { id: v.id } }
                    });
                    await loadTodoList();
                }}>delete</Button>
            </Flex>
        );
    });

    return (
        <Box p="4">
            <Flex my="2">
                <Input mr="2" type="text" value={inputText} onChange={(e) => {
                    setInputText(e.target.value)
                }} />
                <Button onClick={async () => {
                    if (inputText.length === 0) {
                        return;
                    }
                    await API.graphql({
                        query: createTodo,
                        variables: { input: {
                            owner: username,
                            name: inputText
                        } }
                    });
                    setInputText('');
                    await loadTodoList();
                }}>add</Button>
            </Flex>
            <Box>
                {todoListDom}
            </Box>
            <AmplifySignOut />
        </Box>
    );
}

export default App;

Lambda Function

Lambda Functionを追加し、GraphQLから呼び出せるようにする。

amplify function add
  • GraphQLスキーマ編集
    • amplify/backend/api/[api_name]/schema.graphqlを編集する
    • @functionで指定したLambdaを呼び出せる
type Post{
    id: ID!
    msg: String!
    comment: [Comment!] @function(name: "[function_name]-${env}")
}

type Comment{
    postID: ID!
    msg: String!
}

type Query {
    getPost(id: ID!): Post @function(name: "[function_name]-${env}")
}
  • ローカル実行(mock)
    • --eventでイベントデータを指定する
    • 環境変数を追加する時は[env_name]=[env_value] ...を前に付ける
      • ローカル実行時に、リージョンでエラーが出る時はAWS_REGION環境変数を追加する
amplify mock function [function_name] --event=[event_json_file_path]
AWS_REGION=ap-northeast-1 \
amplify mock function [function_name] --event [event_json_file_path]

Lambda Function 実装

下記ドキュメントから抜粋

eventデータ

  • クエリ内容はeventに格納される
  • クエリのどこでFunctionが呼び出されたかは、eventから判別する
    • ドキュメントのサンプルのように、リゾルバを作成して判別使用することが多い

(例)getPost()からの呼び出し時

{
    typeName: "Query",
    fieldName: "getPost",
    arguments: {
        id: "123"
    },
    identity: {
        username: "...." /* cognito username */
    }
}

(例)Post.commentからの呼び出し時

{
    typeName: "Post",
    fieldName: "comment",
    arguments: { },
    source: {
        id: "123",
        msg: "..."
    }
    identity: {
        username: "...." /* cognito username */
    }
}

実装例

const posts = [
    { id: 1, msg: "POST_01" },
    { id: 2, msg: "POST_02" },
    { id: 3, msg: "POST_03" }
];

const comments = [
    { postID: 1, msg: "COMMENT_01_A" },
    { postID: 1, msg: "COMMENT_01_B" },
    { postID: 3, msg: "COMMENT_03_C" }
];

const resolvers = {
    Query: {
        getPost: queryGetPost
    },

    Post: {
        comment: postComment
    }
}

function queryGetPost(event) {
    console.log(event);
    return posts.find(v => v.id === Number.parseInt(event.arguments.id));
}

function postComment(event) {
    console.log(event);
    return comments.filter(v => v.postID === event.source.id);
}

exports.handler = async (event) => {
    const typeHandler = resolvers[event.typeName];
    if (typeHandler) {
        const resolver = typeHandler[event.fieldName];
        if (resolver) {
            return await resolver(event);
        }
    }
    throw new Error("Resolver not found.");
};

Lambda Function内からCognitoユーザー情報を取得する

  • FunctionにCognitoへのアクセス権限を付与する
    • Functionの環境変数AUTH_[cognito_name]_USERPOOLIDに、cognitoのuserpoolidが格納されるので、それを使ってcognitoにアクセスする
amplify function update
  • 実装
import { CognitoIdentityServiceProvider } from 'aws-sdk';
const cognito = new CognitoIdentityServiceProvider();
const USER_POOL_ID = process.env.AUTH_[cognito_name]_USERPOOLID;

exports.handler = async (event) => {

    const cognitoUser = await cognito.adminGetUser({
        UserPoolId: USER_POOL_ID,
        Username: event.identity.username
    }).promise();

    return JSON.stringify(cognitoUser);
};

Lambda Function内からGraphQLを呼び出す

GraphQL(AppSync)のデフォルト認証をCognitoにして、API Keyによる認証を追加し、Lambda FunctionからはAPI Keyを使ってGraphQLを呼び出す。

  • GraphQLに認証方法を追加
    • API Key認証を追加
amplify api update
  • Lambda FunctionにGraphQLへのアクセス権付与
    • GraphQLのURLと、API Keyの環境変数が作成され、Lambda Functionに渡される
      • API_[api_name]_GRAPHQLAPIENDPOINTOUTPUT
      • API_[api_name]_GRAPHQLAPIKEYOUTPUT
    • 環境変数は必ずamplify pushしてAppSyncに設定されたものと一致させてから使用すること
amplify function update
  • GraphQLスキーマ編集
    • amplify/backend/api/[api_name]/schema.graphqlを編集
    • デフォルトの認証がCognitoになっているので、そのままではAPI Key認証でアクセスできない
    • スキーマに@aws_api_keyを追加して、API Key認証でもアクセスできるようにする
type Todo @model
    @auth(rules: [{allow: owner}])
    @aws_api_key
{
  id: ID!
  name: String!
  owner: String!
}

type Mutation @aws_api_key{
    sample: String @function(name: "[function_name]-${env}")
}
  • 実装
    • API Keyをヘッダーにセットする
    • 呼び出し方法は通常のGraphQLと同じ
import axios from 'axios';
const GRAPHQL_EP = process.env.API_AMPLIFYTEST_GRAPHQLAPIENDPOINTOUTPUT;
const GRAPHQL_KEY = process.env.API_AMPLIFYTEST_GRAPHQLAPIKEYOUTPUT;

const createTodo = /* GraphQL */ `
  mutation CreateTodo(
    $input: CreateTodoInput!
    $condition: ModelTodoConditionInput
  ) {
    createTodo(input: $input, condition: $condition) {
      id
      name
      owner
      createdAt
      updatedAt
    }
  }
`;

exports.handler = async (event) => {

    const variables = {
        input: {
            name: "TEST",
            owner: "owner_test"
        }
    };

    const graphqlData = await axios({
        url: GRAPHQL_EP,
        method: 'post',
        headers: {
            'x-api-key': GRAPHQL_KEY
        },
        data: {
            query: createTodo,
            variables
        }
    });

    return JSON.stringify(graphqlData.data);
};

Lambda Functionに任意の環境変数を渡す

感想など

あまり時間をかけずに、使いたい機能だけかいつまんで調べて利用しようとしたのですが、各々の機能が絡んでいて結構手こずりました。

AmplifyやCloudFormationでエラーがでると、自力で解決するしかなく、しかも実際のインフラに起因するエラーなので、ある程度バックエンドの知識がないとお手上げになってしまいますね。

しかし、Amplifyが作るバックエンドはプロダクション利用でも問題ないので安心です。同程度のバックエンドを作るにはかなりのスキルが要求されることを考えると、Amplifyを使うメリットは十分にあるかと思いました。

関連カテゴリー記事

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com