devise-jwt README: JWTで認証するDevise拡張機能(翻訳)
devise-jwt
は、ユーザー認証にJWTというトークンを使うDeviseの拡張機能です。この拡張機能は、セキュア・バイ・デフォルト原則に従っています。
このgemは、cookieが利用できない場合の代替です。cookieと同様に、devise-jwt
のトークンにも必ず有効期限があります。ユーザーが決してログアウトしないようにしたい場合は、OAuth2の実装など、リフレッシュトークンを利用するソリューションの方が適しています。
このライブラリで考慮しているセキュリティ上の懸念事項や、JWTを安全に利用する一般的な方法については、以下の一連の記事で読めます。
- A walk with JWT and security (I): Stand up for JWT revocation | Waiting for dev…
- A walk with JWT and security (II): JWT revocation strategies | Waiting for dev…
- A walk with JWT and security (III): JWT secure usage | Waiting for dev…
- A walk with JWT and security (and IV): A secure JWT authentication implementation for Rack and Rails | Waiting for dev…
devise-jwt
は、warden-jwt_auth
の上に薄いレイヤを提供し、DeviseとRailsで手軽に利用できる形で設定します。
🔗 アップグレード方法について
🔗 v0.7.0
バージョンv0.7.0から、無効化戦略(revocation strategy)のBlacklist
はDenylist
という名前に変更されました。同様に、Whitelist
も Allowlist
という名前に変更されました。
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
フックメソッドを追加できます。このメソッドは、トークンがそのユーザーインスタンスにディスパッチされたタイミングで実行され、token
とpayload
をパラメータとして受け取ります。
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
は、受信トークンが当初と同じクライアントに向けて発行されたものかどうか(aud
とaud_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の条項に基づいてオープンソースとして利用できます。
概要
MITライセンスに基づいて翻訳・公開いたします。