7839

雑草魂エンジニアブログ

【Rails】devise_token_authでToken認証を実装する

結構前にやったことであるが、Rails6でAPIを作成した際に、認証機能を簡単に実装できるdevise_token_authを使ってToken認証機能を実装したので備忘録として残しておく。

実行環境

Sampleコードもおいているので、試したい方いましたらご自由にどうぞ。

インストール

Gemfileに devise_token_auth を追加して、bundle install を実行する

# For Add Access-Token
gem 'devise_token_auth'

deviceのUserモデルを作成する前に、deviceをインストールしていないと、rails db:migrate時に以下のような「Userモデルに対するdeviseメソッドが定義されていない」というエラーが出てしまう。

/usr/local/bundle/gems/activerecord-6.1.5/lib/active_record/dynamic_matchers.rb:22:in `method_missing': undefined method `devise' for User:Class (NoMethodError)

そのため、先にdeviceをインストールしてから、authのUserモデルを作成するようにする。

bash-5.1# bundle exec rails g devise:install
// ~省略~
bash-5.1# bundle exec rails g devise_token_auth:install User auth
Running via Spring preloader in process 90
      create  config/initializers/devise_token_auth.rb
      insert  app/controllers/application_controller.rb
        gsub  config/routes.rb
      create  db/migrate/20220422074222_devise_token_auth_create_users.rb
      create  app/models/user.rb

一部不要なUserのカラムを削除して、bundle exec rails db:migrateを実行してDBのテーブルを作成する。

class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table(:users) do |t|
      ## Required
      t.string :provider, :null => false, :default => "email"
      t.string :uid, :null => false, :default => ""
      ## Database authenticatable
      t.string :encrypted_password, :null => false, :default => ""
      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at
      t.boolean  :allow_password_change, :default => false
      ## Rememberable
      t.datetime :remember_created_at
      ## Confirmable
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.string   :unconfirmed_email # Only if using reconfirmable
      ## Lockable
      # t.integer  :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at
      ## User Info
      t.string :name
      t.string :email
      ## Tokens
      t.text :tokens
      t.timestamps
    end
    add_index :users, :email,                unique: true
    add_index :users, [:uid, :provider],     unique: true
    add_index :users, :reset_password_token, unique: true
    add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

devise_token_auth設定

設定に関しては、以下を採用することにする。

  • tokenを毎回のリクエストで更新しないように変更する(デフォルトでは各リクエストごとに認証情報が更新されてしまう設定になっている。)
  • tokenの有効期限は2週間とする
DeviseTokenAuth.setup do |config|
  # By default the authorization headers will change after each request. The client is responsible for keeping track of the changing tokens. Change this to false to prevent the Authorization header from changing after each request.
  config.change_headers_on_each_request = false
  # By default, users will need to re-authenticate after 2 weeks. This setting determines how long tokens will remain valid after they are issued.
  config.token_lifespan = 2.weeks

CORS対応

APIとクライアントが異なるドメインに存在する場合は、クロスオリジンリクエストを許可するようにRailsAPIを構成する必要がある。CORS(Cross-Origin Resource Sharing)を実現するために、rack-corsのgemを利用する。以下をGemfileに追加して、bundle installを実行する。

gem 'rack-cors'

以下を設定ファイルに追記する。(ただし、任意のドメインからのクロスドメインリクエストが許可されるため、必要なドメインのみをホワイトリストとして設定するほうがセキュリティ的に安全である。)

module App
  class Application < Rails::Application
    # ~省略~
    # CORS
    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins '*'
        resource '*',
          :headers => :any,
          :expose => ['access-token', 'expiry', 'token-type', 'uid', 'client'],
          :methods => [:get, :post, :options, :delete, :put]
      end
    end
  end
end

User作成

初回のUserに関しては、seedで流し込む。

# API-user
User.create!(
  name: 'test-user1',
  email: 'test-user1@test.com',
  password: 'password1'
)

bundle exec rails db:seedでsampleユーザーを作成する。

routes設定

ルーティングに関しては、今回APIとして /api/v1 を先頭につけることにする。そのため、namespaceを用いてルーティングを作成する。

Rails.application.routes.draw do
  root 'home#index'
  namespace :api do
    namespace :v1 do
      get '/whoami', to: 'tests#whoami'
      # device_token_auth
      mount_devise_token_auth_for 'User', at: 'auth'
    end
  end
end

authに関するルーティングは、mount_devise_token_auth_for 'User', at: 'auth'で自動生成される。

bash-5.1# bundle exec rails routes | grep auth
new_api_v1_user_session         GET    /api/v1/auth/sign_in(.:format)         devise_token_auth/sessions#new
api_v1_user_session             POST   /api/v1/auth/sign_in(.:format)         devise_token_auth/sessions#create
destroy_api_v1_user_session     DELETE /api/v1/auth/sign_out(.:format)        devise_token_auth/sessions#destroy
new_api_v1_user_password        GET    /api/v1/auth/password/new(.:format)    devise_token_auth/passwords#new
edit_api_v1_user_password       GET    /api/v1/auth/password/edit(.:format)   devise_token_auth/passwords#edit
api_v1_user_password            PATCH  /api/v1/auth/password(.:format)        devise_token_auth/passwords#update
                                PUT    /api/v1/auth/password(.:format)        devise_token_auth/passwords#update
                                POST   /api/v1/auth/password(.:format)        devise_token_auth/passwords#create
cancel_api_v1_user_registration GET    /api/v1/auth/cancel(.:format)          devise_token_auth/registrations#cancel
new_api_v1_user_registration    GET    /api/v1/auth/sign_up(.:format)         devise_token_auth/registrations#new
edit_api_v1_user_registration   GET    /api/v1/auth/edit(.:format)            devise_token_auth/registrations#edit
api_v1_user_registration        PATCH  /api/v1/auth(.:format)                 devise_token_auth/registrations#update
                                PUT    /api/v1/auth(.:format)                 devise_token_auth/registrations#update
                                DELETE /api/v1/auth(.:format)                 devise_token_auth/registrations#destroy
                                POST   /api/v1/auth(.:format)                 devise_token_auth/registrations#create
api_v1_auth_validate_token      GET    /api/v1/auth/validate_token(.:format)  devise_token_auth/token_validations#validate_token

上記より、以下を用いることにする。

  • sign_up : POST /api/v1/auth or GET /api/v1/auth/sign_up
  • sign_in : POST /api/v1/auth/sign_in
  • sign_out : DELETE /api/v1/auth/sign_out

(おまけ)namespace, scope, moduleの違い

それぞれの使い分けは、URL PathとController Pathの変更の影響範囲によって異なる。

 URL Path  Controller Path
scope 変更される 変更しない
namespace 変更される 変更される
module 変更しない 変更される

controller設定

通常devise_token_authでは、sign-inのリクエストを送信したレスポンスHeaderにあるaccess-token, client, uidを毎回リクエストヘッダに追加することで、認証することができる。( RFC 6750 Bearer Token に準拠している。)

before_action :authenticate_user!, unless: :devise_controller?

このメソッドをcontrollerに追加することで、actionを実行する前にまず認証情報を確認する。ただし、毎回access-token, client, uidの3つをヘッダーに追加するのは面倒である。そこで、Authorizationヘッダーを用いた認証に変更したいと思った。

Bearer認証を満たすには以下の実装をする必要があった。

  • Authorization: Bearer のリクエストに認証する
  • tokenは任意の token68 文字列とする
  • レスポンスヘッダーに、WWW-Authenticate: を追加する
    • 200 OKの場合、WWW-Authenticate: Bearer realm=""
    • 401 Unauthorizedの場合、WWW-Authenticate: Bearer realm="token_required"
    • 400 Bad Requestの場合、WWW-Authenticate: Bearer error="invalid_request"
    • 401 Unauthorizedの場合、WWW-Authenticate: Bearer error="invalid_token"
    • 403 Forbiddenの場合、WWW-Authenticate: Bearer error="insufficient_scope"

まず、sign_in時のレスポンスを変更する。routesで一部controllerに変更を加える設定を追加する。sign_in時には、devise_token_auth/sessions#createのactionが動いているので、sessionsのcreateのみに変更を加える。

# device_token_auth
mount_devise_token_auth_for 'User', at: 'auth', controllers: {
  sessions: 'custom/sessions',
}

実際に変更する内容は、以下の通りである。

  • ヘッダーにaccess-tokenが含まれていれば、access-token, client, uidjsonデータからBase64文字列のtokenを生成する
  • Headerから認証情報を確認するのは面倒なので、Bodyのjsonデータにtokenと期限を追加する
class Custom::SessionsController < DeviseTokenAuth::SessionsController
  prepend_after_action :join_tokens, only: [:create]

  private
    def join_tokens
      return if response.headers['access-token'].nil?

      auth_json = {
        'access-token' => response.headers['access-token'],
        'client' => response.headers['client'],
        'uid' => response.headers['uid'],
      }
      response.headers.delete_if{|key| auth_json.include? key}
      access_token = CGI.escape(Base64.encode64(JSON.dump(auth_json)))

      json_body = JSON.parse(response.body)
      new_json_body = {
        'user' => json_body['data'],
        'access_token' => access_token,
        'expiry' => response.headers['expiry']
      }
      response.body = JSON.dump(new_json_body)
    end
end

こうすることで、sign_in時には以下のようなレスポンスとなる。

{
    "user": {
        "email": "test-user1@test.com",
        "uid": "test-user1@test.com",
        "id": 1,
        "provider": "email",
        "allow_password_change": false,
        "name": "test-user1"
    },
    "access_token": "eyJhY2Nlc3MtdG9rZW4iOiJsZkNqTmhxRDg3Q0hQSGhNMnQ0SXVBIiwiY2xp%0AZW50IjoicGhqeVVfZjdXS1pqN2otWTV6a3VtdyIsInVpZCI6InRlc3QtdXNl%0AcjFAdGVzdC5jb20ifQ%3D%3D%0A",
    "expiry": "1651998480"
}

次に、取得したtokenをAuthorizationヘッダーに追加してAPIをコールする。

devise_token_authではaccess-token, client, uidで認証するので、認証する前に、サーバーサイド側でtokenをaccess-token, client, uidに分離する処理を追加する。

class Api::BaseController < ApplicationController
  before_action :split_tokens
  before_action :authenticate_api_v1_user!, unless: :devise_controller?

  private
    def split_tokens
      return if request.headers['Authorization'].nil?

      token = JSON.parse(Base64.decode64(CGI.unescape(request.headers['Authorization'].match(/Bearer /).post_match)))
      request.headers['access-token'] = token['access-token']
      request.headers['client'] = token['client']
      request.headers['uid'] = token['uid']
    end
end

これで、Authorizationヘッダーを追加することで認証ができるようになった。ヘッダーに付加すべき情報をまとめることができて、処理が楽になった。

class Api::V1::TestsController < Api::BaseController
  def whoami
    render json: current_api_v1_user, status: :ok
  end
end

http://localhost:3000/api/v1/whoami にリクエストを送信し、Authorizationヘッダーが正常であればユーザー情報を取得することができ、認証できない場合にはエラーが返却される。

まとめ

devise_token_authでRailsAPIのToken認証を実装することができた。

ただし、WWW-Authenticateをレスポンスヘッダーに付加できていないので、まだBearer認証の実装を満たすことができていない。また、今回正常系の確認しかできておらず、異常系、エラーハンドリングの実装ができていないので、引き続き実装を進めていきたい。