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

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

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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

原注

本記事は、Rubyの隠れたgem: Deviseシリーズ記事の一部です。

  1. Rails: Deviseを徹底理解する(1)基礎編(翻訳)
  2. Rails: Deviseを徹底理解する(2)応用編(翻訳)-- 本記事

本シリーズ記事のパート1では、サンプルアプリを用いてDeviseを導入し、モジュール、ヘルパー、ビュー、コントローラー、ルーティングを探りました。

このパート2では、Deviseのさらに高度な利用法として、OmniAuthやAPI認証、Authtrailの使い方を探求します。

さっそく始めましょう!

🔗 OmniAuthで認証する

最近のWebアプリケーションは、TwitterやFacebookなどのSNSからGoogleやGitHubなどに至るさまざまな認証プロバイダを利用してログインするオプションを提供していることがほとんどです。

この便利なマルチプロバイダ認証機能の多くは、OmniAuthというライブラリで提供されています。

omniauth/omniauth - GitHub

OmniAuthは、Ruby向けの柔軟で強力な認証ライブラリであり、さまざまな外部認証プロバイダと統合できるようになります。

OmniAuthは、さまざまなOAuthプロバイダーに接続するためのシンプルで統一されたAPIを提供します。OmniAuthは特に、SNSアカウントでサインアップまたはログインするオプションをユーザーに提供したい場合に便利です。OmniAuthを使うことで、RailsアプリケーションにSNSログイン機能を手軽に追加できます。

Devise gemでOmniAuthを併用すると、ユーザーの認証と権限管理がさらに簡単になります。Deviseの組み込み認証機能を活用しながら、外部認証プロバイダのサインインオプションにはOmniAuthを利用できます。

🔗 OmniAuthをDeviseで利用する

前述したように、OmniAuthを使えばさまざまなサードパーティの認証プロバイダと統合できるようになります。本記事ではGitHubを認証プロバイダとして利用することにします。

🔗 OmniAuthをインストールする

アプリのGemfileに以下の行を追加します。

# Gemfile

gem 'omniauth'
gem 'omniauth-github'  # OmniAuthでは、認証プロバイダごとに独自のgemがある

omniauth/omniauth-github - GitHub

OmniAuth 2.0以降を使う場合は、以下も追加する必要があります。

#  Gemfile

gem 'omniauth-rails_csrf_protection'

cookpad/omniauth-rails_csrf_protection - GitHub

omniauth-rails_csrf_protection gemは、OAuthフローへのあらゆるGETリクエストを無効化します。また、OAuthリクエストの直前にRailsのCSRFトークン検証を挿入します。この2つの操作は、OAuth認証フローを標的とするクロスサイトリクエストフォージェリ攻撃を軽減するためのものです。

参考: §3 クロスサイトリクエストフォージェリ(CSRF)-- Rails セキュリティガイド - Railsガイド

次にbundle installを実行して、このgemをインストールします。

🔗 新規GitHub OAuthアプリを作成する

今度は、GitHub上で新しいOAuthアプリを作成する必要があります。このアプリは認証権限を持つユーザーとして振る舞い、必要に応じて簡単に取り消せます。

最初の手順として、GitHubアカウントのプロフィールの「Settings」ページに移動します。
次に、左側のメニューで「Developer settings」をクリックします。以下のような画面が表示されるので、そこで新しいOAuthアプリを作成できます。

GitHub developer settings page

「Register new application」をクリックすると、以下のような画面が表示されます。

Create new OAuth app

以下の要領で項目に入力します。

Application name
新規OAuthアプリに適切な名前を指定します。
Homepage url
今はさしあたってhttp://localhost:3000/を入力します。production環境では実際のホームページURLを入力します。
Application description
必須ではありませんが、OAuthアプリが増えたときに区別できるように入力しておくとよいでしょう。
Authorization callback url
これは必須の入力項目です。通常は、http://<アプリのURL>/users/auth/<アプリケーションプロバイダ名>/callbackのような形式のOAuthコールバックURLに従います。ただし、Googleなど一部のOAuthプロバイダはこの形式に沿っていないので注意が必要です。

入力が終わったら、「Register application」をクリックします。次の画面で、新しいアプリのsecret(秘密情報)を生成し、安全な場所にメモしておいてください(注: secretは一度しか表示されません)。

🔗 Deviseイニシャライザを設定する

Deviseのイニシャライザファイルconfig/initializers/devise.rbを開き、GitHubに関連するOmniAuthセクションに移動します。おそらくコメントアウトされているので、コメントを解除して、新しいGitHub OAuthアプリのIDとsecretを使うように編集します。

# config/initializers/devise.rb

...
 config.omniauth :github, ENV['GITHUB_APP_ID'], ENV['GITHUB_APP_SECRET'], scope:  'user,public_repo'
...

🔗 OmniAuthのコールバックコントローラを作成する

Deviseのコントローラーを既に生成済みであれば、OmniauthCallbacksControllerがカスタマイズ用に準備されています。このコントローラがない場合は、手動で作成し、以下のように編集します。

# app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # OmniAuthプロバイダはここで手軽に追加できる
  ...

  def github
    @user = User.from_omniauth(request.env["omniauth.auth"])
    sign_in_and_redirect @user # sign_in_and_redirectはOAuthのメソッド
  end
  ...
end

上のコードについていくつか補足します。

  • from_omniauth: Userモデル内で実装するメソッドです。
  • sign_in_and_redirect: OAuth内部のメソッドです。

🔗 Userモデルを変更するマイグレーションを追加

今度は、Userモデルにカラムをいくつか追加する必要があります。具体的にはproviderカラムとuidカラムです。

bundle exec rails g migration AddOmniauthToUsers provider:string uid:string

bundle exec rails db:migrateを実行して手順を完了します。

🔗 DeviseのモデルをOmniauthableでOmniAuthに対応する

ここでは、UserモデルにDeviseのOmniauthableモジュールを追加する必要があります。

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable
  ...
end

それが終わったら、from_omniauthメソッドを追加します。これは、さきほど設定したUsers::OmniauthCallbacksControllerから呼び出されます。

# app/models/user.rb

class User < ApplicationRecord
  ...

  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user |
      user.provider = auth.provider
      user.uid = auth.uid
      user.email = auth.info.email
      user.password = Devise.friendly_token[0,20]
    end
  end

  ...

end

残る作業はあと1つ、Deviseのビューにログインリンクを追加する作業です。

🔗 ログインリンクのセットアップ

Deviseは、ユーザー登録用ビューやログイン用ビューに、デフォルトで適切な認証プロバイダのログインリンクを自動的に追加します。このリンクはGETメソッドを使いますが、OmniAuth 2.0以降ではPOSTリクエストが好ましいことがわかっています。
そういうわけで、現在のリンクを無効にして、独自のPOSTリクエストを挿入する必要があります。

<!-- app/views/users/sessions/new.html.erb -->
<!-- デフォルトでPOSTリクエストを行うbutton_toを使っている点に注目 -->

<%= button_to "Sign in with GitHub", user_github_omniauth_authorize_path %>

以上で、Ruby on Rails 7アプリにDeviseとGitHub OAuth認証をセットアップ完了しました。対応するアプリの完全なソースコードはtasker_appリポジトリで入手可能です。

iamaestimo/tasker_app - GitHub

次は、別の高度なユースケースである「API呼び出しをDeviseで認証する方法」について解説します。

🔗 DeviseでAPI認証を行う

昨今、API経由でアプリに接続可能であることをユーザーから期待されることは珍しくありません。このセクションでは、そうしたユーザーリクエストをDeviseで安全に認証する方法について見ていきます。

ブラウザベースの認証は一般にCookieベースで行われますが、API認証はほとんどの場合JWT(JSON Web Token)と呼ばれるトークンを用いて行われます。これらのトークンはHTTPヘッダー内でやり取りされます。

原注

このセクションでは、RailsのAPI専用アプリケーションを使うことが前提となっています。この前提に沿いたい場合は、新しいアプリケーションをrails new app_name --apiで作成してください。

🔗 JWTベースの認証フロー

既に述べたように、API認証はJWTトークンに基づいているので、JWTベースの認証フローがどのように行われるかを理解しておくことが重要です。基本的に、以下にまとめた一般的な手順の流れで行われます。

  • ユーザークライアントがAPIアプリに呼び出しを行う。
  • APIアプリはJWT(JSON Web Token)と呼ばれる認証トークンを返します。この認証トークンはcookieの代わりに利用可能です。
  • 以後、ユーザークライアントから送信されるリクエストは、このトークンをHTTP Authorizationヘッダーで利用する形で行われます。
  • 以後のユーザーは、Deviseの「セッション破棄(session destroy)」操作を実行可能になります。この操作を行うと、認証トークンが破棄され、ユーザーはログアウトします。

それでは、実際にこのフローを実行してみましょう。

最初は、CORS(cross-origin resource sharing: オリジン間リソース共有)と呼ばれるものから始まります。

参考: オリジン間リソース共有 (CORS) - HTTP | MDN

🔗 CORSをセットアップする

CORSは、外部からのリクエストを許可するためにAPIアプリをセットアップします。CORSはHTTPベースのセキュリティポリシーであり、外部からのリクエストがアプリケーションでどのように処理されるかを定義します。
デフォルトのCORSは、初期リクエストを行ったドメインと異なるドメインからのリクエスト(つまり、異なる「origin」からのリクエスト)をすべてブロックします。

CORSを適切に処理するために、rack-corsという便利なgemを使います。

cyu/rack-cors - GitHub

Gemfileで以下の行をコメント解除してから、bundle installコマンドを実行します。

# Gemfile
gem 'rack-cors'

また、対応するCORSイニシャライザファイルを開き、以下のように変更します。

# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "*"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: %w[Authorization Uid]
  end
end

ここで行った内容について重要な点をいくつか指摘しておきます。

origins "*"
「このAPIアプリは任意のソースからのリクエストを受信可能になる」という意味です。
expose: %w[Authorization Uid]
rack-cors gemは、デフォルトではAuthorizationヘッダーやUidヘッダーを公開しませんが、認証トークンが通過するにはこれらのヘッダーが必要です。

それが終わったら、次はDevise gemとそれに対応するDevise-JWT gemをインストールします。

🔗 RailsアプリにDeviseとDevise-JWTを追加する

devise-jwt gemは、JWTトークンを利用可能にするDeviseの拡張機能です。

waiting-for-dev/devise-jwt - GitHub

これらのgemをGemfileに追加してからbundle installを実行します。

# Gemfile

gem 'devise'
gem 'devise-jwt'

Deviseをインストールするジェネレータbundle exec rails g devise:installを実行します。

🔗 モデルの生成と設定

ここでは、2つのモデルをセットアップする必要があります。
通常のDeviseユーザーモデル(bundle exec rails g devise User)に加えて、無効化戦略(revocation storategy: 言い換えればユーザーがAPIからログアウトする方法)に利用するモデルも必要です。

bundle exec rails g model jwt_revocation

以下のように、通常のDeviseユーザーモデルをAPI認証用に変更し、JWTトークンを認証可能にするJwtAuthenticatableモジュールを追加し、トークンの無効化戦略に使うJwtDenylistモデルを定義します。

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
  :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end

次に、第2のJwtDenylistモデルを以下のように設定して、利用する無効化戦略と無効化用テーブル名を参照します。

# app/models/jwt_denylist.rb

class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist
  self.table_name = 'jwt_denylist'
end

次のセクションでは、認証トークンの無効化とその必要性について説明します。

🔗 トークンの無効化が重要な理由

トークンの無効化が重要な理由とは何でしょうか?JWTトークンは、状態を持たないステートレス(stateless)だからです。サーバーは、トークンに署名すること以外にそのトークンについて一切関知しません。このようなシナリオでは、対応するトークンを無効化してユーザーをログアウトする方法がサーバーにはありません。個別のトークンを無効化する手段が存在しないため、そのための手段を作成し、サーバーにそれを使うよう指示する必要があります。

トークンを無効化するときに実際に行われているのは、トークンの一意の部分であるjti(JWT ID)を抽出して、定義済みの無効化戦略に沿ってjtiを利用することです。

もちろん、これは「トークンの無効化戦略とはそもそも何なのか」という別の疑問を呼び起こします。トークンの無効化戦略とは、要するに、サーバーがどのような方法でトークンを無効化するかを定義したものです。基本的な無効化戦略は以下の3種類です。

JTIMatcher戦略
この戦略では、ユーザーモデルにjtiという一意のカラムを追加します。このカラムは、無効化用テーブルとしても振る舞います。ユーザーがリクエストを行うたびに、ヘッダー内のjtiを、保存済みのトークンと照合し、両者が一致する場合にのみアクセスが許可されます。
Denylist戦略
この戦略では、jtiとトークンの失効(expiry: つまり無効化されたトークンのexp)をデータベースのテーブルに保存します。ユーザーがリクエストを行うたびに、現在のトークンのjtiを、データベース内で無効化済みのものと比較するチェックが行われます。両者が一致した場合、そのユーザーのリクエストは拒否されます。
Allowlist戦略
この戦略は、ある意味で最初のJTIMatcher戦略と似ていますが、JWT IDを保存するテーブルと、ユーザーのトークンを保存する別のテーブルが「一対多のリレーションシップ」になる点が異なります。リクエストが行われるたびに、Allowlistテーブルに保存されているユーザーのjtiが、トークンを持つマッチング用テーブルに保存されているものと一致するかどうかをチェックし、両者が一致した場合にのみアクセスが許可されます。

このトークン無効化の概要は、明らかに相当簡略化したものなので、詳しくは以下を参照してください。

waiting-for-dev/devise-jwt - GitHub

🔗 JWTトークンの秘密鍵に署名する設定を行う

ユーザーとそのリクエストを認証するためにセキュアなトークンを利用したいので、それらに署名する方法が必要です。ここで秘密鍵(secret key)が登場します。ここではRailsの秘密鍵であるsecret_key_baseを使わず、別の秘密鍵を新たに生成することが強く推奨されています。

bundle exec rake secretを実行して一意の鍵を生成してから、Deviseのイニシャライザを編集して、その鍵を使うようにします。

# config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET']
  end
  ...
end

最後に、コントローラをセットアップします。

🔗 コントローラのセットアップ

DeviseでAPI認証を実装する最後のステップは、コントローラのセットアップです。話を簡単にするため、「ユーザー登録を担当するコントローラ」と「セッション用のコントローラ」の2つのコントローラをセットアップします。

コントローラを手作りすることにしましょう。最初はRegistrationsControllerです。

# app/controllers/users/registrations/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController

  respond_to :json

  private

  def respond_with(resource, _opts={})
    successful_registration && return if resource.persisted?
    failed_registration
  end

  def successful_registration
    render json: { message: "You've registered successfully", user: current_user }, status: :ok
  end

  def failed_registration
    render json: { message: "Something went wrong. Please try again" }, status: :unprocessable_entity
  end

end

このコントローラでは以下を行います。

  • リクエストに対してJSONをレスポンスとして返すように設定する。
  • 登録の成功または失敗の結果を返すrespond_withアクションを指定する。

次はSessionsControllerです。

# app/controllers/users/sessions/sessions_controller.rb

class Users::SessionsController < Devise::SessionsController

  respond_to :json

  private

  def respond_with(resource, _opts={})
   render json: { message: "Welcome, you're in", user: current_user }, status: :ok
  end

  def respond_to_on_destroy
    successful_logout && return if current_user
    failed_logout
  end

  def successful_logout
    render json: { message: "You've logged out" }, status: :ok
  end

  def failed_logout
    render json: { message: "Something went wrong." }, status: :unauthorized
  end

end

登録用のコントローラーと同様に、このコントローラーもJSONでレスポンスを返すように指定します。また、ユーザーがログインに成功した場合のrespond_withアクションと、ユーザーがサインアウトするためのrespond_to_on_destroyを定義します。

以上で、DeviseとJWTトークンを利用する、実際に動くAPI認証フローが完成しました!

最後のセクションでは、DeviseとAuthtrailでユーザーログインをトラッキングする方法について簡単に説明します。

🔗 DeviseのログインをAuthtrailでトラッキングする

アプリのユーザーがアカウントにログインするたびに、ユーザーのIPアドレスやログインのタイムスタンプなどの詳細情報を含む通知メールを送信したいとしたら、どうすれば実現できるでしょうか?

そのためには、ユーザーログインをトラッキングして、その情報をユーザーに送信する通知メールで利用する必要があります。ただし、最初にユーザーログインをトラッキングする方法が必要です。これは、Authtrailという便利なgemで実現できます。このgemはDeviseとの相性も優れています。

ankane/authtrail - GitHub

🔗 Authtrailをインストールする

最初のステップは、bundle add authtrailを実行してAuthtrail gemをインストールすることです。

さらに、アプリケーションのデータベースにメールアドレスやIPアドレスなどのユーザー識別情報を保存する場合は、Lockbox gemとBlind Index gemを組み合わせる形で、このデータをproduction環境で暗号化しておくことを強く推奨します。

ankane/lockbox - GitHub

ankane/blind_index - GitHub

次に、Authtrailジェネレータを実行して、イニシャライザファイルと、ログインデータを保存するテーブルのマイグレーションを作成します。

# ここではencryptionフラグにlockboxを指定してジェネレータを実行しているが、
# `--encryption=none`を指定すれば暗号化なしにもできる

bundle exec rails g authtrail:install --encryption=lockbox
bundle exec rails db:migrate

🔗 Authtrailのしくみ

ユーザーがログインしようとするたびに、以下の重要な詳細情報を含む新しいAuthtrailレコードが作成されます。

  • ログインで使われたメールアドレス
  • ログインの成功/失敗
  • ログインが失敗した場合の理由
  • ユーザーのIPアドレスやreferrerなどの多くの情報

この情報は必要に応じてさまざまな形で利用できます。
たとえば、ユーザーのアカウントに対してログインが試行されたことを通知するメールをユーザーに送信するときに、ログインに使われたメールアドレスやIPアドレスの情報もメールに含められます。

Authtrailでできることをすべて知るには、Authtrailのドキュメントをお読みください。

🔗 まとめ

本シリーズでは、Devise gemについて詳しく解説しました。

パート1では、Deviseの基礎を理解し、Deviseの「モジュール」「ヘルパー」「ビュー」「コントローラ」「ルーティング」のしくみについても学びました。
最後のパート2では、DeviseをOAuthやAuthtrailと併用する方法や、API認証で利用する方法を詳しく見てきました。

本シリーズが、皆さんにとってDevise認証のすべてを学ぶうえで有用なガイドとなることを願っています。

Happy coding!

P.S. Ruby Magicの記事をいち早くお読みになりたいのであれば、ぜひRuby Magicニュースレターに登録して、記事を見逃さずに読めるようにしましょう!

関連記事

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

Devise::SessionsControllerのcreateアクションで認証を回避する際の注意点


CONTACT

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