Nuxt.js + GraphQL + Ruby on Railsで作ったアプリにJWT認証を追加する方法

本記事ではフロントエンドに **Nuxt.js(Vue.js)**、バックエンドに **Ruby on Rails** 、APIに **GraphQL** を採用したアプリケーションに、**JWTトークンによる認証** を追加する方法についてまとめます。

![login.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/7afbfc6d-b607-bf00-a85c-b7568d0c3a0d.gif)


## 題材

サンプルとして以下チュートリアルで作成したToDoリストを使用します。

[Nuxt.js + GraphQL + Ruby on Railsで作るToDoアプリチュートリアル(前編)](https://tech.fusic.co.jp/posts/2019-08-23-rails-nuxt-graphql-todoapp)
[Nuxt.js + GraphQL + Ruby on Railsで作るToDoアプリチュートリアル(後編)](https://tech.fusic.co.jp/posts/2019-08-24-2019-08-25-rails-nuxt-graphql-todoapp/)

## JWTでの認証フロー

今回実装するJWTでの認証フローを図にまとめました。

![Untitled_graphql-jwt_-_Cacoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/9ff6e9f6-93f2-ee0e-02fc-ce63b0e58976.png)

トークンの発行・保存・付与・検証がめんどくさそうに見えるかもしれませんが、フロントエンド側は[Auth Module](https://auth.nuxtjs.org/)が、バックエンド側は[knock](https://github.com/nsarno/knock)がトークンをいい感じに処理してくれるので、安心してください。

## 実装(バックエンド)

### ユーザモデルを定義する

認証単位となるモデル(User)を生成します。

```bash
$ bundle exec rails g model user email:string password_digest:string
$ bundle exec rails db:migrate
```

Userはパスワードをハッシュ化して管理するので、モデルに `has_secure_password` を宣言します。

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_secure_password
end
```

また、Gemfileの `bcrypt` のコメントを外します。

```ruby:Gemfile
# ・・・中略・・・

# Use Active Model has_secure_password
gem 'bcrypt', '~> 3.1.7

# ・・・中略・・・
```

テスト用のUserを `seed.rb` に追記します。

```ruby:db/seeds.rb
# ・・・中略・・・

User.create(
  email: 'test@example.com',
  password: 'xxxxxxxx',
  password_confirmation: 'xxxxxxxx'
)
```

```bash
$ bundle exec rails db:seed
```

### knockをインストールする

Gemfileにknockを追加し、インストールします。

```ruby:Gemfile
gem 'rack-cors'
gem 'graphql'
gem 'knock' # ★追加
```

```bash
$ bundle install
```

[Rails6だとknockのautoloadに失敗する](https://qiita.com/Y_uuu/items/6963ac6ee10af94ac3c0)のでinitializerで明示的にrequireします。

```ruby:config/initializers/eager_load_knock.rb
require 'knock/version'
require 'knock/authenticable'
```

ジェネレータを実行します。

```bash
$ rails generate knock:install
```

### ログイン/ログアウトのエンドポイントを準備

ジェネレータでControllerを生成します。

```bash
$ rails generate knock:token_controller user
```

各Controllerにて認証処理を行えるようにするため、 `ApplicationController` にてmoduleをincludeします。

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include Knock::Authenticable
end
```

`GraphqlController` のbefore_actionとして認証処理を追加します。

```ruby:app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  before_action :authenticate_user

  # ・・・中略・・・
end
```

今回、セッションは使わないのとCORS設定済みであることを考慮して、CSRF対策を解除します。

```ruby:config/application.rb
# ・・・中略・・・

module RailsNuxtGrapshqlTodoapp
  class Application < Rails::Application
    config.load_defaults 6.0
    config.api_only = true
    config.action_controller.default_protect_from_forgery = false 
  end
end

```

## 動作確認(バックエンド)

サーバ起動して[Insomnia](https://insomnia.rest/)を使ってリクエストを送信してみます。

```bash
$ bundle exec rails s
```

未認証の状態でGraphQL Queryを送信しても、`401` が返ってきます。

<img width="1440" alt="Insomnia__onescene__–_tasks.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/63b16840-93fb-f49c-c01d-a725cc82459e.png">

`/user_token` へリクエストすると、JWTトークンが返ってきます。

<img width="1440" alt="Insomnia__onescene__–_user_token.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/7176e7e9-a7ed-6f33-82d2-ea1df1b9e959.png">

Bearerの設定でTOKEN欄に上記JWTトークンを記載します。

<img width="1440" alt="Insomnia__onescene__–_tasks.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/6cebc82e-3595-f5a8-4aba-6800f9a395dd.png">

この状態で再度GraphQL Queryを送信すると、 `200` が返ってきました。
JWT認証が機能していますね。

<img width="1440" alt="Insomnia__onescene__–_tasks.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/4909824f-a237-da60-bb3c-3279d577f4f2.png">


## 実装(フロントエンド)

### Auth Moduleをインストールする

npmでインストールします。
Auth Moduleは `store/index.js` が存在していないとエラーを出すので、空のファイルを作成しておきます。

```bash
$ npm install @nuxtjs/auth @nuxtjs/axios
$ touch store/index.js
```

`nuxt.config.js` の `modules`, `axios`, `auth`, `apollo` を追記します。

```js
  // ・・・中略・・・
  modules: [
    '@nuxtjs/vuetify',
    '@nuxtjs/pwa',
    '@nuxtjs/eslint-module',
    '@nuxtjs/apollo',
    '@nuxtjs/axios',
    '@nuxtjs/auth'
  ],
  axios: {
    baseURL: 'http://localhost:3000/'
  },
  auth: {
    strategies: {
      local: {
        endpoints: {
          login: { url: 'user_token', method: 'post', propertyName: 'jwt' },
          user: false,
          logout: false
        }
      }
    }
  },
  // ・・・中略・・・
  apollo: {
    clientConfigs: {
      default: {
        httpEndpoint: 'http://localhost:3000/graphql',
        getAuth: () => ''
      }
    }
  }
  // ・・・中略・・・
```

### ログイン画面を準備


<img width="1440" alt="rails_nuxt_grapshql_todoapp_front_-_rails_nuxt_grapshql_todoapp_front.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/c65f3d22-2080-51e0-714e-4f8597cc1aa0.png">

`/login` に相当する画面およびログイン処理を実装します。

```vue:pages/login.vue
<template>
  <v-container>
    <v-row>
      <v-col cols="6" offset="3">
        <v-card>
          <v-card-title>Login</v-card-title>
          <v-card-text>
            <v-form>
              <v-container>
                <v-row>
                  <v-col cols="6">
                    <v-text-field v-model="email" label="Email" required />
                  </v-col>
                </v-row>
                <v-row>
                  <v-col cols="6">
                    <v-text-field v-model="password" type="password" label="Password" required />
                  </v-col>
                </v-row>
                <v-row>
                  <v-col>
                    <v-btn @click="login()">
                      Login
                    </v-btn>
                  </v-col>
                </v-row>
              </v-container>
            </v-form>
          </v-card-text>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
export default {
  middleware({ store, redirect }) {
    window.console.log(store.state.auth.loggedIn)
    if (store.state.auth.loggedIn) {
      return redirect('/')
    }
  },
  data() {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    async login() {
      try {
        await this.$auth.loginWith('local', {
          data: {
            auth: {
              email: this.email,
              password: this.password
            }
          }
        })
        await this.$apolloHelpers.onLogin(this.$auth.getToken('local').match(/^Bearer[ ]+([^ ]+)[ ]*$/i)[1])
        this.$router.push('/')
      } catch (e) {
        window.console.log(e)
      }
    }
  }
}
</script>
```

無名middlewareを用いることで、既にlogin済みの状態でこのページを開くと、'/' でリダイレクトするようにしています。

methodsの `login()` がLOGINボタンを押したときの処理です。
Auth Moduleでログインをした後で、Apollo ModuleへJWTトークンをセットしています。

### ログアウトボタンを追加


<img width="1440" alt="rails_nuxt_grapshql_todoapp_front_-_rails_nuxt_grapshql_todoapp_front.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/60c2794e-cc6b-0bd5-0259-1344f4b38ccb.png">

ログイン中の場合のみ、メニューバーにログアウトボタンを表示します。

```vue:layouts/default.vue
<template>
  <v-app>
    <v-app-bar app>
      <v-toolbar-title v-text="title" />
      <div class="flex-grow-1" />
      <span v-if="loggedIn" @click="logout()">Logout</span>
    </v-app-bar>
    <v-content>
      <nuxt />
    </v-content>
    <v-footer center>
      <v-layout justify-center>
        <span>&copy; 2019 Yuhei Okazaki. All Rights Reserved.</span>
      </v-layout>
    </v-footer>
  </v-app>
</template>

<script>
export default {
  data() {
    return {
      title: 'Tasks'
    }
  },
  computed: {
    loggedIn() {
      return this.$auth.loggedIn
    }
  },
  methods: {
    async logout() {
      try {
        await this.$auth.logout()
        await this.$apolloHelpers.onLogout()
        this.$router.push('/login')
      } catch (e) {
        window.console.log(e)
      }
    }
  }
}
</script>
```

methodsの `logout()` がLOGOUTを押したときの処理です。
Auth Moduleでログアウトをした後で、Apollo Moduleもログアウトしています。

### 未認証時のリダイレクトを追加

このままだと、ログインしていない状態でもタスク一覧画面を開けてしまうので、ログインしていないときには `/login` へ飛ばすようmiddlewareを設定します。

```vue:pages/index.vue
// 中略
export default {
  middleware: 'auth'
  // 中略
}
```

## 動作確認(全体)

冒頭の画像のように、ログインしたときのみタスク一覧が表示されます。

![login.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/77987656-90bf-b3b1-24b2-3e88cb015ee2.gif)

## まとめ

本記事ではバックエンドにRuby on Rails、フロントエンドにNuxt.js、APIにGraphQLを採用したアプリケーションにJWT認証を追加しました。
新規登録画面やタスクとユーザの紐付け等、未実装の処理は多数あるものの、認証というアプリケーションを実装するときの最初の壁は越えられたかと思います。

knockやAuth Module、Apollo Moduleを用いたことで、トークン操作を意識せず簡単に認証追加できたので、ぜひお試しください。