概要
最近フロントエンドから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があるのか?
ありました!!
- GitHub - jwt/ruby-jwt: A pure ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.
- GitHub - nov/json-jwt: JSON Web Token and its family (JSON Web Signature, JSON Web Encryption and JSON Web Key) in Ruby
- GitHub - garyf/json_web_token: A Ruby implementation of the JSON Web Token (JWT) standard, RFC 7519
Starの数を見ると、ruby-jwtが圧倒的だったのと、READMEを見ても、使い方がシンプルでわかりやすかったので、採用しました。
ユーザーがブラウザからメール / パスワードを入力してユーザー登録
まずは、ブラウザからアクセスできるようにしたり、ユーザー登録をするための下準備をします。
# app/controllers/top_controller.rb class TopController < ApplicationController def index end end
# config/routes.rb Rails.application.routes.draw do root 'top#index' end
つづいて、Gemを追加
# Gemfile ... gem 'devise' gem 'jwt' ...
その後こちらのコマンドを実行
bundle install rails g devise:install rails g devise User rake db:migrate
トークンの取得、検証処理の追加
ここから、一気にトークン発行、検証の実装をしていきます。
lib/json_web_token.rbを新規作成します。
# 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起動時に読み込まれるようにします。
# config/initializers/jwt.rb require 'json_web_token'
続いて、ApplicationControllerでトークンのやりとりをするための処理を追加
# app/controllers/application_controller.rb 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の受け口を作っていきます。
# app/controllers/authentication_controller.rb 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に下記を追加。
# config/routes.rb post 'auth_user' => 'authentication#authenticate_user'
最後に、top_controllerにこれまで作ってきたactionをbefore_actionとして設定します。
# app/controllers/top_controller.rb 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が入っているシステムに対しての連携もうまくいったので、良い学びになりました。