Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

Rails 5.1 API mode + webpacker + react + reactstrap で ToDO アプリを書く の続きです

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

Github Repository & commit log

Rails 側: Github commit log
React 側: Github commit log

デモ

auth_example_rails5.1_and_react_reactstrap.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 に追記

Gemfile
+gem 'sorcery', '~> 0.11.0'

sorcery:install を実行

$ ./bin/rails generate sorcery:install

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

$ ./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だと大丈夫かも)
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 はバレないように注意して管理してください

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 の部分は結構ベタに書いているので要改善だと思います

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 として切り出します
例外処理やクライエントへのエラーを送信するためのユーティリティメソッドを追加しています

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 と文字列が一致しているか確認するためです)
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 を追加します

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! を追加

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 という形でログイン用のエンドポイントを追加
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 が返ってくることを確認します
$ 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 が含まれることを確認します
$ curl -XPOST -H 'Content-Type: application/json' -d '{"email": "demo@example.com", "password": "foo"}' http://localhost:5000/api/v1/login

React App

基本方針

  • 部分的な解説になります。すみません(´-﹏-`;)
    • コードを全部貼ると冗長になるので
  • 全体を見たい方は こちら を参照してください
  • 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 を取り出します

ソースコード全体

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 をつけた上でサーバにリクエストを送信するように改修しました

ソースコード全体

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 に保存されている値を削除するようにしています

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 認証をやってみようと考えています

以上です( ・`ω・´)

kaishuu0123
プログラミング(C, Ruby, Java, Python etc) も DB (MySQL, PostgreSQL, sqlite, MongoDB, Berkeley DB etc)も、ネットワーク(Cisco, Juniper etc ) もインフラ(色々) もやっていて、何をやる人なんだか一言で言いにくいので、結局「システムエンジニア」を名乗っています。
https://www.saino.me/
weseek
WESEEK, Inc. はシステム開発のプロフェッショナル集団です。UIデザインからサービス運用のためのネットワーク・インフラ構築まで全てを自社で行います。
https://weseek.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした