AWS Amplifyでサーバーレスなログイン機能をスマートに実装

# はじめに

[英文の記事もあります！](https://medium.com/@wataruoguchi/building-aws-amplify-customized-authentication-forms-for-serverless-vue-js-apps-7da016d992d8)

AWS Amplify、使ったことありますか？フロントエンドの人でも楽しく使えるAWS、それがAmplifyです。AWS自体、最近勉強し始めたのですが、難しいことせずに素早くAWSサービス群が使えちゃうのでこれからもっと使っていきたいです。

本記事はサインアップ／サインインの認証機能を一から作っていくチュートリアルです。[GitHubはこちらから](https://github.com/wataruoguchi/aws-amplify-auth-vuejs-example/tree/cognito)。

# 目次

- AWS Amplifyの私の理解度
- サインアップなどのフォームとVue Routerの実装
- AWS Amplifyを設定
- AWS Amplifyの機能を実装

_NOTE:_ 本記事ではAWS Amplifyチームが提供しているVue.jsのUIコンポーネントについては説明しません。

# AWS Amplifyの私の理解度

AWS Amplifyでググってみたらいいんですが、大体以下のサービスの集まりだと認識するとググりやすいかもしれません。

- Amplify CLI: 開発時にお世話になるCLI。
- Amplify.js: JSコンポーネント。今回はWebの話しかしませんが、Naitive Mobile Appにも同等のものが提供されています。
- Amplify Console: AWS Consoleからアクセスできるクラウドサービス。デプロイ時のCIを提供。まだ触ったことはありません。。

# サインアップなどのフォームとVue Routerの実装

## ここで作るもの：
### フォーム
- サインアップ
- メールアドレス確認
- サインイン

キレイにスタイリングした後のスクリーンショットは[こちらのページ](https://medium.com/@wataruoguchi/building-aws-amplify-customized-authentication-forms-for-serverless-vue-js-apps-7da016d992d8)にアップロードしています。

### ルータ
サインインした時にはサインアップフォーム等を表示させたくないですよね。同時に、サインインしていない時にはサインアウトボタンのあるページを表示させたくないので、ルータを使ってアクセス制御します。

## コーディングタイム！
### インストール
まずは[Vue CLI](https://cli.vuejs.org/)でパッケージをインストールします。ここではVuetifyとVue Routerをインストールします。

```bash
$ # Install Vue CLI
$ npm install @vue/cli -g
$ # Create the project
$ vue create my-app
? Please pick a preset: default (babel, eslint)
$ cd $_
# Install Vuetify
$ vue add vuetify
? Choose a preset: Default (recommended)
# Install Vue Router
$ vue add router
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
```

### サインアップフォーム
サインアップフォームを`src/views/signUp.vue`に作ります。注意したいのは、メールアドレスの項目に`username`を使用しています。これは、AWS Amplifyでは`username`が必須入力項目になっているためです。ここで変数名を`username`にする必要はないのですが便宜的に。

ここでやっていることはVuetifyを使ったことのある方だったら流し読み程度で大丈夫です。フォームを設置し、メールアドレスとパスワードのバリデーションを設定しています。バリデーションを全部パスしたら「Submit」ボタンが押せるようになり、`Console.log`に入力した値が表示されることを確認してください。

```vue
// src/views/SignUp.vue
<template>
  <div class="sign-up">
    <h1>Sign Up</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field
        v-model="password"
        :append-icon="passwordVisible ? 'visibility' : 'visibility_off'"
        :rules="[passwordRules.required, passwordRules.min]"
        :type="passwordVisible ? 'text' : 'password'"
        name="password"
        label="Password"
        hint="At least 8 characters"
        counter
        @click:append="passwordVisible = !passwordVisible"
        required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: "SignUp",
  data() {
    return {
      valid: false,
      username: '',
      password: '',
      passwordVisible: false,
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    passwordRules() {
      return {
        required: value => !!value || 'Required.',
        min: v => v.length >= 8 || 'Min 8 characters',
        emailMatch: () => ('The email and password you entered don\'t match'),
      }
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN UP username: ${this.username}, password: ${this.password}, email: ${this.username}`);
      }
    },
  },
}
</script>
```

### サインアップ確認フォーム
サインアップが完了したら、AWSが対象メールアドレス宛にメールを送ってくれます。そのメールの中には確認コードがあるので、メールアドレスと確認コードを入力することで登録されたメールアドレスが正しいかを確認します。

サインアップフォームとやっていることは同じです。今の段階ではサインアップと同様に`console.log`の出力しか行いません。`src/views/SignUpConfirm.vue`に作ります。

```vue
// src/views/SignUpConfirm.vue
<template>
  <div class="confirm">
    <h1>Confirm</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field v-model="code" :rules="codeRules" label="Code" required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
    <v-btn @click="resend">Resend Code</v-btn>
  </div>
</template>

<script>
export default {
  name: "SignUpConfirm",
  data() {
    return {
      valid: false,
      username: '',
      code: '',
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    codeRules() {
      return [
        v => !!v || 'Code is required',
        v => (v && v.length === 6) || 'Code must be 6 digits'
      ]
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`CONFIRM username: ${this.username}, code: ${this.code}`);
      }
    },
    resend() {
      console.log(`RESEND username: ${this.username}`);
    }
  },
}
</script>
```

### サインインフォーム
通常のサインインフォームです。サインアップフォームととても似ているので説明は特にしません。`src/views/SignIn.vue`に作ります。

```vue
// src/views/SignIn.vue
<template>
  <div class="sign-in">
    <h1>Sign In</h1>
    <v-form v-model="valid" ref="form" lazy-validation>
      <v-text-field v-model="username" :rules="emailRules" label="Email Address" required/>
      <v-text-field
        v-model="password"
        :append-icon="passwordVisible ? 'visibility' : 'visibility_off'"
        :rules="[passwordRules.required, passwordRules.min]"
        :type="passwordVisible ? 'text' : 'password'"
        name="password"
        label="Password"
        hint="At least 8 characters"
        counter
        @click:append="passwordVisible = !passwordVisible"
        required/>
      <v-btn :disabled="!valid" @click="submit">Submit</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: "SignIn",
  data() {
    return {
      valid: false,
      username: '',
      password: '',
      passwordVisible: false,
    }
  },
  computed: {
    emailRules() {
      return [
        v => !!v || 'E-mail is required',
        v => /.+@.+/.test(v) || 'E-mail must be valid'
      ]
    },
    passwordRules() {
      return {
        required: value => !!value || 'Required.',
        min: v => v.length >= 8 || 'Min 8 characters',
        emailMatch: () => ('The email and password you entered don\'t match'),
      }
    },
  },
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN IN username: ${this.username}, password: ${this.password}`);
      }
    },
  },
}
</script>
```

### サインイン後のページ
ここでサインインした後のページを作ってもいいのですが、面倒なので、Vue Routerをインストールした時に生成された`src/views/Home.vue`を使い回しましょう。

### Vue Routerにフォームページを追加していく
Vue Routerをインストールした時、`src/App.vue`がアップデートされたことにお気づきかと思います。

```vue
// src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>
```

これを参考に、フォームページを追加していきます。

```vue
// src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/signUp">Sign Up</router-link> |
      <router-link to="/signUpConfirm">Confirm</router-link> |
      <router-link to="/signIn">Sign In</router-link>
    </div>
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

同様に、Vue Routerをインストールした時に`src/router.js`というファイルが新しく追加されています。フォームページを追加していきましょう。

```js
// src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    {
      path: '/signUp',
      name: 'signUp',
      component: () => import(/* webpackChunkName: "signup" */ './views/SignUp.vue')
    },
    {
      path: '/signUpConfirm',
      name: 'signUpConfirm',
      component: () => import(/* webpackChunkName: "confirm" */ './views/SignUpConfirm.vue')
    },
    {
      path: '/signIn',
      name: 'signIn',
      component: () => import(/* webpackChunkName: "signin" */ './views/SignIn.vue')
    },
  ]
})
```

この段階で、ブラウザ上にナビゲーションアイテムが追加されているのが確認できるはずです。それぞれをクリックして、対応したフォームが表示されているか確認してみましょう。

# AWS Amplifyを設定

フロントエンドの方にとってなんだか敷居の高いAWSをこれから使っていきます。[Get Started](https://aws-amplify.github.io/docs/)のページに従って、AWSのアカウント作成、Amplify CLIのインストールを実行してみてください。インストールが終わったら早速CLIを使っていきます。

```bash
$ amplify configure
```

ざっくり言うとこのコマンドはお使いのコンピューターからAWSにアクセスすることを知らせます。いくつか選択肢があったりしますので、読み進めて任意に設定してください。私が選択した結果は以下の通りです。参考までに。

```bash
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:  us-west-2
Specify the username of the new IAM user:
? user name:  amplify-cognito-vuejs-example
Complete the user creation using the AWS console
https://console.aws.amazon.com/iam/home?region=undefined#/users$new?step=final&accessKey&userNames=amplify-cognito-vuejs-example&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess
Press Enter to continue

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

Successfully set up the new user.
```

次に、同じCLIを使ってプロジェクトを初期化します。

```bash
$ amplify init
```

ここも任意設定です。ご自身の環境に合わせて設定してください。私が選択した結果は以下の通りです。ほぼデフォルトですね。

```bash
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project my-app
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using vue
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run-script build
? Start Command: npm run-script serve
```

そして、認証機能である`auth`を追加します。以下のコマンドだけで追加できちゃいます。

```bash
amplify add auth
```

ここでも選択肢をいくつか選択します。以下、私の選択した結果です。

```bash
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in when using your Cognito User Pool? Email
 Warning: you will not be able to edit these selections.
 What attributes are required for signing up?
Successfully added resource cognitoexample77f073c1 locally
```

何をやっているのかさっぱりかと思いますが、ここではAWS Cognitoという認証機能の設定をしています。AWS Amplifyを通して、Cognitoというサービスを使っているんですね。

最後に、設定ファイルをアップロードします。この設定ファイルは今まで選択してきた結果をもとに勝手に作成されています。語彙が足りませんが、すごいです。

```bash
$ amplify push
```

```bash
Current Environment: dev

| Category | Resource name          | Operation | Provider plugin   |
| -------- | ---------------------- | --------- | ----------------- |
| Auth     | cognitoexampled26e7f7d | Create    | awscloudformation |
? Are you sure you want to continue? Yes
```

# AWS Amplifyの機能を実装

## コーディングタイム！

Amplifyの機能をこれまでに作ったアプリに実装していきます。

### インストール

```bash
$ npm install aws-amplify aws-amplify-vue
```

`aws-amplify-vue`というのをインストールしたのは[`AmplifyEventBus`](https://aws-amplify.github.io/docs/js/vue#amplifyeventbus)というものを使うためだけです。イベントを登録して他のところで実行するだけのためのものなのでReact.jsユーザーの方は代替するものがあるはずです。

### `main.js`
`src/main.js`にて、`amplify`コマンドによって生成された設定ファイル`src/aws-exports.js`をインポートします。

```js
// src/main.js
import Amplify from 'aws-amplify'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)

Vue.use(Auth)
```

### `auth.js`
AWS Amplifyの提供されている機能を実際に使うモジュールを作ってみましょう。Vue.jsのファイル内に直接書いてもいいのですが、個人的に`*.vue`ファイルは描画に関連したこと以外は書きたくないので、こちらのファイルを作成しています。

```js
// src/utils/auth.js
import { Auth } from 'aws-amplify'
import { AmplifyEventBus } from 'aws-amplify-vue'

function getUser() {
  return Auth.currentAuthenticatedUser().then((user) => {
    if (user && user.signInUserSession) {
      return user
    } else {
      return null
    }
  }).catch(err => {
    console.log(err);
    return null;
  });
}

function signUp(username, password) {
  return Auth.signUp({
    username,
    password,
    attributes: {
      email: username,
    },
  })
    .then(data => {
      AmplifyEventBus.$emit('localUser', data.user);
      if (data.userConfirmed === false) {
        AmplifyEventBus.$emit('authState', 'confirmSignUp');
      } else {
        AmplifyEventBus.$emit('authState', 'signIn');
      }
      return data;
    })
    .catch(err => {
      console.log(err);
    });
}

function confirmSignUp(username, code) {
  return Auth.confirmSignUp(username, code).then(data => {
    AmplifyEventBus.$emit('authState', 'signIn')
    return data // 'SUCCESS'
  })
    .catch(err => {
      console.log(err);
      throw err;
    });
}

function resendSignUp(username) {
  return Auth.resendSignUp(username).then(() => {
    return 'SUCCESS';
  }).catch(err => {
    console.log(err);
    return err;
  });
}

async function signIn(username, password) {
  try {
    const user = await Auth.signIn(username, password);
    if (user) {
      AmplifyEventBus.$emit('authState', 'signedIn');
    }
  } catch (err) {
    if (err.code === 'UserNotConfirmedException') {
      // The error happens if the user didn't finish the confirmation step when signing up
      // In this case you need to resend the code and confirm the user
      // About how to resend the code and confirm the user, please check the signUp part
    } else if (err.code === 'PasswordResetRequiredException') {
      // The error happens when the password is reset in the Cognito console
      // In this case you need to call forgotPassword to reset the password
      // Please check the Forgot Password part.
    } else if (err.code === 'NotAuthorizedException') {
      // The error happens when the incorrect password is provided
    } else if (err.code === 'UserNotFoundException') {
      // The error happens when the supplied username/email does not exist in the Cognito user pool
    } else {
      console.log(err);
    }
  }
}

function signOut() {
  return Auth.signOut()
    .then(data => {
      AmplifyEventBus.$emit('authState', 'signedOut');
      return data;
    })
    .catch(err => {
      console.log(err);
      return err;
    });
}

export {getUser, signUp, confirmSignUp, resendSignUp, signIn, signOut};
```

### `auth.js`を使っていきましょう

これまでに作成したページで`auth.js`をimportしていきましょう。

```vue
// src/views/SignUp.vue
<script>
import {signUp} from '@/utils/auth.js' // Adding this line
export default {
  name: "SignUp",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN UP username: ${this.username}, password: ${this.password}, email: ${this.username}`);
        signUp(this.username, this.password); // Adding this line as well
      }
    },
  },
}
</script>
```

```vue
// src/views/SignUpConfirm.vue
<script>
import {confirmSignUp, resendSignUp} from '@/utils/auth.js'  // Adding this line
export default {
  name: "SignUpConfirm",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`CONFIRM username: ${this.username}, code: ${this.code}`);
        confirmSignUp(this.username, this.code);  // Adding this line as well
      }
    },
    resend() {
      console.log(`RESEND username: ${this.username}`);
      resendSignUp(this.username);  // Adding this line as well
    }
  },
}
</script>
```

```vue
// src/views/SignIn.vue
<script>
import {signIn} from '@/utils/auth.js'  // Adding this line
export default {
  name: "SignIn",
...
  methods: {
    submit() {
      if (this.$refs.form.validate()) {
        console.log(`SIGN IN username: ${this.username}, password: ${this.password}`);
        signIn(this.username, this.password);  // Adding this line as well
      }
    },
  },
}
</script>
```

```vue
// src/views/Home.vue
<template>
  <div class="home">
    <v-btn @click="signOut">Sign Out</v-btn>
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
import {signOut} from '@/utils/auth.js'
export default {
  name: 'home',
  components: {
    HelloWorld
  },
  methods: {
    signOut() {
      signOut().then((data) => console.log('DONE', data)).catch((err) => console.log('SIGN OUT ERR', err));
    }
  }
}
</script>
```

### `router.js`

先ほど少し書きましたが、ユーザーのログイン状態に応じてページ遷移を制御したいです。`http://localhost:8080/signUp`をログイン済みのユーザーには表示させたくありませんし、ログインしていないユーザーには`Home.vue`の内容を表示させたくはありませんよね。

```js
// src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import { AmplifyEventBus } from 'aws-amplify-vue'
import {getUser} from '@/utils/auth.js'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home,
      meta: { requiresAuth: true },
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    {
      path: '/signUp',
      name: 'signUp',
      component: () => import(/* webpackChunkName: "signup" */ './views/SignUp.vue'),
      meta: { requiresAuth: false },
    },
    {
      path: '/signUpConfirm',
      name: 'signUpConfirm',
      component: () => import(/* webpackChunkName: "confirm" */ './views/SignUpConfirm.vue'),
      meta: { requiresAuth: false },
    },
    {
      path: '/signIn',
      name: 'signIn',
      component: () => import(/* webpackChunkName: "signin" */ './views/SignIn.vue'),
      meta: { requiresAuth: false },
    },
  ]
})

getUser().then((user) => {
  if (user) {
    router.push({path: '/'})
  }
})

AmplifyEventBus.$on('authState', async (state) => {
  const pushPathes = {
    signedOut: () => {
      router.push({path: '/signIn'})
    },
    signUp: () => {
      router.push({path: '/signUp'})
    },
    confirmSignUp: () => {
      router.push({path: '/signUpConfirm'})
    },
    signIn: () => {
      router.push({path: '/signIn'})
    },
    signedIn: () => {
      router.push({path: '/'})
    }
  }
  if (typeof pushPathes[state] === 'function') {
    pushPathes[state]()
  }
})

router.beforeResolve(async (to, from, next) => {
  const user = await getUser()
  if (!user) {
    if (to.matched.some((record) => record.meta.requiresAuth)) {
      return next({
        path: '/signIn',
      })
    }
  } else {
    if (to.matched.some((record) => typeof(record.meta.requiresAuth) === "boolean" && !record.meta.requiresAuth)) {
      return next({
        path: '/',
      })
    }
  }
  return next()
})

export default router
```

以上です！これだけで動くのか眉唾モノですね。

## 動かしてみましょう！

`http://localhost:8080`にアクセスしてみてください。もし`http://localhost:8080/signIn`に勝手に遷移されたら、`router.js`への変更が無事反映されていますので安心してください。
"Sign Up"メニューから、ご自身のメールアドレスを使ってサインインしてみてください。確認メールが届くはずです。
メールアドレスと確認メールのコードを"Confirm"ページに入力後、ログインフォームにメールアドレスとパスワードを入力すると`http://localhost:8080`に遷移できるはずです。


_NOTE:_ 2019年7月1日現在、以下のエラーがコンソールに出てくるかもしれません。

> No credentials, applicationId or region

これはレポート済みのエラーです。とりあえずアプリは動くはずですので無視してください。

# おわりに

AWS Consoleをあまり使うことなく、簡単にログイン機能を作ることができました。Amplifyすごい。
作成されたユーザーはAWS Console > Cognito > Manage User Pools > Users and groupsで無効にしたり削除したりできるので、ユーザー作成に失敗したらAWS Consoleで削除してください。

Firebaseを使ったことある方はそちらの方が簡単かもしれませんが、AWS Amplifyでも簡単にサーバーレスアプリが作れそうです。これから当分AWS Amplifyで遊んでいきたいと思っています。

お読み頂きありがとうございました。