🦓

[Rails API]DeviseとJWTで認証を作る

2024/10/13に公開

はじめに

Rails APIとVue3で作ったアプリに、DeviseとJWTを使って認証機能を追加します。

環境構築はこちらで記事で行いました。

JWTについて

認証管理には主に2つのアプローチがあります:

  1. クッキー(Cookies)
  2. 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トークンを復号化できる。

https://jwt.io/introduction

tl:dr;

  1. deviseとdevist-jwtをインストールする
  2. deviseを初期化する
  3. Userモデルを作成する
  4. jwt_denylistを作成する
  5. マイグレーションを実行する
  6. コントローラー作成する
  7. devist-jwtを設定する
  8. Applicationコントローラーを設定する
  9. ActionDispatch::Request::Session::DisabledSessionErrorエラー回避
  10. 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ヘッダを追加する必要があります。

api/config/initializers.rb/cors.rb
++ 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(トークンの有効期限)が含まれます。
受信したリクエストを処理するとき、アプリケーションはこのテーブルをチェックしてトークンが失効したかどうかを確認できます。

https://github.com/waiting-for-dev/devise-jwt?tab=readme-ov-file#denylist

jitとexpをnull不可にします。また、jtiにindexを追加します。

api/db/migrate/xxx_create_jwt_denylist.rb
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に関する記述を追加します。

api/app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist

  self.table_name = "jwt_denylist"
end
api/app/models/user.rb
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を編集します。

app/config/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リクエストに応答することを指定します。

app/controllers/users/registrations_controller.rb
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リクエストに応答することを指定します。

app/controllers/users/sessions_controller.rb
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は、生成されたトークンの有効期限を設定します。

config/initializers/devise.rb
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

https://github.com/waiting-for-dev/devise-jwt?tab=readme-ov-file#dispatch_requests

Applicationコントローラーを設定する

Applicationコントローラーにdeviseに関する記述を追加します。
アプリに合わせて許可するパラメーターを追加します。
ここでは、登録、ログイン、アカウント編集用パラメーターを指定してます。

api/app/controllers/application_controller.rb
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がありました。

https://github.com/waiting-for-dev/devise-jwt/issues/235

APIのみのモードでは、セッション・クッキーは使用しません。
Sessionをfalseに設定した偽のラック・セッション・ハッシュを使用し、エラーを回避することができました。

偽のラック・セッショモジュールを作成します。

app/controllers/concerns/rack_session_fix.rb
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