🚀

Terraform, Nuxt(SPA)+Amplify, CloudFront+S3で閲覧制限付きのサイトを作った話(サンプル付き)

2022/05/10に公開

概要

IP制限をかけた事業者用webアプリを作ったので、技術選定の背景やハマりどころ、細かいtipsをまとめました。少しでも参考になれば幸いです。

技術スタック:

セキュリティ要件:

  • IP制限を行い、事業者のIPからしかアクセスできないこと
  • ipass認証を挟み、事業者しかログインできないこと

前提:

  • フロントエンドから利用するAPIは別途用意されており、事業者のIPからのみ利用できるようになっています。(詳細は割愛します。)

サンプルGitHub Repository

サンプル用のRepositoryを作ったので参考にしてみてください。
https://github.com/koheiiwamura/terraform-nuxt-spa

詳細

インフラアーキテクチャ

CloudFront+S3

悩み

Amplify Hostingを使うかCloudFront+S3を自前で作成するか悩みました。

解決策

以下2点の条件があったため、CloudFront+S3を自前で作成するようにしています。

  • WAFを使ってIP制限をしたかったが、Amplify Hostingの設定のみだとできなかった
  • すでにバックエンドにIP制限がかかったREST APIが用意されているため、Amplifyを使ってbackendを作成する必要はなく、またAWSのリソースを直接操作する必要がなかった

補足

  • 比較記事をいくつか拝見したところ、Amplify Hostingは少ない設定で簡単にデプロイまで行うことができますが、セキュリティ要件(e.g. キャッシュポリシー)などの細かい設定が要件に入ってくると管理しにくいみたいですね。
  • 記事執筆時点でもAmplify HostingのWAF対応はリクエストされています(GitHub Issue)。今後に期待です😁

WAF

IP制限を行うためにWAFを使っています。

ハマりどころ

Terraformでリージョンを考慮しないままリソースを作成しようとするとエラーが出ます。
なぜエラーが起きるかというと、WAFv2をCloudFrontで適用させる場合、WAFv2はus-east-1で作成する必要があるからです。

解決策

terraform0.11からmoduleでproviderを指定できるようになっています。
今回の実装ではそれを利用し、WAFとACMのmoduleに対する処理はproviderのリージョンをus-east-1に切り替えています。

environments/dev|prod/main.tf
terraform {
  required_version = "~> 1.1.7"
  ...
  required_providers {
    aws = {
      version = "~> 3.0"
      source  = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

module "network" {
  source = "../../modules/network"

  providers = {
    aws      = aws
    aws.use1 = aws.use1
  }
}
modules/network/main.tf
resource "aws_wafv2_web_acl" "allow_only_vpn" {
  provider    = aws.use1
  scope       = "CLOUDFRONT"
...

(参考記事)
https://www.terraform.io/language/upgrade-guides/0-11#interactions-between-providers-and-modules

https://medium.com/@jyotti/terraform-multi-region-79a84ce1da0c

CloudFront Function

悩み

CloudFrontでS3のオブジェクトをホスティングしている場合、リクエストのuriにはS3のパスまで指定しないといけませんでした。(今回の場合だと、https://<domain>/index.html)

解決策

CloudFront Functionを用いて、ドメインへのリクエストを自動でindex.htmlへのリクエストに書き換えるようにを設置しています。
昔はLambda@Edgeを利用する必要がありましたが、
軽量な処理にはシンプルに設定できて値段も安価なCloudFront Functionを使えるので便利ですね。

plantuml

@startuml
== CloudFrontのみの場合 ==
participant client
participant cloudfront_function
client -> cloudfront: https://domain/index.html
note right of client: パスまで指定する必要がある.
cloudfront -> s3: index.html
cloudfront <-- s3
client <-- cloudfront
== CloudFront Fuctionを挟む場合 ==
client -> cloudfront_function: https://domain
note right of client: ドメインのみをリクエストすればよい.
cloudfront_function -> cloudfront: https://domain/index.html
cloudfront -> s3: index.html
cloudfront <-- s3
client <-- cloudfront
@enduml

(参考記事)
https://dev.classmethod.jp/articles/amazon-cloudfront-functions-release/

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html

Basic認証

Basic認証は、Amplifyを利用せずともLambda@Edgeで実装することもできます。ですが、今回は以下の理由でNuxtJS側にAmplifyライブラリを使うようにしています。

  • 今回のwebアプリと密接な関わりのあるサービスで、ALBのリスナールールとCognitoを組み合わせたBasic認証を行っており、そのuserpoolに登録しているユーザーデータと統一して管理したかったため
  • 将来的にGoogle認証などのSSOを利用できるようにしたいため

Amplify ライブラリ(認証状態管理)

ログイン状態の管理はAmplify ライブラリを使って実装しています。

pluginでAmplifyの初期化

plugins/amplify.ts
import Vue from 'vue'
import Amplify, * as AmplifyModules from 'aws-amplify'
import { Context } from '@nuxt/types'
// @ts-ignore
import { AmplifyPlugin } from 'aws-amplify-vue'
export default (context: Context) => {
  Amplify.configure({
    Auth: {
      region: context.$config.AWS_REGION,
      userPoolId: context.$config.COGNITO_USER_POOL_ID,
      userPoolWebClientId: context.$config.COGNITO_USER_POOL_WEB_CLIENT_ID,
    },
  })

  Vue.use(AmplifyPlugin, AmplifyModules)
}
nuxt.config.ts
  plugins: [{ src: '@/plugins/amplify.ts', mode: 'client' }],

middlewareでページレンダリング前に認証状態をチェックする

middleware/authenticated.ts
import { Context, Middleware } from '@nuxt/types'
import { getUserID, isAuthenticated } from '~/modules/authService'

const checkAuth: Middleware = async (context: Context) => {
  const { store, redirect, route } = context
  const _isAuthenticated = await isAuthenticated()
  // 未認証の場合はログインページへリダイレクト
  if (!_isAuthenticated && route.fullPath !== '/login')
    return redirect('/login')
  // 認証済みの場合はユーザー情報をStoreに保存
  if (_isAuthenticated && store.getters['adminUser/getUserID'] === null) {
    const userID = await getUserID()
    store.dispatch('adminUser/setUserID', userID)
  }
}

export default checkAuth

今回は全ページで認証を挟みたいので、nuxt.config.tsで設定しています

nuxt.config.ts
  router: {
    middleware: 'authenticated',
  },

Amplify経由で認証状態を確認するmodule

modules/authService.ts
import { Auth } from 'aws-amplify'

export const getUserId = async (): Promise<string> => {
  const user = await Auth.currentAuthenticatedUser()
  return user.attributes.sub
}

export const isAuthenticated = async (): Promise<boolean> => {
  return await new Promise((resolve, reject) => {
    Auth.currentAuthenticatedUser()
      .then(() => resolve(true))
      .catch(() => resolve(false))
  })
}

(参考記事)
https://www.ragate.co.jp/blog/articles/7888

Amplify UI Components(認証画面)

ログイン画面はAmplify UI Componentsを使って実装しています。

ハマりどころ

会員登録画面やパスワードリセット画面への遷移を隠す実装がなかなか手間取りました。

前提として事業者であっても会員登録やパスワードリセットを自由に行ってほしくないため、会員登録画面やパスワードリセット画面への遷移を隠す必要がありました。

以下の2点が手間取った大きな理由です。

  • 会員登録画面への遷移を非表示にするのはいろんな記事があるので、かなり混乱します。書かれている通りに設定しても上手くいかない多かった
  • 記事執筆時点では設定方法の公式ページが消えてたので、確かな情報を取得しづらい状況

解決策

結局ソースコードを読むことで万事解決でした。そうすることで以下の2点が分かりました。(参照したソースコード

  • 会員登録画面へのリンクの非表示方法
  • そもそもパスワードリセット画面へのリンクを非表示にする設定はない

パスワードリセット画面へのリンクは翻訳機能を用いてリンクの文字をスペースにし、泥臭く非表示にしています。

pages/login.vue
<template>
  <amplify-authenticator :auth-config="authConfig"> </amplify-authenticator>
</template>

<script>
import { I18n } from 'aws-amplify'
// amplify-ui-vueで用意している文言をカスタマイズ
const dict = {
  ja: {
    'Forgot your password? ': ' ', // オプションでは非表示にできないので、文言をスペースにして非表示としている
    'Reset password': ' ', // オプションでは非表示にできないので、文言をスペースにして非表示としている
    'Sign In': 'ログイン',
  },
}
I18n.putVocabularies(dict)
I18n.setLanguage('ja')
export default {
  data() {
    return {
      authConfig: {
        signInConfig: {
          header: 'ログイン',
          isSignUpDisplayed: false, // 新規登録画面へのリンクは非表示。管理者アカウントは自由に作成させないため。
        },
      },
    }
  },
}

環境変数の設定

悩み

環境変数をハードコーディングせずに外部サービスで管理したい。

解決策

NuxtJSの2.13から環境変数をruntimeConfigで管理できるようになりました。runtimeConfigは.envファイルをサポートし、ファイルの中身を自動で環境変数に設定してくれます。
それ以前はdotEnvなどを使って環境変数に値をセットしてNuxtJS側で使えるようにするなどの工夫が必要でしたが、便利になりました。

publicRuntimeConfig:
  SSRでもCSRでも変数を利用できる。

privateRuntimeConfig:
  SSRでのみ変数を利用できる。
  クライアントに見せたくないデータを隠蔽できる。
  ※SPAでは使用不可

また環境変数をAWS Secrets Managerからデータを取得して.envに記入するスクリプトを作り、ビルド前に実行できるようにしています。

scripts/secretsmanager_to_dotenv.sh
#!/bin/bash -e
SECRET_ID="$1"

aws secretsmanager get-secret-value \
  --secret-id "${SECRET_ID}" \
  --query SecretString \
| jq -r 'fromjson | keys[] as $k | "\($k)=\"\(.[$k])\""' > .env

.github/workflows/main.yml
..
      - name: Application build prod
        run: |
          sh scripts/secretsmanager_to_dotenv.sh {{Secrets Manager name}}
          yarn generate
          rm .env
..

まとめ

細かくtipsを抜き出しているので、SPAを作る用途以外であっても少しでも参考になれば幸いです。
Amplifyがもう少し柔軟にインフラを設定できるようになったら、Amplify Hostingで完結させてみたいです。

Discussion