3連休にCognito vue/aws-amplifyを使って手軽に認証機能をハンズオン

はじめに

こんにちは、江口です。

前回は人事選考の自動化を作成するにあたり、認証基盤をFlask JWTでOAuth2を自作しました。

が、

認証認可の処理はAWS Cognitoに丸々載せ替えることもできます。
Cognitoに載せ換えることで、前回ほどの労力・工数はかなり削減されます。

ということで、今回はCognito + APIGatewayと時々aws-amplify(with vue)のハンズオンです。

また、今回はFacebookやTwitterなどのThirdPartyは取り扱いません。

目指す構成は以下です。

構成図

f:id:eguchi_asial:20180922063221p:plain

  • Cognitoに認証を任せ、AccessTokenを取得する
    • ※フロントはvue x aws-amplifyの構成です。
  • 取得したAccessTokenをAuthorizationHeaderに付与し、APIGatewayをコールする
  • APIGateway(lambda)では、tokenの検証と検証を通過すれば、バックに構える各APIにproxyする
  • 各APIはクライアントにJSONを返す
    • ※各APIはAPIGatewayからのリクエストのみを許可します。

認証はCognitoに任せ、取得したtokenをheaderに仕込んだまま、
APIGatewayもコールしますが、実質APIとしてJSONを返すのはバックに構えるAPIサーバになります。

これにより、Lambdaの処理の中で、URLパターンを見て「/hoge」ならこのAPIに、「/fuga」ならこのAPIに。と、処理を各APIに移譲できます。

認証は共通でCognitoを使うが、サービスは複数ぶら下がることができるという狙いです。

上記が最終形ですが、二回に分けて紹介しようと考えているため、
今回は以下の構成までをハンズオンします。

f:id:eguchi_asial:20180921195911p:plain

  • Cognitoに認証を任せ、AccessTokenを取得する
    • ※フロントはvue x aws-amplifyの構成です。今回はlocalhostです
  • 取得したAccessTokenをAuthorizationHeaderに付与し、APIGateayをコールする
  • APIGateway(lambda)では、tokenの検証と検証を通過すれば、クライアントにテスト用のJSONを返す

つまり、オリジナルのAPIへのproxy部分は今回は作りません。
あくまで、CognitoとAPIGatewayの連携に焦点を当てたサンプルを作成します。

用語について

今回触れていく用語について簡単に紹介します。

Cognito

認証・認可をサポートしてくれます。
今回は下記のUserPoolsとFederated Identitiesとセットで使います。

▼参考
Amazon Cognito とは - Amazon Cognito

User Pools

認証・ユーザーアカウントの管理。
サインアップ・サインイン等の認証処理を手がけてくれます。

▼参考
docs.aws.amazon.com

Federated Identities

認可。
AWS 認証情報を取得して、別の AWS サービスへのアクセスを管理します。

▼参考
docs.aws.amazon.com

APIGateway

APIです。
今回はLambdaと連携してJSONを返します。
オーソライザと呼ばれる機能を使い、Cognitoで認証を通過したアクセスのみ、
APIへのアクセスを認可するようにしています。

認証に通過したユーザーはtokenを取得して持っているはずなので、tokenをAuthorizationHeaderにセットしてAPIにアクセスすることでAPIGatewayにアクセスできるようになります。

▼参考
docs.aws.amazon.com

aws-amplify

Cognitoを簡単に利用できるようになるjsライブラリです。
これまではCognitoIdentity SDKとAWS SDKを組み合わせて使う必要がありましたが、
amplifyのみで完結できるようになりました。

▼参考
docs.aws.amazon.com

▼Github
github.com

Try

Cognito - UserPool -

まずはCognitoのUserPool設定からやっていきます。
これがないと始まりません。

AWSマネージメントコンソールにsighnInし、「Cognito」を開き、
以下の「ユーザープールの管理」を押下します。

f:id:eguchi_asial:20180916162046p:plain

押下したら、新規でユーザープールを作成しますので、
「tutorial」と命名し、「デフォルトを確認する」を選択します。

f:id:eguchi_asial:20180916162050p:plain

デフォルト設定のプレビューが表示されます。

f:id:eguchi_asial:20180916162616p:plain

こちらはあとで編集できますので、とりあえず「プールの作成」を押下し、作成を完了します。

完了したらダッシュボードに遷移すると思いますので、

「アプリクライアント」の「アプリクライアントID」の値をコピーしておいてください。
「アプリクライアントの設定」のを押下し、以下になるようにチェックを入れます。
また、注意点として、「アプリクライアント」の「クライアントシークレットの生成」はチェックが外れていることを確認してください。

これだけで、まずは簡単に器ができました!!

最後にUserPoolを利用する側のクライアントの設定をちょろっといじります。

f:id:eguchi_asial:20180916164834p:plain

callback urlでerrorが出る場合は、一旦サンプルなので「http://localhost:9999/test.php」とでも入れておいてください。

ログイン成功後にaccess_tokenがハッシュ文字列として付与されたURLにリダイレクトされますが、今回は使いません。

「許可されているOAuthフロー」で「Implicit grant」を選択しましたが、
「Authorization code grant」との違いはこちらを参考にしてください。
ユーザープールのアプリクライアントの設定 - Amazon Cognito

今回は「Implicit grant」 = 「認証エンドポイントから直接AccessTokenを取得」する方法にしました。

Cognito - Federated Identities -

続いて、Identityを作成します。

ヘッダーに「ユーザープール | フェデレーティッドアイデンティティ」とメニューがあるので、「フェデレーティッドアイデンティティ」を押下します。

f:id:eguchi_asial:20180916164140p:plain

押下したら、こんな画面に移るので、IDプール名には適当なnameを入力し、
「認証プロバイダー」の「Cognito」タブ「ユーザープールID」に先ほど作成したUserPoolのIDを記入し、アプリクライアントIDに先ほどコピーしたIDを記入します。

記入したら、「プールの作成」を押下します。
※この時エラーが出ることがありますが、時間を置いてから押すことで無事に作成されました。なんでしょうかね。

これで完了です。

続いてIAMの設定画面に移りますので、デフォルトのまま、AUTHロールとUNAUTHロールを新規作成します。

f:id:eguchi_asial:20180920180032p:plain

これでIDプールの作成は完了です。

Cognitoの確認

Cognitoはログインページがデフォルトで用意されるので、試しに表示して見ます。

以下のようにURLを組み立ててアクセスしてみると...

"https://YOUR_DOMAIN/oauth2/authorize?response_type=token&client_id=[YOUR_CLIENT_ID]&redirect_uri=http://localhost:9999/test.php"

f:id:eguchi_asial:20180916172134p:plain

このようなデフォルトUIが表示されます。
実際にログインすると、AccessTokenが帰ります。
試しに、localhost:9999でphp -aでlocalServerを立ち上げて見ます。

# vi test.php
<?php
$str = $_SERVER["REQUEST_URI"];
preg_match('/#(.+)$/', $str, $res);
var_dump(parse_url($str));

中身は適当でいいです。

作成したらlocalhostを立ち上げます。


# php -S localhost:9999

立ち上げたら、先ほどのURLにアクセスして見ますと...

http://localhost:9999/test.php#id_token=eyJra...[省略]&access_token=....[省略]&expires_in=3600&token_type=Bearer

このような形でTokenが取得できるかと思います!!

APIGatewayの用意

認証基盤は用意できたので、続いてプロキシとしてのAPIGateWayを用意します。

ですが、今回はプロキシではなく、「Cognitoの認証を通過したTokenを持ったリクエスト時にのみ、JSONを200Statusで返す」まで作って見ます。

まず、こちらを参考に、以下のようなlambda関数を一つ作成してください。

・関数名「cognito-tutorial-lambda」

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": 'Hello from Lambda!'
    }

私はPythonが好きなのでpython36を選んでますが、ここは何でも構いません。

続いて、APIGatewayに遷移し、新しいAPIの作成で適当にテスト用のAPIを作成します。

f:id:eguchi_asial:20180918185326p:plain

「新しいAPI」で「APIの作成」を押下します。

以下のようにドロップダウンメニューから「リソースの作成」でtestディレクトリを作成します。

f:id:eguchi_asial:20180918185519p:plain

ディレクトリが作成できたら、「メソッドの作成」からGETメソッドを作成し、統合タイプを「Lambda関数」にし、先ほど作成したlambda関数名を入力して作成してください。

f:id:eguchi_asial:20180918185831p:plain

これでGETでJSONを取得するAPIが一つできました。

最後に/testを選択した状態でドロップダウンより「CORSの有効化」を行います。
CORSの設定として、今回は「Access-Control-Allow-Origin」ヘッダに「http://localhost:8080」を設定しました。
これで、あとで紹介するフロントでresponse bodyを受け取れるようになります。
(※ Access-Control-Allow-Originに指定されたドメイン以外からのリクエストだと、ブラウザがresponseを取得しません。)

APIGatewayAPIにオーソライザーを新規追加する

しつこいですが、Cognitoで認証を通過したユーザーだけがAPIにアクセスできます。

それ以外のユーザーは401ないしは403となります。

ちゃっちゃかやっていきます。

先ほど作成したAPIのメニューから「オーソライザ」を選択し、「新しいオーソライザを作成」を押します。

f:id:eguchi_asial:20180917100348p:plain

上図のような設定になるように、作成すれば完了です。

オーソライザをAPIのアクセス制限に設定する

追加したオーソライザをAPIに当てていきます。
先ほど作成した「GET: /test」エンドポイントを選択し、「メソッドリクエスト」をクリックすると、以下のような設定画面に遷移します。

f:id:eguchi_asial:20180917100613p:plain

「認証」に先ほど追加したオーソライザを選択します。
画面キャプチャから漏れてしまいましたが、「HTTTPリクエストヘッダー」の開き、「Authorization」を追加、必須に指定します!

これで準備は完了です。

フロント(vue / aws-amplify)

フロントで行う処理は以下です。

  1. Cognitoログインフォームを表示
  2. APIGatewayにdeployしたAPIをaxios経由でコールする

です。

今回は以下の技術構成でフロントを構成してます。

  • vue
    • JavaScript フレームワーク
  • axios
    • 非同期通信で使用
  • aws-amplify
    • Cognitoを利用したWebアプリケーションを開発するための包括的ライブラリ
  • vue-router
    • URLとVueコンポーネントのマッピング。ルーティングに使用
環境構築
  1. npm install -g vue/cli
  2. vue-init web pack cognito-tutorial
  3. npm install(以下のpackage.jsonを参考)
  4. aws-exports.jsをsrc下に手動で作成
{
  "name": "cognito-tutorial",
  "version": "1.0.0",
  "description": "A Vue.js project",
  "author": "eguchi <eguchi@asial.co.jp>",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "unit": "jest --config test/unit/jest.conf.js --coverage",
    "e2e": "node test/e2e/runner.js",
    "test": "npm run unit && npm run e2e",
    "lint": "eslint --ext .js,.vue src test/unit test/e2e/specs",
    "build": "node build/build.js"
  },
  "dependencies": {
    "@vue/eslint-config-prettier": "^3.0.3",
    "amplify": "0.0.11",
    "aws-amplify": "^1.0.11",
    "axios": "^0.18.0",
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^8.2.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-jest": "^21.0.2",
    "babel-loader": "^7.1.1",
    "babel-plugin-dynamic-import-node": "^1.2.0",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "babel-register": "^6.22.0",
    "chalk": "^2.0.1",
    "chromedriver": "^2.27.2",
    "copy-webpack-plugin": "^4.0.1",
    "cross-spawn": "^5.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^4.15.0",
    "eslint-config-standard": "^10.2.1",
    "eslint-friendly-formatter": "^3.0.0",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-node": "^5.2.0",
    "eslint-plugin-promise": "^3.4.0",
    "eslint-plugin-standard": "^3.0.1",
    "eslint-plugin-vue": "^4.0.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^1.1.4",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "html-webpack-plugin": "^2.30.1",
    "jest": "^22.0.4",
    "jest-serializer-vue": "^0.3.0",
    "nightwatch": "^0.9.12",
    "node-notifier": "^5.1.2",
    "optimize-css-assets-webpack-plugin": "^3.2.0",
    "ora": "^1.2.0",
    "portfinder": "^1.0.13",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "rimraf": "^2.6.0",
    "selenium-server": "^3.0.1",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "uglifyjs-webpack-plugin": "^1.1.1",
    "url-loader": "^0.5.8",
    "vue-jest": "^1.0.2",
    "vue-loader": "^13.3.0",
    "vue-style-loader": "^3.0.1",
    "vue-template-compiler": "^2.5.2",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0",
    "webpack-dev-server": "^2.9.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}
<||
src/aws-exports.jsの中身は以下のようにします。
各自で作成した値を埋め込むだけです。
>|javascript|
import Amplify from 'aws-amplify'
Amplify.configure({
  Auth: {
    // フェデレーションアイデンティティのID
    identityPoolId: 'ap-northeast-1:hogee',
    // リージョン
    region: 'ap-northeast-1',
    // ユーザープールのID
    userPoolId: 'ap-northeast-hogeeeet',
    // ユーザープールのウェブクライアントID
    userPoolWebClientId: 'fugaaaa',
    mandatorySignIn: true
  }
})

[aws-exports.js参考]
https://aws-amplify.github.io/amplify-js/media/authentication_guide.html

webpackサンプルを作成すると、最初から「HelloWorld.vue」ページコンポーネントがあると思いますので、これを書き換えちゃいます。

こんな感じにしてください。

<template>
  <div class="page">
    <div class="login-form">
      <p>ログイン</p>
      <p>{{ status }}</p>
      <p>{{ message_text }} </p>
      <ul class="login-form">
        <li>
          <label>ユーザー名</label>
          <input
            v-model="userInfo.username"
            type="text">
        </li>
        <li>
          <label>パスワード</label>
          <input
            v-model="userInfo.password"
            type="password">
        </li>
      </ul>
      <button
        class="btn btn-primary"
        @click="signIn">ログイン</button>
      <button
        class="btn btn-primary"
        @click="signOut">ログアウト</button>
      <br>
      <router-link :to="{ name: 'ApiGatewayTest'}">APIGateway連携テスト</router-link>
    </div>
  </div>
</template>
<script>
import Amplify, { Auth } from 'aws-amplify'
const awsExports = require('@/aws-exports').default
Amplify.configure(awsExports)
export default {
  data() {
    return {
      status: '',
      userInfo: {
        username: '',
        password: ''
      },
      message_text: '',
      url: ''
    }
  },
  created() {
    this.checkLogin()
  },
  mounted() {
    console.log(this.userInfo)
  },
  methods: {
    checkLogin() {
      Auth.currentSession()
        .then(data => {
          console.log(data)
          this.status = 'ログインしています'
        })
        .catch(err => {
          console.log(err)
          this.status = 'ログインしていません'
        })
    },
    signIn() {
      Auth.signIn(this.userInfo.username, this.userInfo.password)
        .then(data => {
          this.message_text = 'ログインしました'
          this.status = 'こんにちは、' + data.username + 'さん'
        })
        .catch(err => {
          console.log(err)
          this.message_text = 'ログインできませんでした'
        })
      this.checkLogin()
    },
    signOut() {
      Auth.signOut()
        .then(data => {
          console.log(data)
          this.message_text = 'ログアウトしました'
        })
        .catch(err => {
          console.log(err)
          this.message_text = 'ログアウトできませんでした'
        })
      this.checkLogin()
    }
  }
}
</script>
<style scoped>
.login-form {
  list-style: none;
}
</style>

これだけで、Cognitoへのログイン処理ができますが、

<router-link :to="{ name: 'ApiGatewayTest'}">APIGateway連携テスト</router-link>

とあるように、APIGatewayのAPIを呼び出すために、ルーティングとコンポーネントを追加します。

▼routing
routingはrouter/index.jsを編集します。
以下のようにします。

import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import ApiGatewayTest from '@/components/ApiGatewayTest'
Vue.use(Router)
export default new Router({
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    },
    {
      path: '/apigateway-test',
      name: 'ApiGatewayTest',
      component: ApiGatewayTest
    }
  ]
})

これで「/apigateway-test」でApiGatewayTest.vueが呼ばれます。

src/components/ApiGatewayTest.vueを以下のように作成します。

<template>
  <div class="apigateway-test">
    <div class="contents">
      <p>{{ status }}</p>
      <p class="message">{{ message }}</p>
    </div>
  </div>
</template>
<script>
import { Auth } from 'aws-amplify'
import * as api from '@/libs/api/api'
export default {
  data() {
    return {
      status: '',
      message: 'empty'
    }
  },
  created() {
    Auth.currentSession()
      .then(data => {
        console.log(data)
        this.status = 'ログインしています'
      })
      .catch(err => {
        console.log(err)
        this.status = 'ログインしていません'
      })
  },
  mounted() {
    this.test()
  },
  methods: {
    async test() {
      const { data: { body } } = await api.getTest()
      this.message = body
    }
  }
}
</script>
<style scoped>
.message {
  font-weight: bold;
  font-size: 24px;
}
</style>

次に非同期APIアクセス処理を追記します。

src/libs/apiでディレクトリを作成し、api.jsを以下のように作成します。

import axios from 'axios'
// API系は自分のAPI使ってね
const API_BASE_URL = 'https://hogeeeeeeee-api.ap-northeast-1.amazonaws.com/'
const API_ENV = 'stg'
const apiClient = axios.create({
  baseURL: API_BASE_URL
})
apiClient.interceptors.request.use(async config => {
  config.headers['Authorization'] = localStorage.getItem(
   // 各自の環境で生成されたlocalStorageのidTokenのkey名を使う
    'CognitoIdentityServiceProvider.hogeeeeee.eguchi.idToken'
  )
  return config
})
export async function getTest() {
  return apiClient.get(`${API_BASE_URL}${API_ENV}/test`)
}

API_BASE_URLとlocalStorageのkey名は各自で入れ替えてください。
API_ENVはAPIGatewayのステージが該当します。
最終的なURLイメージとしては以下のようなエンドポイントになります。

https://hogeeee-api.ap-northeast-1.amazonaws.com/stg/test

amplifyを使うと、ログイン成功時にlocalStorageにCredential情報が自動で詰められるので、その中でも以下のキャプチャにあるような「IdToken」をAuthorizationヘッダに使用してください。

f:id:eguchi_asial:20180917103830p:plain

これで完了です。

全体的な流れのまとめとしては、、、

1. npm run devでlocalサーバ開始
2. 「http://localhost:8080」にアクセスする
f:id:eguchi_asial:20180917104040p:plain

3. ユーザー名とパスワードを入力して、ログインをおす

f:id:eguchi_asial:20180917104139p:plain

ログインしました。となります。この時、localStorageにもCredentialsが格納されてます。確認してみてください。

4. 「APIGateway連携テスト」をクリックして遷移する

f:id:eguchi_asial:20180917104304p:plain

遷移すると、ApiGatewayTest.vueが呼ばれます。
ApiGatewayTest.vueではmountedの中で

mounted() {
    this.test()
  },
  methods: {
    async test() {
      const { data: { body } } = await api.getTest()
      this.message = body
    }
  }

のように、APIをコールしてます。
APIからJSONを取得し、

this.message = body

でdataのmessageに値を詰めてます。
これが

<p class="message">{{ message }}</p>

で画面に表示されます。なお、messageのdefault値は「'empty'」です。

amplifyのAPIクラスに置き換えてみる

上記のやり方でも機能実現は可能です。
ですが、自分でlocalStorageからkeyを指定して、axiosでheaderにセットして〜とか管理しなくても、
実はamplifyを使って書き換えることも可能です。
この場合、localStorageのkeyを意識しないで済みます。

「src/aws-exports.js」と「src/libs/api/api.js」と「ApiGatewayTest.vue」をそれぞれ以下のように書き換えて見ましょう

▼src/aws-exports.js
※各自の環境に書き換えてください。

import Amplify from 'aws-amplify'
Amplify.configure({
  Auth: {
    // フェデレーションアイデンティティのID
    identityPoolId: 'ap-northeast-1:3dce04b6-45c8-49c7-9b53-654ad7cbd2aa',
    // リージョン
    region: 'ap-northeast-1',
    // ユーザープールのID
    userPoolId: 'ap-northeast-1_KAmmkV74r',
    // ユーザープールのウェブクライアントID
    userPoolWebClientId: '4enc6qmrc2isf2phv5hoag17t',
    mandatorySignIn: true
  },
  API: {
    endpoints: [
      {
        name: 'cognito-tutorial-api',
        endpoint: 'https://milaadw0od.execute-api.ap-northeast-1.amazonaws.com'
      }
    ]
  }
})

▼src/libs/api/api.js

import { Auth, API } from 'aws-amplify'
const API_NAME = 'cognito-tutorial-api'
export async function getTest() {
  const user = await Auth.currentAuthenticatedUser()
  const token = user.signInUserSession.idToken.jwtToken
  return API.get(API_NAME, '/stg/test', {
    headers: {
      Authorization: token
    }
  })
}

▼ApiGatewayTest.vue

methods: {
    async test() {
      const { body } = await api.getTest()
      this.message = body
    }
  }
最後に...

※環境構築がうまくいかなった人は、完成形をGithubに上げてありますので、設定やエンドポイントを書き換えて使ってみてください。
github.com

まとめ

今回は

  • Cognito UserPoolの作成
  • Cognito FederatedIdentityの作成
  • APIGatewayの作成
  • vue / aws-amplifyを使ったフロント処理

まで行いました。

次回は、今回APIとして使ったLambdaをプロキシとして使用し、
後続のオリジナルAPIに処理を繋ぐという処理をやっていきたいと思います。

長文・駄文でしたが、ありがとうございました。

以上です。