AuthModule+Devise+Devise_token_authを使用した認証機能

## はじめに
Nuxt.js Rails Docker postgresqlで新規プロジェクトがあったのでその認証機能の備忘録

## 環境
macOS

## 前提条件
docker-compose upでfornt,apiのコンテナが立ち上がり
`localhost:8080`（Nuxt）と`localhost:3000`(Rails)でウェルカムページが表示されていること
環境構築がまだの方は[こちら](https://qiita.com/Bezzi05791520/items/1c42d1faf03eec62dc8e)を参考にどうぞ

## この記事を読んでできること
`devise`/`devise_token_auth`/`authModule`を使用したログイン/ログアウト/新規登録の実装
*細かいコードの説明はしていません

## ログインの流れ
* front側でメールアドレス/パスワードを入力してHTTPリクエストをapi側へ送信
* api側でHTTPリクエストを受け取り`devise_token_auth`にて認証する
* 認証正常：ヘッダー情報をfront側へ返す<br>認証失敗：エラーメッセージをfornt側へ返す
* front側でapi側から返ってきたヘッダー情報を`localStorage`へ保存する
* フラッシュメッセージで 「ログインに成功しました」と表示する

## ログアウトの流れ
* localStorageに保存されたヘッダー情報を乗せてapi側にHTTPリクエストを送る
* api側からレスポンスを受け取り`localStorage`内のヘッダー情報の削除を行う
* フラッシュメッセージで「ログアウトしました」と表示する

## 新規登録の流れ
* メールアドレス/パスワード/パスワード再確認を入力してHTTPリクエストをapi側へ送信
* 登録正常:正常レスポンスをfront側へ返す<br>登録異常:エラーメッセージをfront側へ返す

*今回は新規登録後にログイン状態にしたい為、新規登録とログインを合わせて行います

## ページデザイン
今回はvuetifyを使用し下記画像のようなデザインにします。
※背景が黒になっている方は`nuxt.config.js`の55行目付近の`dark`をfalseに変更してください

* ログイン画面
![スクリーンショット 2021-10-18 21.45.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/67c8994e-24e0-a1ea-76ea-2a5e74380a98.png)

* 新規登録画面
![スクリーンショット 2021-10-18 21.46.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/9bc9a2fa-98e3-802e-2db4-e8e068c2e7e3.png)

## front側

```front/layout/default.vue
<template>
  <v-app>
    <v-navigation-drawer
      v-model="drawer"
      :mini-variant="miniVariant"
      :clipped="clipped"
      fixed
      app
    >
      <v-list>
        <v-list-item
          v-for="(item, i) in items"
          :key="i"
          :to="item.to"
          router
          exact
        >
          <v-list-item-content>
            <v-list-item-title v-text="item.title" />
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar
      :clipped-left="clipped"
      fixed
      app
    >
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <v-toolbar-title v-text="title" />
      <v-spacer />
      <span v-if="$auth.loggedIn">
        <v-btn
          color="error"
          dark
          @click="logout"
        >
          ログアウト
        </v-btn>
      </span>
    </v-app-bar>
    <v-main>
      <v-container>
        <FlashMessage />
        <Nuxt />
      </v-container>
    </v-main>
    <v-navigation-drawer
      v-model="rightDrawer"
      :right="right"
      temporary
      fixed
    >
      <v-list>
        <v-list-item @click.native="right = !right">
          <v-list-item-action>
            <v-icon light>
              mdi-repeat
            </v-icon>
          </v-list-item-action>
          <v-list-item-title>Switch drawer (click me)</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-footer
      :absolute="!fixed"
      app
    >
      <span>&copy; {{ new Date().getFullYear() }}</span>
    </v-footer>
  </v-app>
</template>

<script>
export default {
  data () {
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      items: [
        {
          title: 'ログイン',
          to: '/'
        },
        {
          title: '新規登録',
          to: '/sign_up'
        }
      ],
      miniVariant: false,
      right: true,
      rightDrawer: false,
      title: '認証機能'
    }
  },
  methods: {
    logout () {
      this.$axios.delete('/api/v1/auth/sign_out', {
        headers: {
          uid: localStorage.getItem('uid'),
          'access-token': localStorage.getItem('access-token'),
          client: localStorage.getItem('client')
        }
      })
        .then((res) => {
          this.$auth.logout()
          localStorage.removeItem('uid')
          localStorage.removeItem('access-token')
          localStorage.removeItem('client')
          this.$router.push('/')
          this.$store.dispatch(
            'flashMessage/showMessage',
            {
              message: 'ログアウトしました',
              type: 'success',
              status: true
            },
            { root: true }
          )
          this.$store.commit('user_information/logout')
        })
    }
  }
}
</script>

```

## ログインページ
```fornt/pages/index.vue
<template>
  <v-main>
    <v-container>
      <v-row justify="center" align-content="center" class="text-caption">
        <v-col cols="8">
          <v-card>
            <v-card-title>
              ログイン
            </v-card-title>
            <Notification v-if="errors" :messages="errors" />
            <v-card-text>
              <v-form>
                <v-text-field
                  v-model="email"
                  prepend-icon="mdi-account-circle"
                  label="メールアドレス"
                />
                <v-text-field
                  v-model="password"
                  :type="showPassword ? 'text' : 'password'"
                  :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
                  prepend-icon="mdi-lock"
                  label="パスワード"
                  @click:append="showPassword = !showPassword"
                />
              </v-form>
              <v-card-actions>
                <v-btn
                  color="info"
                  block
                  @click="loginWithAuthModule"
                >
                  ログイン
                </v-btn>
              </v-card-actions>
              <v-layout justify-right>
                <v-card-actions>
                  <nuxt-link to="/sign_up">
                    会員登録がまだの方はこちら
                  </nuxt-link>
                </v-card-actions>
              </v-layout>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-main>
</template>

<script>
export default {
  data () {
    return {
      message: '新規登録です',
      showPassword: false,
      email: '',
      password: '',
      errors: null,
      user: {}
    }
  },
  methods: {
    // loginメソッドの呼び出し
    async loginWithAuthModule () {
      await this.$auth
        .loginWith('local', {
          // emailとpasswordの情報を送信
          data: {
            email: this.email,
            password: this.password
          }
        })
        .then(
          (response) => {
          // 認証に必要な情報をlocalStorageに保存
            localStorage.setItem('access-token', response.headers['access-token'])
            localStorage.setItem('client', response.headers.client)
            localStorage.setItem('uid', response.headers.uid)
            localStorage.setItem('token-type', response.headers['token-type'])
            this.$router.push('/')
            this.$store.dispatch(
              'flashMessage/showMessage',
              {
                message: 'ログインしました.',
                type: 'success',
                status: true
              },
              { root: true }
            )
            this.user = response.data.data
            this.$store.dispatch('user_information/setUser', this.user)
            return response
          }
        )
        .catch((e) => {
          this.errors = e.response.data.errors
        })
    },
    authenticate () {
      this.$auth.loginWith('app')
    }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
}
.v-divider {
  margin: 30px;
  border-width:medium;
}
.v-icon {
  float:left;
}
</style>

```
## 新規登録
sign_up.vueファイルの作成

```
sample $ cd front

front $ touch pages/sign_up.vue
```

```front/pages/sign_up.vue
<template>
  <v-main>
    <v-container>
      <v-row justify="center" align-content="center" class="text-caption">
        <v-col cols="8">
          <v-card>
            <v-card-title>
              新規登録
            </v-card-title>
            <Notification v-if="errors" :messages="errors" />
            <v-card-text>
              <v-form>
                <v-text-field
                  v-model="user.email"
                  prepend-icon="mdi-account-circle"
                  label="メールアドレス"
                />
                <v-text-field
                  v-model="user.password"
                  :type="showPassword ? 'text' : 'password'"
                  :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
                  prepend-icon="mdi-lock"
                  label="パスワード"
                  @click:append="showPassword = !showPassword"
                />
                <v-text-field
                  v-model="user.password_confirmation"
                  :type="showPassword ? 'text' : 'password'"
                  :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
                  prepend-icon="mdi-lock"
                  label="パスワード再確認"
                  @click:append="showPassword = !showPassword"
                />
              </v-form>
              <v-card-actions>
                <v-btn
                  color="info"
                  block
                  @click="registerUser"
                >
                  新規登録
                </v-btn>
              </v-card-actions>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-main>
</template>

<script>
import Notification from '../components/Notification.vue'
export default {
  components: { Notification },
  auth: false,
  data () {
    return {
      showPassword: false,
      errors: null,
      user: {
        email: '',
        password: '',
        password_confirmation: '',
        host: 0,
        host_name: false
      }
    }
  },
  methods: {
    registerUser () {
      this.$axios.post('/api/v1/auth', this.user)
        .then((response) => {
          this.$router.push('/')
          this.$store.dispatch(
            'flashMessage/showMessage',
            {
              message: '新規登録しました',
              type: 'success',
              status: true
            },
            { root: true }
          )
          this.loginWithAuthModule()
        })
        .catch((e) => {
          this.errors = e.response.data.errors.full_messages
        })
    },
    loginWithAuthModule () {
      this.$auth
        .loginWith('local', {
          // emailとpasswordの情報を送信
          data: {
            email: this.user.email,
            password: this.user.password
          }
        })
        .then(
          (response) => {
            // レスポンスで返ってきた、認証に必要な情報をlocalStorageに保存
            localStorage.setItem('access-token', response.headers['access-token'])
            localStorage.setItem('client', response.headers.client)
            localStorage.setItem('uid', response.headers.uid)
            localStorage.setItem('token-type', response.headers['token-type'])
            this.user = response.data
            this.$store.commit('user_information/login', this.user)
            return response
          }
        )
    }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
}
.v-divider {
  margin: 30px;
  border-width:medium;
}
.v-icon {
  float:left;
}
</style>


```

## コンポーネントの追加

フラッシュメッセージ用

```
front $ touch conponents/FlashMessage.vue
```

```components/FlashMessage.vue
<template>
  <v-snackbar
    v-model="status"
    transition="slide-x-reverse-transition"
    right
    top
    :color="type"
  >
    <div class="ml-5 font-weight-bold white--text">
      {{ message }}
    </div>
  </v-snackbar>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters({
      message: 'flashMessage/message',
      type: 'flashMessage/type',
      status: 'flashMessage/status'
    })
  }
}
</script>

```
エラーメッセージ用

```
front $ touch conponents/Notification.vue
```

```components/Notification.vue
<template>
  <v-alert
    type="error"
  >
    <span v-for="m in messages" :key="m.id">
      <span>{{ m }}</span><br>
    </span>
  </v-alert>
</template>

<script>
export default {
  name: 'Notification',
  props: {
    messages: {
      type: Array,
      reauired: false,
      default: () => {}
    }
  }
}
</script>

```

## authModuleの追加

```
sample $ docker-compose run front yarn add @nuxtjs/auth
```
インストール完了後、`nuxt.config.js`に追加

```nuxt.config.js
 modules: [
    '@nuxtjs/axios',
    '@nuxtjs/auth' //追加
  ],
```

## authModuleオプション追加
```front/nuxt.config.js
// 追加
 auth: {
    redirect: {
      login: '/',
      logout: '/',
      callback: false,
      home: '/'
    },
    strategies: {
      local: {
        endpoints: {
          login: { url: '/api/v1/auth/sign_in', method: 'post', propertyName: 'token' },
          logout: false,
          callback: false,
          user: false
        }
      }
    }
  }
```


## vuex用のファイルの作成
AuthModuleはVuexを使用して、ユーザの認証情報を管理します。
`front`/`store`ディレクトリ内に`user_information.js`を作成します。

```
front $ touch store/user_information.js
```

```user_information.js
export const state = () => ({
  user: 'null'
})

export const mutations = {
  login (state, payload) {
    state.user = payload
  },
  logout (state) {
    state.user = null
  }
}

export const actions = {
  setUser (context, user) {
    context.commit('login', user)
  }
}

export const getters = {
  getUser: state => state.user
}

```

フラッシュメッセージ用ファイルの作成

```
front $ touch store/flashMessage.js
```

```flashMessage.js
export const state = () => ({
  message: '',
  type: '',
  status: false
})

export const getters = {
  message: state => state.message,
  type: state => state.type,
  status: state => state.status
}

export const mutations = {
  setMessage (state, message) {
    state.message = message
  },
  setType (state, type) {
    state.type = type
  },
  setStatus (state, bool) {
    state.status = bool
  }
}

export const actions = {
  showMessage ({ commit }, { message, type, status }) {
    commit('setMessage', message)
    commit('setType', type)
    commit('setStatus', status)
    setTimeout(() => {
      commit('setStatus', !status)
    }, 3000)
  }
}

```

## api側
フロント側はある程度、デザインが整いましたね
では、続いてRails側をやっていきましょう

```ruby:gemfile
# ログイン機能 
gem 'devise' # 追加
gem 'devise_token_auth' # 追加
gem 'devise-i18n',        '1.9.4' # 追加

# CORS設定
gem 'rack-cors' # 追加
```

## イメージ作成
`gemfile`を書き換えたので一度buildを行います

```
sample $ docker-compose build
```

## `devise`/`devise_token_auth`関連ファイル生成

```
sample $ docker-compose run api bundle exec rails g devise:install

sample $ docker-compose run api bundle exec rails g devise_token_auth:install User auth

sample $ docker-compose run api bundle exec rails db:migrate

```

## devise_token_authの設定
セットアップ項目がコメントアウトされているので必要箇所のコメントアウトを外す

```config/initializers/devise_token_auth.rb
# 8行目付近
config.change_headers_on_each_request = false

# 12行目付近
config.token_lifespan = 2.weeks

# 45行目付近
config.headers_names = {:'access-token' => 'access-token',
                          :'client' => 'client',
                          :'expiry' => 'expiry',
                          :'uid' => 'uid',
                          :'token-type' => 'token-type' }

```

## devise日本語化

```config/application.rb
module App
  class Application < Rails::Application
    config.load_defaults 6.0

    config.i18n.default_locale = :ja # 追加
    config.api_only = true
  end
en
```
ja.ymlファイル追加

```
api $ touch config/locales/ja.yml
```

```config/locales/ja.yml
ja:
  activerecord:
    errors:
      messages:
        record_invalid: "バリデーションに失敗しました: %{errors}"
        restrict_dependent_destroy:
          has_one: "%{record}が存在しているので削除できません"
          has_many: "%{record}が存在しているので削除できません"
  date:
    abbr_day_names:
      - 日
      - 月
      - 火
      - 水
      - 木
      - 金
      - 土
    abbr_month_names:
      -
      - 1月
      - 2月
      - 3月
      - 4月
      - 5月
      - 6月
      - 7月
      - 8月
      - 9月
      - 10月
      - 11月
      - 12月
    day_names:
      - 日曜日
      - 月曜日
      - 火曜日
      - 水曜日
      - 木曜日
      - 金曜日
      - 土曜日
    formats:
      default: "%Y/%m/%d"
      long: "%Y年%m月%d日(%a)"
      short: "%m/%d"
    month_names:
      -
      - 1月
      - 2月
      - 3月
      - 4月
      - 5月
      - 6月
      - 7月
      - 8月
      - 9月
      - 10月
      - 11月
      - 12月
    order:
      - :year
      - :month
      - :day
  datetime:
    distance_in_words:
      about_x_hours:
        one: 約1時間
        other: 約%{count}時間
      about_x_months:
        one: 約1ヶ月
        other: 約%{count}ヶ月
      about_x_years:
        one: 約1年
        other: 約%{count}年
      almost_x_years:
        one: 1年弱
        other: "%{count}年弱"
      half_a_minute: 30秒前後
      less_than_x_seconds:
        one: 1秒以内
        other: "%{count}秒未満"
      less_than_x_minutes:
        one: 1分以内
        other: "%{count}分未満"
      over_x_years:
        one: 1年以上
        other: "%{count}年以上"
      x_seconds:
        one: 1秒
        other: "%{count}秒"
      x_minutes:
        one: 1分
        other: "%{count}分"
      x_days:
        one: 1日
        other: "%{count}日"
      x_months:
        one: 1ヶ月
        other: "%{count}ヶ月"
      x_years:
        one: 1年
        other: "%{count}年"
    prompts:
      second: 秒
      minute: 分
      hour: 時
      day: 日
      month: 月
      year: 年
  errors:
    format: "%{attribute}%{message}"
    messages:
      accepted: を受諾してください
      blank: を入力してください
      confirmation: と%{attribute}の入力が一致しません
      empty: を入力してください
      equal_to: は%{count}にしてください
      even: は偶数にしてください
      exclusion: は予約されています
      greater_than: は%{count}より大きい値にしてください
      greater_than_or_equal_to: は%{count}以上の値にしてください
      inclusion: は一覧にありません
      invalid: は不正な値です
      less_than: は%{count}より小さい値にしてください
      less_than_or_equal_to: は%{count}以下の値にしてください
      model_invalid: "バリデーションに失敗しました: %{errors}"
      not_a_number: は数値で入力してください
      not_an_integer: は整数で入力してください
      odd: は奇数にしてください
      other_than: は%{count}以外の値にしてください
      present: は入力しないでください
      required: を入力してください
      taken: はすでに存在します
      too_long: は%{count}文字以内で入力してください
      too_short: は%{count}文字以上で入力してください
      wrong_length: は%{count}文字で入力してください
    template:
      body: 次の項目を確認してください
      header:
        one: "%{model}にエラーが発生しました"
        other: "%{model}に%{count}個のエラーが発生しました"
  helpers:
    select:
      prompt: 選択してください
    submit:
      create: 登録する
      submit: 保存する
      update: 更新する
  number:
    currency:
      format:
        delimiter: ","
        format: "%n%u"
        precision: 0
        separator: "."
        significant: false
        strip_insignificant_zeros: false
        unit: 円
    format:
      delimiter: ","
      precision: 3
      separator: "."
      significant: false
      strip_insignificant_zeros: false
    human:
      decimal_units:
        format: "%n %u"
        units:
          billion: 十億
          million: 百万
          quadrillion: 千兆
          thousand: 千
          trillion: 兆
          unit: ""
      format:
        delimiter: ""
        precision: 3
        significant: true
        strip_insignificant_zeros: true
      storage_units:
        format: "%n%u"
        units:
          byte: バイト
          eb: EB
          gb: GB
          kb: KB
          mb: MB
          pb: PB
          tb: TB
    percentage:
      format:
        delimiter: ""
        format: "%n%"
    precision:
      format:
        delimiter: ""
  support:
    array:
      last_word_connector: "、"
      two_words_connector: "、"
      words_connector: "、"
  time:
    am: 午前
    formats:
      default: "%Y年%m月%d日(%a) %H時%M分%S秒 %z"
      long: "%Y/%m/%d %H:%M"
      short: "%m/%d %H:%M"
    pm: 午後

```




## CSRFチェック
現在のままだとapiリクエストでCSRFチェックに引っかかるので`application_controller`と`cors.rb`を編集

```application_controller.rb
class ApplicationController < ActionController::API
        include DeviseTokenAuth::Concerns::SetUserByToken
        skip_before_action :verify_authenticity_token, if: :devise_controller?, raise: false

end
```

```initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV["API_DOMAIN"] || "localhost:8080"

    resource '*',
      headers: :any,
      expose: ['access-token', 'uid', 'client', 'token-type'], #この行を新たに追加
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
```


## ルーティング設定

```routes.rb
Rails.application.routes.draw do
  devise_for :users

  
  namespace :api do
    scope :v1 do
      mount_devise_token_auth_for 'User', at: 'auth'
    end
  end
end

```

## seeds.rb編集
ログインできるように予めユーザをデータベースに登録します。

```db/seeds.rb
User.create(email: 'admin@example.com',
            password: 'password')
```

seedsファイルを反映させる

```
sample $ docker-compose run api bundle exec rails db:migrate:reset

sample $ docker-compose run api bundle exec rails db:migrate

sample $ docker-compose run api bundle exec rails db:seed
```

## リクエストテスト
さあ　それでは実際にリクエストテストを行っていきましょう
今回はAdvanced REST client(ARC)を使用してテストを行います。
インストールがまだの方は[こちら](https://itsakura.com/tool-arc-json)を参考にどうぞ


ログイン
* method: post
* URL: http://localhost:3000/api/v1/auth/sign_in
* Body: {"email":"admin@example.com", "password":"password"}

![スクリーンショット 2021-10-20 21.17.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/17ef8220-44eb-188c-9d00-59761bd519a8.png)

上記項目を選択/入力して`send`ボタンを押すと成功の場合、レスポンスが返って来ます。
*レスポンス情報のDETAILSを押すとResponse headersを確認できます


![スクリーンショット 2021-10-20 21.22.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/792ddf0c-69d7-2b02-88cc-e2b070f8fb96.png)

ログアウト
* method: delete
* URL: http://localhost:3000/api/v1/auth/sign_out
* Body: ログインテスト時に取得したResponse headersの`uid`/`access-token`/`client`情報を乗せる
参考
![スクリーンショット 2021-10-20 21.34.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/8450f953-bacc-8b9d-f375-c34571977fba.png)

## 実際に新規登録/ログイン/ログアウトをしてみよう

```
sample $ docker-compose up
```

コンテナ起動後[localhost:8080](http://localhost:8080)へアクセス

## ログイン
###　成功の場合
* メールアドレス/パスワードを入力してログインボタンを押す
* 画面右側にフラッシュメッセージで`ログインしました`と表示される
* 画面右側上部にログアウトボタンが表示される

![スクリーンショット 2021-10-21 21.30.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/328522e5-a297-94a8-dd0a-a7d54fa1e01a.png)

### 失敗の場合
* エラーメッセージが表示される
![スクリーンショット 2021-10-21 21.35.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/39297d41-29a1-feb7-79f7-766f5e137dbb.png)

## ログアウト
* 画面右上のログアウトボタンを押す
* 画面右側にフラッシュメッセージで`ログアウトしました`と表示される
![スクリーンショット 2021-10-21 21.37.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/3e70644a-ea28-af19-68aa-da5b00e0cb81.png)

## 新規登録
###　成功の場合
* 会員登録がまだの方はこちらをクリックし新規登録画面へ遷移する
* 必要な情報を入力し、新規登録ボタンを押す
* 画面右側にフラッシュメッセージで`新規登録しました`と表示されログイン画面へ遷移する
* ![スクリーンショット 2021-10-21 21.40.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/87ff0ad4-c90b-9d8e-b593-70d815b136cc.png)

### 失敗の場合
* エラー内容がフォーム上部へ表示されます
![スクリーンショット 2021-10-21 21.42.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1986536/0712d1f0-b41a-411a-92f8-2d8fcadb4a32.png)


お疲れ様でした