[Rails API]DeviseとJWTで認証を作る
はじめに
Rails APIとVue3で作ったアプリに、DeviseとJWTを使って認証機能を追加します。
環境構築はこちらで記事で行いました。
JWTについて
認証管理には主に2つのアプローチがあります:
- クッキー(Cookies)
- JWT(JSON Web Token)
今回では後者を使用します。
JSON Webトークン(JWT)は、情報をJSONオブジェクトとして安全に送信するために使用されるトークンです。 認証や情報交換によく使用されます。
JWTを使用すると、必要なデータはすべてトークン自体に含まれているため、サーバーはセッション情報を保存する必要がないです。
クライアント サーバー
| |
|---ログイン要求--->|
| |
|<--JWTを生成・送信--|
| |
|---JWTを保存--->|
| |
|---保護されたリソース要求(JWT付き)--->|
| |
| |---JWTを検証--->|
| |
|<--リソースへのアクセス許可--|
| |
シリアライズされたJWT の構造:
[Header:ヘッダー].[Payload:ペイロード].[Signature:署名]
After successful authentication (verifying that the username and password match), the server generates an accessToken by encrypting the “userId” and “expiresIn” information and then sends it to the client (the browser). The browser receives this token, saves it, and then includes it with every subsequent request.
認証に成功(ユーザー名とパスワードが一致することを確認)すると、サーバーは「userId」と「expiresIn」の情報を暗号化してaccessTokenを生成し、クライアント(ブラウザ)に送信する。 ブラウザはこのトークンを受け取り、保存し、以後のリクエストに含める。
In this way, no session information is saved in the database. All the information on the current user or the “bearer” is stored in the token itself and with every request, the server will know the user just by decrypting the token, with no need for a database search for the session. Only the server that has the access token secret can decrypt the JWT token.
この方法では、セッション情報はデータベースに保存されない。 現在のユーザーまたは「ベアラ」に関するすべての情報はトークン自体に保存され、リクエストのたびに、サーバーはトークンを解読するだけでユーザーを知ることができ、セッションをデータベースで検索する必要はありません。 アクセストークンシークレットを持つサーバーだけが、JWTトークンを復号化できる。
tl:dr;
- deviseとdevist-jwtをインストールする
- deviseを初期化する
- Userモデルを作成する
- jwt_denylistを作成する
- マイグレーションを実行する
- コントローラー作成する
- devist-jwtを設定する
- Applicationコントローラーを設定する
-
ActionDispatch::Request::Session::DisabledSessionError
エラー回避 - Postman動作確認
deviseとdevist-jwtをインストールする
docker compose exec web bundle add devise devise-jwt
docker compose exec web bundle add devise devise-jwt
docker compose exec web bundle add devise devise-jwt
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Fetching jwt 2.9.3
Fetching orm_adapter 0.5.0
Fetching dry-core 1.0.1
Fetching bcrypt 3.1.20
Fetching warden 1.2.9
Fetching responders 3.1.1
Installing jwt 2.9.3
Installing orm_adapter 0.5.0
Installing dry-core 1.0.1
Fetching dry-auto_inject 1.0.1
Fetching dry-configurable 1.2.0
Installing bcrypt 3.1.20 with native extensions
Installing warden 1.2.9
Installing responders 3.1.1
Installing dry-auto_inject 1.0.1
Installing dry-configurable 1.2.0
Fetching warden-jwt_auth 0.10.0
Installing warden-jwt_auth 0.10.0
Fetching devise 4.9.4
Installing devise 4.9.4
Fetching devise-jwt 0.12.1
Installing devise-jwt 0.12.1
cors.rbを編集する
Gemfileにあるrake-cors
のコメントアウトを解除し、Gemをインストールします。
クロスドメインリクエストを行う場合は、許可されたリクエストヘッダと公開されたレスポンスヘッダのリストにAuthorizationヘッダを追加する必要があります。
++ Rails.application.config.middleware.insert_before 0, Rack::Cors do
++ allow do
++ origins "localhost:5173"
++
++ resource "*",
++ headers: :any,
++ methods: %i[get post put patch delete options head]
++ expose: %w[Authorization Uid]
++ end
++ end
Deviseを初期化する
ジェネレートコマンドでDeviseを初期化します。
docker compose exec web rails generate devise:install
create config/initializers/devise.rb
create config/locales/devise.en.yml
===============================================================================
... 略
Deviseユーザーを作成する
docker compose exec web rails generate devise User
invoke active_record
create db/migrate/20241020054852_devise_create_users.rb
create app/models/user.rb
insert app/models/user.rb
route devise_for :users
jwt_denylistを作成する
docker compose exec web rails g model jwt_denylist jti:string exp:datetime
invoke active_record
create db/migrate/20241020060248_create_jwt_denylists.rb
create app/models/jwt_denylist.rb
このコマンドは、JWT(JSON Web Token)拒否リスト(ブロックリストとも呼ばれる)を実装するために使用されるモデルを作成しています。
デフォルトでは、JWTはステートレスで、有効期限が切れるまで有効です。
しかし、有効期限が切れる前にトークンを無効にしたい場合もあります(ユーザーのログアウト、セキュリティ侵害など)。
denylist は、無効にされたトークンの追跡を可能にし、アプリケーションのセキュリティを強化します。
devise-jwtを使用している場合、このモデルを使用して失効ストラテジーを実装できます。
ユーザーがログアウトしたときやトークンを無効にする必要があるとき、このjwt_denylistテーブルにレコードが追加されます。
レコードには、jti(JWTの一意な識別子)とexpiration(トークンの有効期限)が含まれます。
受信したリクエストを処理するとき、アプリケーションはこのテーブルをチェックしてトークンが失効したかどうかを確認できます。
jitとexpをnull不可にします。また、jtiにindexを追加します。
class CreateJwtDenylist < ActiveRecord::Migration[7.2]
def change
create_table :jwt_denylist do |t|
t.string :jti, null: false
t.datetime :exp, null: false
t.timestamps
end
add_index :jwt_denylist, :jti, unique: true
end
end
続いて、作成されたユーザーモデルとJwtDenyListモデルにJWTに関する記述を追加します。
class JwtDenylist < ApplicationRecord
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = "jwt_denylist"
end
class User < ApplicationRecord
devise :database_authenticatable, :registerable, :validatable
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
マイグレーションを実行する
マイグレーションを実行し、データベースに反映します。
docker compose exec web rails db:migrate
== 20241020060241 DeviseCreateUsers: migrating =============================
-- create_table(:users)
-> 0.0143s
-- add_index(:users, :email, {:unique=>true})
-> 0.0022s
-- add_index(:users, :reset_password_token, {:unique=>true})
-> 0.0017s
== 20241020060241 DeviseCreateUsers: migrated (0.0183s) ====================
== 20241020060248 CreateJwtDenylist: migrating ================================
-- create_table(:jwt_denylist)
-> 0.0058s
-- add_index(:jwt_denylist, :jti, {:unique=>true})
-> 0.0013s
== 20241020060248 CreateJwtDenylist: migrated (0.0071s) =======================
認証用コントローラーを作成する
docker compose exec web rails g devise:controllers users sessions registrations
create app/controllers/users/confirmations_controller.rb
create app/controllers/users/passwords_controller.rb
create app/controllers/users/registrations_controller.rb
create app/controllers/users/sessions_controller.rb
create app/controllers/users/unlocks_controller.rb
create app/controllers/users/omniauth_callbacks_controller.rb
===============================================================================
Some setup you must do manually if you haven't yet:
Ensure you have overridden routes for generated controllers in your routes.rb.
For example:
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions'
}
end
===============================================================================
ターミナルに記載した通りにroutes.rb
を編集します。
Rails.application.routes.draw do
devise_for :users, path: '', path_names: {
sign_in: 'login',
sign_out: 'logout',
registration: 'signup'
},
controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
end
RegistrationsController
ユーザー登録に関するアクションを作成します。
JSONリクエストに応答することを指定します。
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
private
def respond_with(resource, _opts = {})
register_success && return if resource.persisted?
register_failed
end
def register_success
render json: { message: 'Signed up successfully.', user: resource }, status: :ok
end
def register_failed
render json: {
message: "Signed up failure.",
errors: resource.errors.full_messages
}, status: :unprocessable_entity
end
end
SessionsController
ユーザーログインに関するアクションを作成します。
同じくJSONリクエストに応答することを指定します。
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
private
def respond_with(resource, _opts = {})
render json: { message: 'Logged in successfully.', user: resource }, status: :ok
end
def respond_to_on_destroy
current_user ? log_out_success : log_out_failure
end
def log_out_success
render json: { message: "Logged out successfully." }, status: :ok
end
def log_out_failure
render json: { message: "Logged out failure."}, status: :unauthorized
end
end
devist-jwtを設定する
deviseの設定ファイルにJWTに関する記述を追加します。
JWTトークンがディスパッチされるべきのリクエストを追加します。
各項目はリクエストメソッドとリクエストパスの2つの要素の配列でなければならないです。
/login
に対応するPOST
リクエストと/logout
に対するDELETE
リクエストを追加します。
ここでは、POSTリクエストから/login
呼び出しのたびに、"Bearer "
+トークンとしてAuthorizationヘッダーにJWTトークンを追加し、成功したレスポンスが送り返されます。
/logout
エンドポイントへのDELETE呼び出しで、トークンを破棄することを指定しています。 jwt.expiration_time
は、生成されたトークンの有効期限を設定します。
Devise.setup do |config|
...
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.fetch(:secret_key_base)
jwt.dispatch_requests = [
%w[POST /login]
]
jwt.revocation_requests = [
%w[DELETE /logout]
]
jwt.expiration_time = 30.minutes.to_i
end
end
Applicationコントローラーを設定する
Applicationコントローラーにdeviseに関する記述を追加します。
アプリに合わせて許可するパラメーターを追加します。
ここでは、登録、ログイン、アカウント編集用パラメーターを指定してます。
class ApplicationController < ActionController::API
include ActionController::MimeResponds # APIモード
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [ :email, :password, :password_confirmation ])
devise_parameter_sanitizer.permit(:sign_in, keys: [ :email, :password ])
devise_parameter_sanitizer.permit(:account_update, keys: [ :password, :password_confirmation ])
end
end
ActionDispatch::Request::Session::DisabledSessionError
エラー回避
ここで登録しようとしたらターミナルにこちらのエラーが出ました。
これは現時点ではRails 7のDeviseで未修正のバグです。
web-1 | ActionDispatch::Request::Session::DisabledSessionError (Your application has sessions disabled. To write to the session you must first configure a session store):
Devise-JWTのリポジトリに、いくつかの解決方法も含めてこの問題についてのissueがありました。
APIのみのモードでは、セッション・クッキーは使用しません。
Sessionをfalseに設定した偽のラック・セッション・ハッシュを使用し、エラーを回避することができました。
偽のラック・セッショモジュールを作成します。
module RackSessionFix
extend ActiveSupport::Concern
class FakeRackSession < Hash
def enabled?
false
end
end
included do
before_action :set_fake_rack_session_for_devise
private
def set_fake_rack_session_for_devise
request.env['rack.session'] ||= FakeRackSession.new
end
end
end
コントローラーにincludeします。
class Users::SessionsController < Devise::SessionsController
include RackSessionFix
end
class Users::RegistrationsController < Devise::RegistrationsController
include RackSessionFix
end
Postman動作確認
登録
ユーザーを作成してみます。
ターミナル上も確認します。
web-1 | Started POST "/signup" for 192.168.65.1 at 2024-10-20 17:31:38 +0900
web-1 | Processing by Users::RegistrationsController#create as */*
web-1 | Parameters: {"user"=>{"email"=>"[FILTERED]", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "registration"=>{"user"=>{"email"=>"[FILTERED]", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}}}
web-1 | TRANSACTION (0.8ms) BEGIN
web-1 | User Create (3.6ms) INSERT INTO "users" ("email", "encrypted_password", "reset_password_token", "reset_password_sent_at", "remember_created_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "id" [["email", "[FILTERED]"], ["encrypted_password", "[FILTERED]"], ["reset_password_token", "[FILTERED]"], ["reset_password_sent_at", "[FILTERED]"], ["remember_created_at", nil], ["created_at", "2024-10-20 08:31:38.411255"], ["updated_at", "2024-10-20 08:31:38.411255"]]
web-1 | TRANSACTION (0.5ms) COMMIT
web-1 | Completed 200 OK in 266ms (Views: 0.2ms | ActiveRecord: 4.9ms (1 query, 0 cached) | GC: 31.4ms)
web-1 |
ログイン
続いて、ユーザーをログインしてみます。
ターミナル上も確認します。
web-1 | Started POST "/login" for 192.168.65.1 at 2024-10-20 18:01:27 +0900
web-1 | Processing by Users::SessionsController#create as */*
web-1 | Parameters: {"user"=>{"email"=>"[FILTERED]", "password"=>"[FILTERED]"}, "session"=>{"user"=>{"email"=>"[FILTERED]", "password"=>"[FILTERED]"}}}
web-1 | User Load (2.1ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 ORDER BY "users"."id" ASC LIMIT $2 [["email", "[FILTERED]"], ["LIMIT", 1]]
web-1 | Completed 200 OK in 214ms (Views: 1.1ms | ActiveRecord: 2.1ms (1 query, 0 cached) | GC: 0.0ms)
web-1 |
web-1 |
Bearerトークンが作成されたことも確認します。
こちらのトークンをコピーし、Postman上新しいタブを開きます。
Authorizationをクリックし、Auth TypeドロップダウンからBearer Tokenを選択し、コピーしたトークンを貼ります。
ログアウトを試してみます。
ログアウト
また、ログアウトにより、jwt_denylist
に新しいレコードが作成されたことも確認します。
web-1 | JwtDenylist Load (5.4ms) SELECT "jwt_denylist".* FROM "jwt_denylist" WHERE "jwt_denylist"."jti" = $1 AND "jwt_denylist"."exp" = $2 LIMIT $3 [["jti", "7e9a3a26-fc4a-4208-a2b9-b1460d283eb8"], ["exp", "2024-10-20 09:36:13"], ["LIMIT", 1]]
web-1 | TRANSACTION (0.1ms) BEGIN
web-1 | JwtDenylist Create (6.6ms) INSERT INTO "jwt_denylist" ("jti", "exp", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["jti", "7e9a3a26-fc4a-4208-a2b9-b1460d283eb8"], ["exp", "2024-10-20 09:36:13"], ["created_at", "2024-10-20 09:09:24.020325"], ["updated_at", "2024-10-20 09:09:24.020325"]]
web-1 | TRANSACTION (1.3ms) COMMIT
終わりに
DeviseとJWTでAPIの認証を実装してみました。
Discussion