Rails 5.1 API mode + webpacker + react + reactstrap な ToDO アプリに認証機能を追加する (sorcery gem で JWT)

[Rails 5.1 API mode + webpacker + react + reactstrap で ToDO アプリを書く](https://qiita.com/kaishuu0123/items/00b89e092f156a02a3e5) の続きです

sorcery を使って JWT 認証を実現する方法がいまいちまとまっていなかったので、自分用にメモしておきます

# Github Repository & commit log

Rails 側: [Github commit log](https://github.com/kaishuu0123/rails5.1-react-reactstrap-example/commit/48943201d66223af1cd7b161a9d962a15438e9a9)
React 側: [Github commit log](https://github.com/kaishuu0123/rails5.1-react-reactstrap-example/commit/5c47f618477b7986c516d1ccca7978db5be2b409)

# デモ

![auth_example_rails5.1_and_react_reactstrap.gif](https://qiita-image-store.s3.amazonaws.com/0/19717/65f24e16-635c-58d6-6448-d254b21d88ee.gif)

デモ内容は以下

1. 認証の制限が**ない**ページでの Task 追加
2. 認証の制限が**ある**ページでエラーメッセージ表示 (下記、2つとも 401 が返ること)
    * タスク取得
    * タスク作成
3. `demo@example.com` アカウントでログインし、認証の制限が**ある**ページでタスク取得/タスク追加ができる

# Rails API 改修内容
## sorcery gem のインストール & sorcery:install の実行 & `db:migrate` 実行

https://github.com/Sorcery/sorcery#installation あたりを参考に。

Gemfile に追記

```diff:Gemfile
+gem 'sorcery', '~> 0.11.0'
```

sorcery:install を実行

```shell
$ ./bin/rails generate sorcery:install
```

`db:migrate` を実行して、sorcery が作成した migration を反映します

```shell
$ ./bin/rails db:migrate
```

## User モデルの改修

下記のように sorcery:install 時に作成された User モデルに対して改修をします
`password`, `password_confirmation` attribute を仮想的に追加して、 validation を行っています
ついでにメールアドレスベースの認証にするため、 `validates :email, uniqueness: true` を追加しています。

* ※ sorcery では本来 `password`, `password_confirmation` という仮想 attribute が追加されるはずですが、Rails 5.1.6 では上手くいかなかったため、明示的に追加しています(Rails 4だと大丈夫かも)

```ruby:app/models/user.rb
class User < ApplicationRecord
  authenticates_with_sorcery!

  attribute :password, :string
  attribute :password_confirmation, :string

  validates :password, presence: true, length: { minimum: 3 }, if: -> { new_record? || changes[:crypted_password] }
  validates :password, confirmation: true, if: -> { new_record? || changes[:crypted_password] }
  validates :password_confirmation, presence: true, if: -> { new_record? || changes[:crypted_password] }

  validates :email, uniqueness: true
end
```

## Jwt::TokenProvider を app/services 以下に追加

今回はJWT の encode/decode に `secret_key_base` を利用しています
実運用する際には、くれぐれも `secret_key_base` はバレないように注意して管理してください

```ruby:app/services/jwt/token_provider.rb
module Jwt
  class TokenProvider
    class << self
      def decode(token)
        JWT.decode token, Rails.application.secrets.secret_key_base
      end

      def refresh_tokens(user)
        tokens = Jwt::TokenProvider.create_pair_tokens user_id: user.id.to_s

        user.update_attribute :refresh_token, tokens[:refresh_token]

        tokens
      end

      def create_pair_tokens(payload)
        {
          access_token: issue_token(payload.merge(exp: Time.current.to_i + 30.minutes)),
          refresh_token: issue_token(payload.merge(exp: Time.current.to_i + 1.months))
        }
      end

      private
      def issue_token(payload)
        JWT.encode payload, Rails.application.secrets.secret_key_base
      end
    end
  end
end
```

## 認証のチェックを行うヘルパ的なモジュールを追加

`Jwt::TokenProvider.decode` を使って送信されてきたトークンから user_id を取得します
これを認証として使います
`bearer_token` の部分は結構ベタに書いているので要改善だと思います

```ruby:app/controllers/concerns/user_authenticator.rb
module UserAuthenticator
  extend ActiveSupport::Concern

  included do
    attr_reader :current_user

    def authenticate!
      payload, _ = Jwt::TokenProvider.decode bearer_token

      @current_user = User.find(payload['user_id'])
    end

    def bearer_token
      pattern = /^Bearer /
      header = request.headers['Authorization']

      header.gsub(pattern, '') if header && header.match(pattern)
    end
  end
end
```

## UserAuthenticator を利用し、API Controller のベースとなる ApplicationController を追加

API エンドポイントの各 Controller が行う共通処理を `Api::Base::ApplicationController` として切り出します
例外処理やクライエントへのエラーを送信するためのユーティリティメソッドを追加しています

```ruby:app/controllers/api/base/application_controller.rb
class Api::Base::ApplicationController < ActionController::API
  include UserAuthenticator

  rescue_from JWT::DecodeError, with: :token_invalid
  rescue_from JWT::ExpiredSignature, with: :token_has_expired
  rescue_from ActionController::ParameterMissing, with: :parameter_missing
  rescue_from ActiveRecord::RecordInvalid, with: :record_invalid

  private
  def token_invalid
    render json: { status: :error, message: :token_must_be_passed }, status: 401
  end

  def token_has_expired
    render json: { status: :error, message: :token_has_expired }, status: 403
  end

  def parameter_missing
    render json: {
      status: error,
      message: :parameter_missing,
      data: {
        parameter: exception.param
      }
    }, status: 400
  end

  def unauthorized
    render json: { status: :error, message: :unauthorized }, status: 401
  end

  def record_invalid
    render json: {
      status: :error,
      message: :record_invalid,
      data: exception.record.errors
    }, status: 422
  end
end
```

## ユーザの新規登録(作成)を担当する UsersController を追加

`/api/v1/users/create` に POST をすることでユーザを作成する Controller を作ります
ユーザ作成時に必要なパラメータは以下の通りです

* `email` メールアドレス
* `password` パスワード
* `password_confirmation` パスワード確認(`password` と文字列が一致しているか確認するためです)

```ruby:app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < Api::Base::ApplicationController
  def create
    @user = User.create! user_params

    payload = Jwt::TokenProvider.refresh_tokens @user
    payload[:user] = @user

    render json: { status: :success, data: payload }
  end

  private
  def user_params
    params.require(:user)
          .permit(:email, :password, :password_confirmation)
  end
end
```

## ログイン処理を担当する AuthsController を追加

`/api/v1/auths/create` に POST をすることで JWT を発行する Controller を追加します

```ruby:app/controllers/api/v1/auths_controller.rb
class Api::V1::AuthsController < Api::Base::ApplicationController
  def create
    if (user = User.authenticate(params[:email], params[:password]))
      tokens = Jwt::TokenProvider.refresh_tokens user

      render json: { status: :success, data: tokens }
    else
      unauthorized
    end
  end
end
```

## 認証による制限を既存の Controller に仕掛ける

制限をしたい Controller に `before_action: authenticate!` を追加

```ruby:app/controllers/api/v1/strict_tasks_controller.rb
class Api::V1::StrictTasksController < Api::V1::Base::ApplicationController
  before_action :authenticate!
  before_action :set_task, only: [
    :show, :update, :destroy
  ]

  def index
    ...
end
```

## config/routes.rb に今まで作成した controller と action をマッピングする

users#create, auths#create というエンドポイントでは分かりにくいので、追加で下記のようにしました

* `/api/v1/register` という形でユーザ登録用のエンドポイントを追加
* `/api/v1/login` という形でログイン用のエンドポイントを追加

```diff:config/routes.rb
Rails.application.routes.draw do
  root to: "home#index"
  get 'home/index'

  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :tasks
+      resources :strict_tasks
+
+      resources :users, only: [:create]
+      resource :auths, only: [:create]
+
+      post 'register' => 'users#create'
+      post 'login' => 'auths#create'
       ....
```

## curl による動作確認
### ユーザ登録
* 以下の curl コマンドを叩いて 200 が返ってくることを確認します

```shell
$ curl -XPOST -H 'Content-Type: application/json' -d '{"email": "demo@example.com", "password": "foo", "password_confirmation": "foo"}' http://localhost:5000/api/v1/register
```

### ログイン確認
* 以下の curl コマンドを叩いて 200 が返ってきて、レスポンスに JWT の access token が含まれることを確認します

```shell
$ curl -XPOST -H 'Content-Type: application/json' -d '{"email": "demo@example.com", "password": "foo"}' http://localhost:5000/api/v1/login
```

# React App
## 基本方針

* 部分的な解説になります。すみません(´-﹏-`；)
    * コードを全部貼ると冗長になるので
* 全体を見たい方は [こちら](https://github.com/kaishuu0123/rails5.1-react-reactstrap-example/tree/jwt-with-sorcery/app/javascript/components) を参照してください
* React Router を導入して SPA っぽく動作させています
* 今回はユーザ登録画面は無しです( curl で事前にユーザを作っておきます )

実装のポイントは以下のとおりです

* ログイン画面でログイン処理を実施
* ログインに成功したら、レスポンスを取得して access_token, refresh_token を localStorage に記録
* localStorage に access_token が記録されていたら、 `Authorization` ヘッダに access token をつけてリクエストを投げる
* ログアウト時には localStorage に記録していた access_token, refresh_token を破棄する
    * API 側で明示的に破棄する API エンドポイントを追加してもいいかもしれません
        * 今回のアプリではサーバ側に破棄するリクエストは投げていません
    * JWT は有効期限があるので、有効期限が切れるまで token が有効になってしまうので、むしろサーバ側にも破棄を明示的に知らせた方がいいかも

## ログイン処理

`/api/v1/login` に POST でメールアドレスとパスワードを送信して、レスポンスが返ってきたら、JSON の中から access token と refresh token を取り出します

[ソースコード全体](https://github.com/kaishuu0123/rails5.1-react-reactstrap-example/blob/jwt-with-sorcery/app/javascript/components/login-component.jsx)

```jsx:app/javascript/components/login-component.jsx
...
  handleSubmit(event, email, password) {
    let request = new Request('/api/v1/login', {
      method: 'POST',
      headers: new Headers({
        'Content-Type': 'application/json'
      }),
      body: JSON.stringify({
        email: email,
        password: password
      })
    });

    fetch(request).then(function (response) {
      return response.json();
    }).then((json) => {
      localStorage.setItem('access_token', json.data.access_token);
      localStorage.setItem('refresh_token', json.data.refresh_token);

      this.props.history.push('/');
    }).catch(function (error) {
      console.error(error);
    })

    event.preventDefault();
  }
...
```

## リクエスト部分

GET リクエスト発行時に localStorage に保存されている access_token を取り出し、`Authorization` ヘッダに `Bearer` をつけた上でサーバにリクエストを送信するように改修しました

[ソースコード全体](https://github.com/kaishuu0123/rails5.1-react-reactstrap-example/blob/jwt-with-sorcery/app/javascript/components/strict-task-page-component.jsx)

```jsx:app/javascript/components/strict-task-page-component.jsx
    const token = localStorage.getItem('access_token');
...
    let request = new Request('/api/v1/strict_tasks', {
      method: 'GET',
      headers: new Headers({
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      })
    });

    fetch(request).then(function (response) {
      if (!response.ok) {
        throw Error(`[GET Task] ${response.status} ${response.statusText}`);
      }

      return response.json();
    }).then(function (tasks) {
      this.setState({
        strictTasks: tasks
      });
    }.bind(this)).catch(function (error) {
      toast.error(error.toString(), TOASTER_ERROR_OPTION);
    });
...
```

## ログアウト処理

別箇で LogoutComponent を作成し、React アプリ上で LogoutComponent にアクセスした際にマウントしたタイミングで localStorage　に保存されている値を削除するようにしています

```jsx:app/javascript/components/logout-component.jsx
...
  componentDidMount() {
    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
  }

  render() {
    return (
      <Redirect to="/" />
    )
  }
...
```

# 最後に

* これで Rails 5.1 + webpacker + React なアプリに JWT 認証を追加することができました
* 割りと簡単に認証機能が追加ができた、と思います（多分)
* localStorage を利用するので、 React 以外にも応用が効くと思います
* 次回はソーシャルログイン機能か LDAP 認証をやってみようと考えています

以上です( ･`ω･´)