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を作る(2要素認証設定編)](https://qiita.com/kk-icare/items/9bd25f906865ce6578bc)

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

# Rails側

## sessions_contorllerの修正

2要素認証を実現させるために、devise_token_authのsessions_controllerをオーバーライドする形を取りたいと思います。

https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/sessions_controller.rb

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

```terminal
$ mkdir app/controllers/devise_token_auth/

$ touch app/controllers/devise_token_auth/sessions_controller.rb
```

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

```ruby
module DeviseTokenAuth
  class SessionsController < DeviseTokenAuth::ApplicationController
    before_action :set_user_by_token, only: [:destroy]
    after_action :reset_session, only: [:destroy]

    def new
      render_new_error
    end

    def create
      # Check
      field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first

      @resource = nil
      if field
        q_value = get_case_insensitive_field_from_resource_params(field)

        @resource = find_resource(field, q_value)
      end

      if @resource && valid_params?(field, q_value) && (!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
        valid_password = @resource.valid_password?(resource_params[:password])
        if (@resource.respond_to?(:valid_for_authentication?) && !@resource.valid_for_authentication? { valid_password }) || !valid_password
          return render_create_error_bad_credentials
        end
        @token = @resource.create_token
        @resource.save

        sign_in(:user, @resource, store: false, bypass: false)

        yield @resource if block_given?

        render_create_success
      elsif @resource && !(!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
        if @resource.respond_to?(:locked_at) && @resource.locked_at
          render_create_error_account_locked
        else
          render_create_error_not_confirmed
        end
      else
        render_create_error_bad_credentials
      end
    end

    def destroy
      # remove auth instance variables so that after_action does not run
      user = remove_instance_variable(:@resource) if @resource
      client = @token.client
      @token.clear!

      if user && client && user.tokens[client]
        user.tokens.delete(client)
        user.save!

        yield user if block_given?

        render_destroy_success
      else
        render_destroy_error
      end
    end

    protected

    def valid_params?(key, val)
      resource_params[:password] && key && val
    end

    def get_auth_params
      auth_key = nil
      auth_val = nil

      # iterate thru allowed auth keys, use first found
      resource_class.authentication_keys.each do |k|
        if resource_params[k]
          auth_val = resource_params[k]
          auth_key = k
          break
        end
      end

      # honor devise configuration for case_insensitive_keys
      if resource_class.case_insensitive_keys.include?(auth_key)
        auth_val.downcase!
      end

      { key: auth_key, val: auth_val }
    end

    def render_new_error
      render_error(405, I18n.t('devise_token_auth.sessions.not_supported'))
    end

    def render_create_success
      render json: {
        data: resource_data(resource_json: @resource.token_validation_response)
      }
    end

    def render_create_error_not_confirmed
      render_error(401, I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email))
    end

    def render_create_error_account_locked
      render_error(401, I18n.t('devise.mailer.unlock_instructions.account_lock_msg'))
    end

    def render_create_error_bad_credentials
      render_error(401, I18n.t('devise_token_auth.sessions.bad_credentials'))
    end

    def render_destroy_success
      render json: {
        success:true
      }, status: 200
    end

    def render_destroy_error
      render_error(404, I18n.t('devise_token_auth.sessions.user_not_found'))
    end

    private

    def resource_params
      params.permit(*params_for_resource(:sign_in))
    end
  end
end
```

次に、オーバーライドしたcontrollerのアクションが呼ばれるようにroutingを修正します。

```ruby
# config/routes.rb

  mount_devise_token_auth_for 'User', at: 'auth', controllers: {
    sessions: 'devise_token_auth/sessions'
  }
```

次に、application_controller.rbでログイン時に許可するパラメータを絞る実装を行います。

```ruby
# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include DeviseTokenAuth::Concerns::SetUserByToken
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_in, keys: [:email, :password, :otp_code])
  end
end
```

次に、先ほど作成したsessions_controllerをゴリゴリ修正していきます。

```ruby
    def create
      # Check
      field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first

      @resource = nil
      if field
        q_value = get_case_insensitive_field_from_resource_params(field)

        @resource = find_resource(field, q_value)
      end

      if @resource && valid_params?(field, q_value) && (!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
        valid_password = @resource.valid_password?(resource_params[:password])
        if (@resource.respond_to?(:valid_for_authentication?) && !@resource.valid_for_authentication? { valid_password }) || !valid_password
          return render_create_error_bad_credentials
        end

        # 2要素認証機能が有効な場合
        if @resource.otp_required_for_login
          # FE側からotp_codeが送られていて、かつそのotp_codeで認証した結果、認証に成功した場合
          if resource_params[:otp_code].present? && @resource.validate_and_consume_otp!(resource_params[:otp_code])
            @token = @resource.create_token
            @resource.save

            sign_in(:user, @resource, store: false, bypass: false)

            yield @resource if block_given?

            return render_create_success
          else
            # FE側からotp_codeが送られていないか、認証に失敗した場合は、例外をレンダリング
            return render_create_error_need_to_two_factor_auth
          end
        end

        @token = @resource.create_token
        @resource.save

        sign_in(:user, @resource, store: false, bypass: false)

        yield @resource if block_given?

        render_create_success
      elsif @resource && !(!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
        if @resource.respond_to?(:locked_at) && @resource.locked_at
          render_create_error_account_locked
        else
          render_create_error_not_confirmed
        end
      else
        render_create_error_bad_credentials
      end
    end

    # もろもろ省略

    protected

    # 2要素認証が必要または2要素認証に失敗した場合にレンダリングされる
    def render_create_error_need_to_two_factor_auth
      render_error(401, I18n.t('devise_token_auth.sessions.bad_credentials'), { two_factor_auth: true })
    end

    private

    def resource_params
      params.permit(*params_for_resource(:sign_in))
    end
  end
end
```

render_create_error_need_to_two_factor_authは、第三引数に「2要素認証が有効化されている」ことを表すデータを入れています。

dataのtwo_factor_authがtrueだった場合は、FE側で2要素認証のコードを入力するフォームを表示するために使います。

## 返り値の修正

これで2要素認証そのものの実装は完了ですが、このままだと返り値に本来は含まれて欲しくない値まで返却されてしまうため、app/models/user.rbにログイン後の返り値を絞り込むメソッドを定義します。

```ruby
class User < ApplicationRecord
  TOKEN_VALIDATION_RESPONSE_ATTRS = [
    :otp_required_for_login,
    :id,
    :provider,
    :email,
    :uid,
    :name,
    :nickname,
    :image
  ]

  # 省略
  def token_validation_response
    self.as_json(only: TOKEN_VALIDATION_RESPONSE_ATTRS)
  end
end
```

DeviseTokenAuth::Concerns::Userをincludeすると定義されるtoken_validation_responseをオーバーライドしています。jsonとして返却したい値をsymbolを含んだ配列として定義し、as_jsonメソッドのonlyオプションに定数を渡すことで、定数に含まれているsymbolのattrのみが返却されるようにしています。


これでサーバーサイド側の準備は完了です。

# Vue.js側

## src/views/Login.vueの修正

ログインのAPIを叩いて、2要素認証が必要な場合は認証コードを表示して入力して表示できるところまで実装したいと思います。

以下のようにsrc/views/Login.vueを修正します。

```vue
# src/views/Login.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">
      Login
    </h2>
    <div v-if="isTwoFactorAuth" 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">
            OtpCode
          </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 OtpCode">
      </div>
      <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="handleLogin"
        >
          Login
        </button>
      </div>
    </div>
    <div v-else 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">
            Email
          </div>
        </div>
        <input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="email" type="email" placeholder="Enter your Email">
      </div>
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            Password
          </div>
        </div>
        <input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="password" type="password" placeholder="Enter your password">
      </div>
      <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="handleLogin"
        >
          Login
        </button>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
/* eslint-disable @typescript-eslint/camelcase */

import { defineComponent, reactive, ref, toRefs } from 'vue'
import { login } from '@/api/auth'
import router from '@/router'

export default defineComponent({
  name: 'Home',
  setup () {
    const formData = reactive({
      email: '',
      password: '',
      otp_code: ''
    })

    const isTwoFactorAuth = ref(false)

    const handleLogin = async () => {
      await login(formData.email, formData.password, formData.otp_code)
        .then((res) => {
          if (res?.data.two_factor_auth) {
            isTwoFactorAuth.value = true
          } else {
            if (res?.status === 200) {
              router.push('/')
            } else {
              alert('メールアドレスかパスワードが間違っています。')
            }
          }
        })
        .catch(() => {
          alert('ログインに失敗しました。')
        })
    }

    return {
      ...toRefs(formData),
      isTwoFactorAuth,
      handleLogin
    }
  }
})
</script>
```

requiredTwoFactorAuthをリアクティブな値として定義し、ログインのAPIを叩いた結果、two_factor_authがtrue(= 2要素認証が有効になっている場合)の場合は、requiredTwoFactorAuthのvalueをtrueにし、template側で認証コードの入力フォームを表示するようにしています。

## 認証を行う関数の修正

合わせて、src/api/auth.tsのlogin関数の引数を修正します。

```typescript
export const login = async (email: string, password: string, otp_code: string) => {
  return await Client.post('/auth/sign_in', { email, password, otp_code })
    .then((res: AxiosResponse<User>) => {
      setAuthDataFromResponse(res.headers)
      return res
    })
    .catch((err: AxiosError) => {
      return err.response
    })
}
```

新たなパラメータとしてotp_codeを受け取れるように修正しています。

これで準備完了です。

# 動作確認

すでに2要素認証が有効になっているユーザーで検証してみます。

ログインページへアクセス

![スクリーンショット 2021-07-07 17.45.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/346d9a77-396a-1ad4-f980-fc58b478532f.png)

Email & passwordが正しい場合、認証コードが入力できるフォームが表示される

![スクリーンショット 2021-07-07 17.46.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/5a1ef112-293c-f0b6-0397-ba06a97a3722.png)

入力した認証コードが正しい場合、トップ画面に遷移する。

返り値は以下のような値になります。

![スクリーンショット 2021-07-07 17.48.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/8a28568d-780f-9046-7c7f-b396e1ecdcd9.png)

また、LocalStorageにもaccess-token等が格納されている

![スクリーンショット 2021-07-07 17.50.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/55869f77-9109-ca65-1c7b-caa24ed64db5.png)

これで動作確認完了です。

# おわりに

エラーハンドリングやフォームのバリデーション等を全く考慮できていませんが、最低限の実装ができました。

次はprovide/injectを使って認証状態を管理し、NavBarのボタンの出し分けを実装します。