Rails 5.1 API mode + webpacker + react + reactstrap で ToDO アプリを書く の続きです
sorcery を使って JWT 認証を実現する方法がいまいちまとまっていなかったので、自分用にメモしておきます
Github Repository & commit log
Rails 側: Github commit log
React 側: Github commit log
デモ
デモ内容は以下
- 認証の制限がないページでの Task 追加
- 認証の制限があるページでエラーメッセージ表示 (下記、2つとも 401 が返ること)
- タスク取得
- タスク作成
-
demo@example.com
アカウントでログインし、認証の制限があるページでタスク取得/タスク追加ができる
Rails API 改修内容
sorcery gem のインストール & sorcery:install の実行 & db:migrate
実行
https://github.com/Sorcery/sorcery#installation あたりを参考に。
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だと大丈夫かも)
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
はバレないように注意して管理してください
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
の部分は結構ベタに書いているので要改善だと思います
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
として切り出します
例外処理やクライエントへのエラーを送信するためのユーティリティメソッドを追加しています
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
と文字列が一致しているか確認するためです)
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 を追加します
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!
を追加
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
という形でログイン用のエンドポイントを追加
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 側で明示的に破棄する API エンドポイントを追加してもいいかもしれません
ログイン処理
/api/v1/login
に POST でメールアドレスとパスワードを送信して、レスポンスが返ってきたら、JSON の中から access token と refresh token を取り出します
...
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
をつけた上でサーバにリクエストを送信するように改修しました
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 に保存されている値を削除するようにしています
...
componentDidMount() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
render() {
return (
<Redirect to="/" />
)
}
...
最後に
- これで Rails 5.1 + webpacker + React なアプリに JWT 認証を追加することができました
- 割りと簡単に認証機能が追加ができた、と思います(多分)
- localStorage を利用するので、 React 以外にも応用が効くと思います
- 次回はソーシャルログイン機能か LDAP 認証をやってみようと考えています
以上です( ・`ω・´)