Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(2要素認証設定編)

# はじめに

本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルの2要素認証設定編の記事になります。

前回: [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(RequestSpec編)](https://qiita.com/kk-icare/items/20a180f7209ed8f4c485)

次回: [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(2要素ログイン編)](https://qiita.com/kk-icare/items/1c298a4243058751b99b)

# Rails側

Railsで2要素認証を行う場合、既にdeviseが導入されているなら、[devise-two-factor](https://github.com/tinfoil/devise-two-factor)を使うとそれほど工数をかけずに2要素認証を導入できるかと思いますので、今回はこのGemを用いて実装します。

[devise-two-factor](https://github.com/tinfoil/devise-two-factor)はGemの内部の実装で、[rotp](https://github.com/mdp/rotp)というTOTP(Time-based One-time Password)での認証を提供するGemを使っているので、deviseを使っていない場合は[rotp](https://github.com/mdp/rotp)単体で使うといいかもしれません。

## devise-two-factorの導入

まずはGemのインストールから

### インストール

```ruby
# Gemfile

gem 'devise-two-factor'
```

でbundle install

```ruby
$ bundle
```

bundle installが終了したら、devise-two-factorの機能を使うのに必要なカラムの生成とモデルの書き換えを行ってくれるコマンドを実行します。

### カラムの生成とモデルの書き換えコマンドの実行

```ruby
$ bundle exec rails generate devise_two_factor User ENCRYPTION_KEY
```

上記コマンドを実行すると、Userモデルに以下のような変更が加わります。

```ruby
class User < ActiveRecord::Base
  devise :two_factor_authenticatable,
         :otp_secret_encryption_key => ENV['ENCRYPTION_KEY']

  # database_authenticatableが削除される
  devise :registerable, :recoverable, :rememberable, :trackable, :validatable
end
```

さらに、2要素認証に必要なマイグレーションファイルも自動生成されます。

```ruby
class AddDeviseTwoFactorToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :encrypted_otp_secret, :string
    add_column :users, :encrypted_otp_secret_iv, :string
    add_column :users, :encrypted_otp_secret_salt, :string
    add_column :users, :consumed_timestep, :integer
    add_column :users, :otp_required_for_login, :boolean
  end
end
```

変更内容が確認できたら以下コマンドを実行

```ruby
$ rails db:migrate
```

### 環境変数の設定

先ほど追加したENCRYPTION_KEYの管理方法はアプリケーションコードにベタ書き以外はご自身のお好きな方法で良いかと思いますが、今回はRails付属のcredentialsで管理したいと思います。

https://zenn.dev/banrih/articles/f22f0a70bbead2a02110

ENCRTPTION_KEYの値は適当にSecureRandom.hex(32)等で生成したものを割り当てることにします。

```ruby
$ rails c

$ SecureRandom.hex(32)
=> "ランダムな文字列"
```

以下コマンドを実行

```ruby
$ EDITOR=vi rails credentials:edit

# aws:
#   access_key_id: 123
#   secret_access_key: 345

# ここから追加
# devise_two_factor encryption_key

devise_two_factor:
  ENCRYPTION_KEY: "ランダムな文字列を貼り付けてね"

# ここまで

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: d56...
```

追加し終えたら、escキーを押してから :wq を押すと保存ができます。

追加した環境変数をきちんと参照できるか確かめてみます。

```ruby
$ Rails.application.credentials.devise_two_factor[:ENCRYPTION_KEY]
=> "hogefugahogefuga..."
```

参照できることが確認できました。

先ほど自動で追加されたUserモデルの記述を変更します。

```ruby
class User < ActiveRecord::Base
  # ENV['ENCRYPTION_KEY'] から以下のように変更
  devise :two_factor_authenticatable,
         :otp_secret_encryption_key => Rails.application.credentials.devise_two_factor[:ENCRYPTION_KEY]

  devise :registerable, :recoverable, :rememberable, :trackable, :validatable
end
```

これで導入は完了です。

## 2要素認証設定ページの設定

次に2要素認証設定ページのRails側の実装を行います。

### Routing

2要素認証の設定画面のルーティングを定義します。

```ruby
  scope format: 'json' do
    # ここから追加
    namespace :users do 
      resource :account, only: :show
    end
    # ここまで

    resources :users do
      resources :posts
    end
  end
```

### Controller

Controllerは、Users::AccountsControllerを定義します。

```ruby
class Users::AccountsController < ApplicationController
  before_action :authenticate_user!

  def show; end
end
```

### Views

views/users/accounts/show.json.jbuilderを定義します。

```ruby
json.extract! current_user, :id, :allow_password_change, :email, :nickname, :provider, :uid, :otp_required_for_login
```

Vue.js側で、2要素認証ログインが有効かどうかを表すotp_required_for_loginの値を使って、表示の出し分けをする予定です。

## 2要素認証の有効化

### Routing

2要素認証有効化のルーティングを定義します。

```ruby
    namespace :users do
      resource :account, only: :show
      resource :two_factor_auth, only: [:show, :create] # 追加
    end
```

### Controller

- Users::TwoFactorAuthsController

createアクション内でパラメータの有無で2つのことを行います。

- OTPのSecretをUserに保存しつつ、QRコードのuriを返却する
- QRコードを読み取って入力された6桁の認証コードを認証する

```ruby
# app/controllers/users/two_factor_auths_controller.rb

class Users::TwoFactorAuthsController < ApplicationController
  before_action :authenticate_user!

  def show; end

  def create
    if permitted_otp_code[:otp_code]
      current_user.validate_and_consume_otp!(permitted_otp_code[:otp_code])
      current_user.otp_required_for_login = true
      current_user.save!
    else
      unless current_user.otp_secret
        current_user.otp_secret = User.generate_otp_secret(32)
        current_user.save!
      end
    end

    render :show
  end

  def permitted_otp_code
    params.permit(:otp_code)
  end
end
```

### views

```ruby
# app/views/users/two_factor_auths/show.json.jbuilder

json.otp_uri current_user.otp_provisioning_uri("service_name", issuer: "company_name")
```

これでRails側の実装は終了です。

# Vue.js側

次にVue.js側で2要素認証設定画面作成とQRコードの表示、2要素認証を有効化するAPIを叩く関数の作成等を行います。

## 2要素認証の有効化画面

### ルーティングの設定

URLは、/accountとします。

src/router/index.tsを編集します。

```typescript
import NewPost from '@/views/NewPost.vue'
import Account from '@/views/Account.vue' // 追加

  {
    path: '/posts/:id',
    name: 'PostDetail',
    component: PostDetail,
    meta: { requiresAuth: true }
  },
  // ここから追加
  {
    path: '/account',
    name: 'Account',
    component: Account,
    meta: { requiresAuth: true }
  }
　　　　// ここまで
```

Accountコンポーネントは後ほど実装しますので、エラーが出ていても一旦無視でOKです。


### コンポーネントの作成

次に、アカウント設定画面のコンポーネントの作成を行います。

```terminal
$ touch src/views/Account.vue
```

作成されたファイルを以下のように修正してください。

```vue
<template>
  <div class="mt-16 px-16 mx-auto xl:max-w-3xl">
    <h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
      xl:text-bold">
      アカウント
    </h2>
    <div class="mt-12">
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            2要素認証
          </div>
        </div>
        <p v-if='account.otp_required_for_login'>設定済み</p>
        <p v-else>設定する</p>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { getAccount } from '@/api/user'
import { User } from '@/types/user'

export default defineComponent({
  name: 'Account',
  setup () {
    const account = ref({} as User)
    const onGetAccount = async () => {
      await getAccount()
        .then((res) => {
          account.value = res.data
        })
    }

    onMounted(() => {
      onGetAccount()
    })

    return {
      account
    }
  }
})
</script>

```

まずはアカウント情報を取得するAPIを叩いて、2要素認証が有効かどうかをv-ifで判定して表示の出し分けをするところまで実装しています。

次に、User型のプロパティの追加をします。

```typescript
export type User = {
  allow_password_change: boolean;
  email: string;
  id: string;
  image: string | null;
  nickname: string;
  provider: string;
  uid: string;
  otp_required_for_login: boolean; // 追加
}
```

2要素認証が有効かどうかを表すotp_required_for_loginプロパティを新たに追加しています。

次に、ユーザー情報を取得する関数を定義します。

### APIを叩く関数の実装

```terminal
$ touch src/api/user.ts
```

作成したファイルを以下のように編集します。

```typescript
import Client from '@/api/client'
import {
  getAuthDataFromStorage,
  setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'
import { User } from '@/types/user'

export const getAccount = async () => {
  return await Client.get('/users/account', { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<User>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err) => {
      return err.response
    })
}
```

先ほどRails側で定義した/users/accountのエンドポイントを叩く関数を定義しました。

返り値はこんな内容になっています。

```json
{
	"id": 1,
	"allow_password_change": false,
	"email": "test-user+1@example.com",
	"nickname": "テスト",
	"provider": "email",
	"uid": "test-user+1@example.com",
	"otp_required_for_login": false
}
```

次に、アカウント画面へのリンクボタンをNavBarに設置します。

### NavBarの修正

```vue
// src/component/NavBar.vue

<template>
  <nav class="bg-gray-800">
    <div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
      <div class="relative flex items-center justify-between h-16">
        <div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
          <div class="flex-shrink-0 flex items-center">
          </div>
          <div class="hidden sm:block sm:ml-6">
            <div class="flex space-x-4">
              <button @click='movePosts()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">Top</button>
            </div>
          </div>
        </div>
　　　　　　　　　　　　　　　　// ここから追加
        <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button class="bg-gray-800 flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-haspopup="true">
              <button @click='moveAccount()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">MyPage</button>
              </button>
            </div>
          </div>
        </div>
        // ここまで
        <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button @click='moveNewPost()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">create Post</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </nav>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import router from '@/router'

export default defineComponent({
  name: 'NavBar',

  setup () {
    const movePosts = () => {
      router.push('/posts')
    }

    const moveNewPost = () => {
      router.push('/posts/new')
    }

    // ここから追加
    const moveAccount = () => {
      router.push('/account')
    }
    // ここまで

    return {
      movePosts,
      moveNewPost,
      moveAccount // 追加
    }
  }
})
</script>

```

一度問題なく/users/accountが表示できるか試してみましょう。

![スクリーンショット 2021-06-28 23.39.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/d6623beb-3e61-e585-d37d-1a983ee6746d.png)

/users/accountにアクセスして上記のような表示になっていれば一旦OKです。

### QRコード表示の準備

QRコードの表示には、vue-qrcodeというライブラリを使います。

https://github.com/fengyuanchen/vue-qrcode

以下のコマンドを実行してください。

```terminal
$ npm install vue@next qrcode @chenfengyuan/vue-qrcode@next
```

次にmain.tsを以下のように修正します。

```typescript
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'
import '@/assets/styles/tailwind.css'
import VueQrcode from '@chenfengyuan/vue-qrcode' // 追加

const app = createApp(App).use(router)

// ここから追加
if (VueQrcode.name) {
  app.component(VueQrcode.name, VueQrcode)
}
// ここまで

app.mount('#app')
```

if文で条件分岐を挟んでいるのは、VueQrcodeTypeのnameプロパティがoptionalなため、app.component関数の第一引数でstringが渡されることが定義されているのに、string | undefined な値を渡せないよ！とeslintに怒られてしまうためです、、、

![スクリーンショット 2021-06-28 23.48.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/02e35531-4b35-425f-c0b5-643c8d06b6ee.png)

```typescript
// component関数の型定義

export declare interface App<HostElement = any> {
    version: string;
    config: AppConfig;
    use(plugin: Plugin_2, ...options: any[]): this;
    mixin(mixin: ComponentOptions): this;
    component(name: string): Component | undefined;
    component(name: string, component: Component): this;
```

これでComponent内で、

```vue
<vue-qrcode :value="qrCode" />
```

のように使うことで、QRコードの表示が可能になります。

### QRコードのuriを取得する

次にRails側で定義したQRコードのuriを取得するAPIを叩く関数を定義し、QRコードを表示させるところまで実装してみます。

```typescript
// src/api/user.ts

/* eslint-disable @typescript-eslint/camelcase */ // camelcaseを使えと怒られる場合は追記してください

import Client from '@/api/client'
import {
  getAuthDataFromStorage,
  setAuthDataFromResponse
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'
import { User } from '@/types/user'
import { CurrentOtp } from '@/types/current_otp' // 追加

export const getAccount = async () => {
  return await Client.get('/users/account', { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<User>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err) => {
      return err.response
    })
}

// ここから追加
export const createQrCode = async () => {
  return await Client.post('/users/two_factor_auth', {}, { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<CurrentOtp>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err) => {
      return err.response
    })
}
// ここまで
```

Rails側に定義した/users/two_factor_authのエンドポイントをpostリクエストで叩く関数を定義しています。返り値は下記のようになります。

```json
{
	"otp_uri": "otpauth://totp/company_name:service_name?secret=YOURSECRET&issuer=company_name"
}
```

次にCurrentOtp型の定義を行います。

```terminal
$ touch src/types/current_otp.ts
```

作成されたファイルを以下のように修正します。

```typescript
export type CurrentOtp = {
  otp_uri: string;
}
```

次に、Account.vueでQRコードを表示する修正を行います。

## QRコードの表示

src/views/Account.vueを以下のように編集します。

```vue
<template>
  <div class="mt-16 px-16 mx-auto xl:max-w-3xl">
    <h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
      xl:text-bold">
      アカウント
    </h2>
    <div class="mt-12">
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            2要素認証設定
          </div>
        </div>
        <p v-if='account.otp_required_for_login'>設定済み</p>
        <p v-else><button @click='getQrCode()'>設定する</button></p> // 追加
        // ここから追加
        <div v-if="qrCode">
          <vue-qrcode :value="qrCode" />
        </div>
       // ここまで
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import { getAccount, createQrCode } from '@/api/user' // createQrCodeを追加
import { User } from '@/types/user'

export default defineComponent({
  name: 'Account',
  setup () {
    const account = ref({} as User)
    const qrCode = ref('') // 追加

    const onGetAccount = async () => {
      await getAccount()
        .then((res) => {
          account.value = res.data
        })
    }

    // ここから追加
    const getQrCode = async () => {
      await createQrCode()
        .then((res) => {
          qrCode.value = res.data.otp_uri
        })
    }
    // ここまで

    onMounted(() => {
      onGetAccount()
    })

    return {
      account,
      getQrCode, // 追加
      qrCode  // 追加
    }
  }
})
</script>

```

「設定する」ボタンの押下をトリガーに、QRコードを生成するAPIを叩いて、refで定義したリアクティブな値に代入し、templateにQRコードを描画するようにしています。

ここで一旦動作確認をしてみます。

添付画像のようにQRコードが表示されていればOKです。(画像は一部加工しています。)

![スクリーンショット 2021-06-29 19.02.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/cad0e470-7740-d06b-b509-c1b1cb7204b6.png)

### 6桁の認証コードを入力して2要素認証を有効化する

次に、2要素認証アプリ(私はGoogleAuthenticatorを使いました)でQRコードを読み取り、表示された6桁の認証コードを入力して、2要素認証を有効化するところまでを実装していきます。

Account.vueを以下のように修正してください。

```vue
<template>
  <div class="mt-16 px-16 mx-auto xl:max-w-3xl">
    <h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
      xl:text-bold">
      アカウント
    </h2>
    <div class="mt-12">
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            2要素認証設定
          </div>
        </div>
        <p v-if='account.otp_required_for_login'>設定済み</p>
        <p v-else><button @click='getQrCode()'>設定する</button></p>
        <div v-if="qrCode">
          <vue-qrcode :value="qrCode" />

          // ここから追加
          <div class="mt-8">
            <div class="flex justify-between items-center">
              <div class="text-sm font-bold text-gray-700 tracking-wide">
                認証コード
              </div>
            </div>
            <input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="otp_code" type="password" placeholder="Enter your otp code">
            <div class="mt-10">
              <button class="bg-indigo-500 text-gray-100 p-4 w-full rounded-full tracking-wide
                            font-semibold font-display focus:outline-none focus:shadow-outline
                            hover:bg-indigo-600 shadow-lg"
                      @click="handleVerifyOtpCode()"
              >
                認証する
              </button>
            </div>
          </div>
　　　　　　　　　　　　　　　　　　　　// ここまで
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, reactive, ref, toRefs } from 'vue' // reactiveとtoRefsを追加
import { getAccount, createQrCode, verifyOtpCode } from '@/api/user' // verifyOtpCodeを追加
import { User } from '@/types/user'
import router from '@/router' // 追加

export default defineComponent({
  name: 'Account',
  setup () {
    const account = ref({} as User)
    const qrCode = ref('')
    
    // ここから追加
    const formData = reactive({
      // eslint-disable-next-line @typescript-eslint/camelcase
      otp_code: ''
    })
    // ここまで

    const onGetAccount = async () => {
      await getAccount()
        .then((res) => {
          account.value = res.data
        })
    }

    const getQrCode = async () => {
      if (account.value.otp_required_for_login) {
        return ''
      } else {
        await createQrCode()
          .then((res) => {
            qrCode.value = res.data.otp_uri
          })
      }
    }

    // ここから追加
    const handleVerifyOtpCode = async () => {
      await verifyOtpCode(formData.otp_code)
        .then(() => {
          router.push('/')
          alert('2要素認証の設定に成功しました。')
        })
    }
    // ここまで

    onMounted(() => {
      onGetAccount()
    })

    return {
      account,
      getQrCode,
      qrCode,
      ...toRefs(formData), // 追加
      handleVerifyOtpCode  // 追加
    }
  }
})
</script>

```

認証コード入力用のフォームと、その入力された認証コードをパラメータとして、APIを叩く関数を呼び出すボタンを定義しています。

次に、src/api/user.tsを編集します。

```typescript
export const createQrCode = async () => {
  return await Client.post('/users/two_factor_auth', {}, { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<CurrentOtp>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err) => {
      return err.response
    })
}

// ここから追加
export const verifyOtpCode = async (otp_code: string) => {
  return await Client.post('/users/two_factor_auth', { otp_code }, { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<CurrentOtp>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err) => {
      return err.response
    })
}
// ここまで
```

認証コードをパラメーターに取ってRails側で定義した認証コードの検証を行うAPIを叩く関数を定義しています。

以下の添付画像のようにフォームとボタンが表示されていればOKです。

![スクリーンショット 2021-06-29 19.13.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/228005b0-46ce-f789-7cfd-088888bc5c5d.png)

これで準備完了です。一通り動作確認してみます。

## 動作確認

- /accountにアクセスし、「設定する」のボタンを押す。QRコードが表示されればOK

![スクリーンショット 2021-06-29 19.13.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/4bec0c35-6e94-af07-487a-2accb40af511.png)

- 通信内容を確認すると、two_factor_authのエンドポイントに向けてリクエストが走ったことがわかる

![スクリーンショット 2021-06-29 19.17.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/c57fa2a4-2f7d-dd60-b367-de6ac88c46b8.png)

- GoogleAuthenticatorでQRコードを読み取って、6桁のコードを入力し、認証に成功するとアラートが表示される

![スクリーンショット 2021-06-29 19.22.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/788df839-47fd-e5b1-ba8b-7d885fcec82f.png)


これで2要素認証の有効化までは実装できました。


# おわりに

簡易的な実装ですが、自前で2要素認証の有効化機能まで実装ができました。

次回は2要素ログインを実装します。(クソださデザインをなんとかしたくなってきた、、、)