概要
最近フロントエンドからAPIを叩く実装における認証の仕組みをどうするか考えていました。
以前、AWS Cognitoを使った認証仕組みを作ったことはあったのですが、Railsの場合はどのようにJWTを扱うか知りたかったので、作ってみました。
今回は、タイトルの通りRails 5.2でJWTとdeviseを使った認証の仕組みについて解説します。*1
想定しているケース
想定しているケースはシンプルです。
ユーザーの動き的には、こんなことをすることを想定しています。
- ユーザーがブラウザからメール / パスワードを入力してユーザー登録
- ユーザーがAPIでデータのやりとりをするために、CUIでメール / パスワードを使ってトークンを取得
- 1で取得したトークンをヘッダーに入れて、リクエストをすると、APIが叩けるようになる。
この記事では、主に2, 3.の実装について、説明します。
調査したこと
想定しているケースの「ユーザーがブラウザからメール/パスワードを入力してユーザー登録」はdeviseに任せようと思います。
トークンの発行と検証は、よくわからなかったので、調査したこととしてて、2つありました
- JWTを扱うための仕組みがdeviseにあるか?
- なければ、それを補うためのgemがあるのか?
JWTを扱うための仕組みがdeviseにあるか?
まだ未実装でした\(^o^)/
なければ、それを補うためのgemがあるのか?
ありました!!
Starの数を見ると、ruby-jwtが圧倒的だったのと、READMEを見ても、使い方がシンプルでわかりやすかったので、採用しました。
ユーザーがブラウザからメール / パスワードを入力してユーザー登録
まずは、ブラウザからアクセスできるようにしたり、ユーザー登録をするための下準備をします。
class TopController < ApplicationController
def index
end
end
Rails.application.routes.draw do
root 'top#index'
end
つづいて、Gemを追加
...
gem 'devise'
gem 'jwt'
...
その後こちらのコマンドを実行
bundle install
rails g devise:install
rails g devise User
rake db:migrate
トークンの取得、検証処理の追加
ここから、一気にトークン発行、検証の実装をしていきます。
lib/json_web_token.rbを新規作成します。
class JsonWebToken
class << self
def encode(payload)
JWT.encode(payload, Rails.application.credentials.config[:secret_key_base])
end
def decode(token)
HashWithIndifferentAccess.new(
JWT.decode(
token,
Rails.application.credentials.config[:secret_key_base]
)[0]
)
rescue
nil
end
end
end
JWTの秘密鍵は、Railsのsecret_key_baseを使いましょう。
*2
次に、上記のクラスをいちいちrequireをするのがめんどくさいので、config/initializers/jwt.rbを作って、Rails起動時に読み込まれるようにします。
require 'json_web_token'
続いて、ApplicationControllerでトークンのやりとりをするための処理を追加
class ApplicationController < ActionController::Base
attr_reader :current_user
protected
def authenticate_request!
unless user_id_in_token?
render json: { errors: ['Not Authenticated'] }, status: :unauthorized
return
end
@current_user = User.find(auth_token[:user_id])
rescue JWT::VerificationError, JWT::DecodeError
render json: { errors: ['Not Authenticated'] }, status: :unauthorized
end
private
def http_token
@http_token ||= if request.headers['Authorization'].present?
request.headers['Authorization'].split(' ').last
end
end
def auth_token
@auth_token ||= JsonWebToken.decode(http_token)
end
def user_id_in_token?
http_token && auth_token && auth_token[:user_id].to_i
end
end
最後に、トークンを作るためのAPIの受け口を作っていきます。
class AuthenticationController < ApplicationController
protect_from_forgery except: :authenticate_user
def authenticate_user
user = User.find_for_database_authentication(email: params[:email])
if user.valid_password?(params[:password])
render json: payload(user)
else
render json: {errors: ['Invalid Username/Password']}, status: :unauthorized
end
end
private
def payload(user)
return nil unless user and user.id
{
auth_token: JsonWebToken.encode({user_id: user.id, exp: (Time.now + 2.week).to_i}),
user: {id: user.id, email: user.email}
}
end
※protect_from_forgery except: :authenticate_user
を設定しないとAPIを叩いた時に、CORSエラーとなるため、設定しておきます。
※今回の実装だと、JWTは一度発行すると2週間は失効できなくなるので、payloadのexpをよしなに変更するか、別実装は考えておいた方が良いと思います。
config/routes.rbに下記を追加。
post 'auth_user' => 'authentication#authenticate_user'
最後に、top_controllerにこれまで作ってきたactionをbefore_actionとして設定します。
class TopController < ApplicationController
before_action :authenticate_request!
def index
render json: {'logged_in' => true}
end
end
これで実装完了です!
動作確認
実装が終わったので、意図した実装になっているか、確認します。
ブラウザからユーザー登録していくのはめんどくさいので、rails cでやっちゃいましょう。
$ rails c
User.create(email:'test@test.com', password:'changeme', password_confirmation:'changeme')
これで下準備は完了です。
rails s
で起動して、curl http://localhost:3000/
を実行してみましょう。
レスポンスとして{"errors":["Not Authenticated"]}
が返ってくればOKです。
トークンの取得
curl -X POST -d email="test@test.com" -d password="changeme" http://localhost:3000/auth_user
こちらを実行すると、レスポンスとしてトークンが返ってきます。
{"auth_token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE1MjYxOTA1MDB9.ABVCQGdzF3u2XcAp66vXZxeUy2dhsCuxsg88NsEdoFs","user":{"id":1,"email":"test@test.com"}}
トークンを使ってAPI実行
返ってきたトークンを使えば、認証が通るようになっているはずです。やってみましょう。
curl --header "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE1MjYxOTA1MDB9.ABVCQGdzF3u2XcAp66vXZxeUy2dhsCuxsg88NsEdoFs" http://localhost:3000
{"logged_in":true}
というjsonが返って来れば成功です!
まとめ
基本の形はこれで十分なのではないかと思います。
システムによって検討しなければならない点としては、先述の通り、トークンの失効するタイミングについてです。
この実装では2週間待たなければトークンは失効されず、ずっと有効なままです。そのため、仮に失効しなければならないことがあった場合に、ほぼ対応不能となるので、認証時の処理を少し変える必要があると思います。
とはいえ、RailsでJWTを簡単に扱えるgemがあったので、JWTを使った認証の仕組みをサクッと作ることができました。
deviseが入っているシステムに対しての連携もうまくいったので、良い学びになりました。