production.log

ピクスタ株式会社で開発部の部長をやっている星直史のブログです。

Rails 5.2でJWTとdeviseを使った認証の仕組みを作る

概要

最近フロントエンドからAPIを叩く実装における認証の仕組みをどうするか考えていました。
以前、AWS Cognitoを使った認証仕組みを作ったことはあったのですが、Railsの場合はどのようにJWTを扱うか知りたかったので、作ってみました。

今回は、タイトルの通りRails 5.2でJWTとdeviseを使った認証の仕組みについて解説します。*1

想定しているケース

想定しているケースはシンプルです。
ユーザーの動き的には、こんなことをすることを想定しています。

  1. ユーザーがブラウザからメール / パスワードを入力してユーザー登録
  2. ユーザーがAPIでデータのやりとりをするために、CUIでメール / パスワードを使ってトークンを取得
  3. 1で取得したトークンをヘッダーに入れて、リクエストをすると、APIが叩けるようになる。

この記事では、主に2, 3.の実装について、説明します。

調査したこと

想定しているケースの「ユーザーがブラウザからメール/パスワードを入力してユーザー登録」はdeviseに任せようと思います。 トークンの発行と検証は、よくわからなかったので、調査したこととしてて、2つありました

  • JWTを扱うための仕組みがdeviseにあるか?
  • なければ、それを補うためのgemがあるのか?

JWTを扱うための仕組みがdeviseにあるか?

まだ未実装でした\(^o^)/

なければ、それを補うためのgemがあるのか?

ありました!!

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が入っているシステムに対しての連携もうまくいったので、良い学びになりました。

*1:Rails, JWT, deviseの説明は省きます

*2:Rails 5.2から秘密鍵周りが変わったので、5.1以下を使っている方は書き方が多少異なります。 おそらくRails.application.secrets.secret_key_baseとなるはず。