Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

devise-jwt README: JWTで認証するDevise拡張機能(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。

waiting-for-dev/devise-jwt - GitHub

devise-jwt README: JWTで認証するDevise拡張機能(翻訳)

devise-jwtは、ユーザー認証にJWTというトークンを使うDeviseの拡張機能です。この拡張機能は、セキュア・バイ・デフォルト原則に従っています。

このgemは、cookieが利用できない場合の代替です。cookieと同様に、devise-jwtのトークンにも必ず有効期限があります。ユーザーが決してログアウトしないようにしたい場合は、OAuth2の実装など、リフレッシュトークンを利用するソリューションの方が適しています。

このライブラリで考慮しているセキュリティ上の懸念事項や、JWTを安全に利用する一般的な方法については、以下の一連の記事で読めます。

devise-jwtは、warden-jwt_authの上に薄いレイヤを提供し、DeviseとRailsで手軽に利用できる形で設定します。

waiting-for-dev/warden-jwt_auth - GitHub

🔗 アップグレード方法について

🔗 v0.7.0

バージョンv0.7.0から、無効化戦略(revocation strategy)のBlacklistDenylistという名前に変更されました。同様に、WhitelistAllowlistという名前に変更されました。

Denylistで更新が必要なのは、無効化戦略モデルで使うinclude行だけです。

# include Devise::JWT::RevocationStrategies::Blacklist # 変更前
include Devise::JWT::RevocationStrategies::Denylist

Allowlistについては、ユーザーモデルで利用しているinclude行を更新する必要があります。

# include Devise::JWT::RevocationStrategies::Whitelist # 変更前
include Devise::JWT::RevocationStrategies::Allowlist

また、WhitelistedJwtモデル名をAllowlistedJwtに変更し、model/whitelisted_jwt.rbファイル名をmodel/allowlisted_jwt.rbに変更し、背後のデータベーステーブルをallowlisted_jwtsに変更する(またはモデルが古い名前を使い続けるように構成する)必要もあります。

🔗 インストール方法

アプリケーションのGemfileに以下の行を追加します。

gem 'devise-jwt'

次に以下を実行します。

$ bundle

または以下を実行して自分でインストールします。

$ gem install devise-jwt

🔗 利用法

最初に、APIアプリケーションでDeviseが動作するように設定する必要があります。このプロジェクトのWikiページ『Configuring Devise for APIs』の手順に沿って設定してください(Wikiを改善してくださる方はさらに大歓迎です)。

🔗 秘密鍵の設定

生成されたトークンへの署名に使う秘密鍵(secret key)を設定する必要があります。これは以下のようにDeviseの初期化ファイルで行えます。

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
  end
end

Rails 5.2以降の暗号化credentialsを使っている場合は、秘密鍵をconfig/credentials.yml.encファイルに保存できます。

bin/rails credentials:editを実行してcredentialsエディタを開き、そこにdevise_jwt_secret_keyを追加します。

原注

環境によっては、上を実行するために$EDITORの設定が必要な場合があります。


# その他の秘密情報... # Devise JWT用のベース秘密情報として利用する devise_jwt_secret_key: abc...xyz

以下のDeviseイニシャライザを追加します。

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = Rails.application.credentials.devise_jwt_secret_key!
  end
end

重要

このsecretには、アプリケーションのsecret_key_baseとは別のsecretを利用することが推奨されます。アプリケーションのsecret_key_baseは、システムの他のコンポーネントで既に利用されている可能性がかなり高く、複数コンポーネントが同一のsecretを共有していると、コンポーネントの1つが脆弱性を抱えている場合の影響範囲が広がってしまう可能性が高まるためです。Railsで新しいsecretを生成する操作は、rails secretで簡単に行えます。

また、secretをリモートリポジトリにプッシュする形で共有することは厳禁です。例に示したように、環境変数を利用することが推奨されます。

現在利用されているアルゴリズムはHS256です。secretとの照合に利用するアルゴリズム名には、コンフィグで別の名前を指定できます(サポートされているアルゴリズムについてはruby-jwt gemを参照してください)。

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_secret_key!)
    jwt.algorithm = Rails.application.credentials.devise_jwt_algorithm!
  end
end

指定するアルゴリズムが非対称(RS256など)で、デコード用のsecretが別途必要な場合は、以下のようにdecoding_secretも設定します。

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_private_key!)
    jwt.decoding_secret = OpenSSL::PKey::RSA.new(Rails.application.credentials.devise_jwt_public_key!)
    jwt.algorithm = 'RS256' # (または他の非対称アルゴリズム)
  end
end

🔗 モデルを設定する

どのユーザーモデルをJWTトークンで認証可能にするかを指定する必要があります。ユーザーモデルにおける認証プロセスは以下のように進められます。

  • ユーザーは、Deviseのセッション作成リクエストを介して認証されます(例: 標準の:database_authenticatableモジュールを利用する)。

  • 認証が成功すると、JWTトークンはAuthorizationレスポンスヘッダー内でBearer #{token}という形式でクライアントにディスパッチされます(トークンはサインアップが成功した場合にもディスパッチされます)。

  • クライアントはこのトークンを利用して、同じユーザーについて次のリクエストを認証可能になります。このトークンは、Authorizationリクエストヘッダー内でBearer #{token}という形式で指定します。

  • クライアントがDeviseのセッション無効化リクエストにアクセスすると、トークンは無効化(revoked)されます。

.jsonなどのフォーマットセグメントを含むパスを利用している場合に、これを適切に利用するには、request_formats設定のオプションを参照してください。

ここまで見てきたように、トークンはサーバー側で無効化されることが期待されている点が他のJWT認証ライブラリと異なっています。JWTの無効化が必要かつ有用な理由について以下の記事を書きました。

参考: A walk with JWT and security (I): Stand up for JWT revocation

以下はユーザーモデルの設定例です。

class User < ApplicationRecord
  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: Denylist
end

JWTペイロードに何かを追加する必要が生じた場合は、ユーザーモデル内にjwt_payloadメソッドを定義することで行えます。このメソッドは以下のようにHashを返さなければなりません。

def jwt_payload
  { 'foo' => 'bar' }
end

ユーザーモデルにはon_jwt_dispatchフックメソッドを追加できます。このメソッドは、トークンがそのユーザーインスタンスにディスパッチされたタイミングで実行され、tokenpayloadをパラメータとして受け取ります。

def on_jwt_dispatch(token, payload)
  do_something(token, payload)
end

注: クロスドメインリクエストを行う場合は、必ずリクエストの許可済みヘッダーのリストと公開されるレスポンスヘッダーのリストにAuthorizationヘッダーをそれぞれ追加してください。このとき、以下のようにrack-corsなどを利用可能です。

config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://your.frontend.domain.com'
    resource '/api/*',
      headers: %w(Authorization),
      methods: :any,
      expose: %w(Authorization),
      max_age: 600
  end
end
🔗 セッションストレージの注意点

利用しているRailsアプリケーションでセッションストレージが有効になっており、デフォルトのDeviseセットアップが設定済みの場合は、ヘッダーにトークンが存在するかどうかにかかわらず、同一オリジンのリクエストがセッションで認証される場合があります。

その理由は、デフォルトのDeviseワークフローが以下のようになっているためです。

  • :database_authenticatable戦略に基づいてユーザーがサインインすると、以下の条件のいずれかが満たされない場合はユーザーをセッションに保存します。
    • セッションが無効になっている場合
    • Deviseのconfig.skip_session_storageコンフィグに:params_authが含まれている場合
    • 未検証のリクエストがRailsのRequest forgery protectionで処理される場合(ただし通常はAPIリクエストで無効になっています)
  • Warden(Devise内部のエンジン)は、ユーザーがセッション内で持っているリクエストを、戦略(ここでは:jwt_authenticatable)がなくても認証します。

したがって、この注意点を回避したい場合のオプションは以下の5通りがあります。

  • 🔗 1: セッションを無効にする。

おそらくAPI開発ではセッションは不要でしょう。セッションを無効にするには、config/initializers/session_store.rbを以下のように変更します。

Rails.application.config.session_store :disabled

なお、Railsアプリケーションを新規作成するときに--apiフラグを指定した場合は、既にセッションが無効になっています。

  • 🔗 2: 他の目的で引き続きセッションが必要な場合

以下のようにconfig/initializers/devise.rbでユーザーストレージ:database_authenticatableを無効にします。

config.skip_session_storage = [:http_auth, :params_auth]
  • 🔗 3: Deviseを別のモデル(AdminUserなど)で利用していて、Devise全体のセッションストレージを無効にしたくない場合

以下のようにセッションストレージをモデルごとに無効にできます。

class User < ApplicationRecord
  devise :database_authenticatable #, your other enabled modules...
  self.skip_session_storage = [:http_auth, :params_auth]
end
  • 🔗 4: 一部のコントローラでセッションが必要な場合

セッションが不要なコントローラで、以下のようにセッションをコントローラレベルで無効にできます。

class AdminsController < ApplicationController
  before_action :drop_session_cookie

  private

  def drop_session_cookie
    request.session_options[:skip] = true
  end
  • 🔗 5: DeviseにあるデフォルトのSessionsControllerを独自のコントローラでオーバーライドしている場合

以下のようにstore: false属性をsign_inメソッドとsign_in_and_redirectメソッドとbypass_sign_inメソッドに渡すことで、ユーザーをWardenセッションに保存しないようDeviseに指示できます。

sign_in user, store: false

🔗 無効化戦略について

devise-jwtには、すぐに利用できる3つの無効化戦略が用意されています。無効化戦略の一部は、以下のブログ記事で解説されている内容を実装したものであり、戦略のメリットとデメリットについても同記事で解説しています。

参考: A walk with JWT and security (II): JWT revocation strategies | Waiting for dev…

🔗 JTIMatcher

この戦略では、モデルクラスが無効化戦略として振る舞います。ユーザーに無効化戦略を追加するには、jtiという名前の文字列カラムが必要です。jtiはJWT IDの略で、トークンを一意に識別することを目的とする標準のクレーム(claim: RFC7519で定義されているキーバリューペア)です。

この戦略は次のように動作します。

  • トークンがユーザーにディスパッチされると、そのモデルのjtiカラム(このカラムはレコード作成時に初期化される)からjtiクレームが取得される。

  • 認証済みのあらゆるアクションで、受信トークンのjtiクレームが、そのユーザーのjtiカラムと照合される。
    この認証は両者が同じ場合にのみ成功する。

  • ユーザーがサインアウトをリクエストすると、そのユーザーのjtiカラムが変更され、それによってユーザーに提供されたトークンが無効になる。

この戦略を利用するには、ユーザーモデルにjtiカラムを追加する必要があります。したがって、マイグレーションで以下のような設定を行う必要があります。

def change
  add_column :users, :jti, :string, null: false
  add_index :users, :jti, unique: true
  # userレコードが既に存在する場合は、
  # そのカラムをnon-nullableにするよりも前のタイミングで
  # そのユーザーの`jti`カラムを初期化しておく必要がある。
  # その場合のマイグレーションは以下のようになる。
  # add_column :users, :jti, :string
  # User.all.each { |user| user.update_column(:jti, SecureRandom.uuid) }
  # change_column_null :users, :jti, false
  # add_index :users, :jti, unique: true
end

重要

jtiカラムにはuniqueインデックスを設定することが推奨されます。そうすることで、同一のjtiを持つ有効なトークンが同時に2つ存在しないことがデータベースレベルで保証されます。

次に、この戦略をモデルクラスに追加して、戦略に沿った設定を行う必要があります。

class User < ApplicationRecord
  include Devise::JWT::RevocationStrategies::JTIMatcher

  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: self
end

この戦略は、ユーザーモデルでjwt_payloadメソッドを利用する点にご注意ください。したがって、jwt_payloadメソッドを利用する必要がある場合は、以下のようにsuperを呼び出すことを忘れてはいけません。

def jwt_payload
  super.merge('foo' => 'bar')
end
🔗 Denylist

このDenylist(不許可リスト)戦略では、データベーステーブルを無効化済みJWTトークンのリストとして利用します。トークンを1位に識別するjtiクレームは永続化されます。古くなったトークンをクリーンアップ可能にするために、exp(expiration time: 失効時刻)クレームも保存されます。

Denylist戦略を利用するには、マイグレーションで以下のように不許可リストを作成する必要があります。

def change
  create_table :jwt_denylist do |t|
    t.string :jti, null: false
    t.datetime :exp, null: false
  end
  add_index :jwt_denylist, :jti
end

パフォーマンス上の理由から、jtiカラムはインデックス化しておくのが有利です。

注: バージョン0.4.0より前のDenylist戦略を利用していた場合は、expフィールドがない可能性があります。その場合は、以下のマイグレーションを実行してください。

class AddExpirationTimeToJWTDenylist < ActiveRecord::Migration
  def change
    add_column :jwt_denylist, :exp, :datetime, null: false
  end
end

次に、以下のようにDenylist戦略用のモデルを作成して、この戦略をincludeする必要があります。

class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist

  self.table_name = 'jwt_denylist'
end

最後に、ユーザーモデルでこのモデルを利用するよう設定します。

class User < ApplicationRecord
  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
🔗 Allowlist

Allowlist(許可リスト)戦略のモデル自身も無効化戦略として振る舞いますが、有効なトークン(実際にはユーザーを一意に識別するjtiクレーム)をユーザーのレコードごとに保存するために、別のテーブルと1対多関連付けも必要です。

Allowlist戦略のワークフローは以下のとおりです。

  • トークンがユーザーにディスパッチされると、関連付けられたテーブルにそのユーザーのjtiクレームが保存される。

  • 以後は認証のたびに受信トークンのjtiが、そのユーザーに関連付けられているすべてのjtiと照合される。
    この認証は両者が同じ場合にのみ成功する。

  • ユーザーがサインアウトすると、関連付けられたテーブルからトークンのjtiが削除される。

実際には、jtiクレームに加えてaud(audienceの略)クレームも保存され、認証のたびに照合されます。このaudクレームをaud_headerと組み合わせることで、同一ユーザーが使っている別のクライアントやデバイスを区別できるようになります。

古くなったトークンをクリーンアップ可能にするため、expクレームも保存されます。

Allowlist戦略を利用するには、関連付けるテーブルとモデルを作成する必要があります。この関連付けテーブル名はallowlisted_jwtsでなければなりません。

def change
  create_table :allowlisted_jwts do |t|
    t.string :jti, null: false
    t.string :aud
    # `aud`クレームを利用したい場合は、以下のように`aud`クレームに`NOT NULL`制約を追加する:
    # t.string :aud, null: false
    t.datetime :exp, null: false
    t.references :your_user_table, foreign_key: { on_delete: :cascade }, null: false
  end

  add_index :allowlisted_jwts, :jti, unique: true
end

重要

jtiカラムにはuniqueインデックスを設定することが推奨されます。そうすることで、同一のjtiを持つ有効なトークンがデータベースレベルで同時に2つ存在しなくなります。

foreign_key: { on_delete: :cascade }, null: false on t.references :your_user_tableを定義しておくと、データベースの参照整合性を維持するうえで有用です。

次に、以下のモデルを作成します。

class AllowlistedJwt < ApplicationRecord
end

最後に、モデルにAllowlist戦略をincludeして設定します。

class User < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Allowlist

  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: self
end

この戦略は、ユーザーモデルでon_jwt_dispatchメソッドを利用する点にご注意ください。したがって、on_jwt_dispatchメソッドを利用する必要がある場合は、以下のようにsuperを呼び出すことを忘れてはいけません。

def on_jwt_dispatch(token, payload)
  super
  do_something(token, payload)
end
🔗 Null戦略

Null戦略は、Null Objectパターンを用いる戦略であり、トークンを無効化しません。

Null戦略は、トークンの無効化が不要であることが絶対確実である場合に備えて提供されたものであり、利用しないことが推奨されています。

class User < ApplicationRecord
  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: Devise::JWT::RevocationStrategies::Null
end
🔗 カスタム戦略

独自の戦略を実装することも可能です。実装で必要なのはjwt_revoked?revoke_jwtという2つのメソッドだけであり、どちらもJWTのペイロードpayloadとユーザーレコードuserをこの順序でパラメータとして受け取ります。

実装例:

module MyCustomStrategy
  def self.jwt_revoked?(payload, user)
    # Does something to check whether the JWT token is revoked for given user
  end

  def self.revoke_jwt(payload, user)
    # Does something to revoke the JWT token for given user
  end
end

class User < ApplicationRecord
  devise :database_authenticatable,
         :jwt_authenticatable, jwt_revocation_strategy: MyCustomStrategy
end

🔗 テスト

:jwt_authenticatableを設定したモデルは、通常はセッションから取得されません。このため、Deviseのsign_inヘルパーは期待通りに動作しません。

test環境でリクエストを認証するのに必要なものは、production環境で行うことと同じです。すなわち、リクエストごとにAuthorizationヘッダーに有効なトークンを(Bearer #{token}形式で)設定することです。

有効なトークンを取得する方法は、以下の2通りです。

  • 1: リクエストの有効なサインイン後にレスポンスのAuthorizationヘッダーを調べる
  • 2: 手動で作成する

オプション1はアプリケーションの実際のワークフローをテストできますが、テストのたびに実行すると処理が遅くなる可能性があります。

オプション2については、Authorizationの名前/値ペアを指定のリクエストヘッダーに追加するためのテストヘルパーが提供されています。これは以下のように利用できます。

# 最初にヘルパーモジュールをrequireする
require 'devise/jwt/test_helpers'

# ...

  it 'tests something' do
    user = fetch_my_user()
    headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
    # これは`Authorization`ヘッダー内の`use`用に有効なトークンを追加する
    auth_headers = Devise::JWT::TestHelpers.auth_headers(headers, user)

    get '/my/end_point', headers: auth_headers

    expect_something()
  end

通常は、これを独自のテストヘルパーでラップする形で使います。

🔗 設定項目

このライブラリは、以下のようにDeviseのコンフィグオブジェクトでjwtを呼び出すことで設定可能です。

Devise.setup do |config|
  config.jwt do |jwt|
    # ...
  end
end
🔗 secret

secret(秘密鍵)は、生成されるJWTトークンへの署名に使われます。この設定は必須であり、省略してはいけません。

🔗 rotation_secret

秘密鍵のローテーションを許可します。新しい値をsecretに設定し、古い秘密鍵をrotation_secretにコピーします。

🔗 expiration_time

JWTが生成されてからの有効期間(秒)を設定します。この期間を過ぎると、無効化していなくてもJWTが無効になります。

デフォルトは3600秒(1時間)です。

🔗 dispatch_requests

JWTトークンをディスパッチすべきリクエストは、セッション作成以外にもいくつかあります。

dispatch_requestsは2次元配列でなければなりません。各項目は「リクエストメソッド名」「リクエストパスとマッチすべき正規表現」という2個の要素を持つ配列です。

jwt.dispatch_requests = [
                          ['POST', %r{^/dispatch_path_1$}],
                          ['GET', %r{^/dispatch_path_2$}],
                        ]

重要: 想定外のマッチを避けるため、上のように正規表現を^$で区切ることが推奨されます。

🔗 revocation_requests

JWTトークンを無効化するリクエストは、セッション無効化以外にもいくつかあります。

revocation_requestsは2次元配列でなければなりません。各項目は「リクエストメソッド名」「リクエストパスとマッチすべき正規表現」という2個の要素を持つ配列です。

jwt.revocation_requests = [
                            ['DELETE', %r{^/revocation_path_1$}],
                            ['GET', %r{^/revocation_path_2$}],
                          ]

重要: 想定外のマッチを避けるため、上のように正規表現を^$で区切ることが推奨されます。

🔗 request_formats

(トークンをディスパッチまたは無効化ために)処理しなければならないリクエストのフォーマットを指定します。

request_formatsは、キーが「Deviseのスコープ」、値が「リクエストフォーマットの配列」のハッシュでなければなりません。スコープが存在しない場合や、nil項目が存在する場合は、フォーマットなしのリクエストが考慮されます。

たとえば以下の設定の場合、userスコープではjsonリクエストのディスパッチと無効化を行います(/users/sign_in.jsonのように)が、admin_userスコープではフォーマットなしのxmlリクエストを同様に処理します(/admin_user/sign_in.xml/admin_user/sign_inのように)。

jwt.request_formats = {
                        user: [:json],
                        admin_user: [nil, :xml]
                      }

デフォルトでは、フォーマットなしのリクエストのみが処理されます。

🔗 aud_header

コンテンツがペイロードのaudクレーム(claim)に保存されるリクエストヘッダーを指定します。

aud_headerは、受信トークンが当初と同じクライアントに向けて発行されたものかどうか(audaud_headerがマッチするかどうか)を検証するのに使われます。クライアント同士を区別したくない場合は、このヘッダーを指定する必要はありません。

重要: このワークフローは万全ではありません。シナリオによってはユーザーがリクエストヘッダーを捏造できるため、任意のクライアントを偽装可能です。そのような場合は、より堅牢な方法が必要かもしれません(クライアントidとクライアントsecretを用いるOAuthなど)。

デフォルト値: JWT_AUD

🔗 このgemの開発について

このgemの開発環境作成用にDockerファイルとdocker-composeファイルを用意しているので、以下のコマンドを実行するだけでDockerを利用できます。

docker-compose up -d

続いて、たとえば以下を実行します。

docker-compose exec app rspec

貢献

バグレポートやプルリクエストは、GitHubのhttps://github.com/waiting-for-dev/devise-jwtリポジトリで歓迎されます。本プロジェクトは、安全で気持ちよくコラボレーションできる場となることを目的としており、プロジェクトの貢献者はContributor Covenantに記載されている行動規範を遵守することが期待されます。

リリースポリシー

devise-jwtは、セマンティックバージョニング(semantic versioning)の原則に沿ってリリースされます。

ライセンス

このgemは、MIT Licenseの条項に基づいてオープンソースとして利用できます。

関連記事

Rails: Deviseを徹底理解する(1)基礎編(翻訳)

Rails: Deviseを徹底理解する(2)応用編(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。