Edited at

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


デモ

デモ内容は以下


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

以上です( ・`ω・´)