OAuth2 認証の実装: 小林研 Rails Tips (74)

このページの内容は以下のリポジトリに1日遅れで反映されます(記事執筆前に前日分をコミットしています)。

https://github.com/hkob/hkob_blog

注意: この日の作業は翌日全てなかったことにしています。順番に見ている人はこの日の作業をスキップしてください。

はじめに

Rails Tips の 74 回目です。Rails ガイドでは BASIC認証をしていますが、せっかくなので Devise を使った認証を使ってみます。ただし、パスワード管理などはしたくないため、Google による OAuth 認証をしてみましょう。

Rails をはじめよう - Railsガイド

11. セキュリティ

Google による OAuth2 については、以下のページを参考に実装しました。

[Rails]deviseとomniauthによるgoogleログイン

まず、Google Cloud にてプロジェクトを作成します。

その後、「API とサービス」から「OAuth 同意画面」をクリックします。「User Type」を外部とし、「作成」をクリックして、OAuth 2.0 クライアント ID を作成します。

これらの情報をクレデンシャルファイルに保存します。

EDITOR=vim rails credentials:edit -e development
Adding config/credentials/development.key to store the encryption key: 3bda9e36f804268c7fc3c0868395cf5d

Save this in a password manager your team can access.

If you lose the key, no one, including you, can access anything encrypted with it.

      create  config/credentials/development.key

Ignoring config/credentials/development.key so it won't end up in Git history:

      append  .gitignore

Configured Git diff driver for credentials.
Editing config/credentials/development.yml.enc...
File encrypted and saved.

中身は以下のようになります。

google:
  google_client_id: ***
  google_client_secret: ***

認証系の gem を追加し、bundle します。

gem "devise"
gem "omniauth"
gem "omniauth-rails_csrf_protection"
gem "omniauth-github"
gem "omniauth-google-oauth2"

最初に devise のインストールをを行います。

$ bin/rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml
===============================================================================

Depending on your application's configuration some manual setup may be required:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

     * Required for all applications. *

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"
     
     * Not required for API-only Applications *

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

     * Not required for API-only Applications *

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views
       
     * Not required *

===============================================================================

config/initializers/devise.rb に omniauth の設定を追加します。

  config.omniauth :google_oauth2,
                  Rails.application.credentials.google[:google_client_id],
                  Rails.application.credentials.google[:google_client_secret]

モデルは User として作成します。

$ bin/rails g devise User
      invoke  active_record
      create    db/migrate/20240211070843_devise_create_users.rb
      create    app/models/user.rb
      invoke    rspec
      create      spec/models/user_spec.rb
      insert    app/models/user.rb
       route  devise_for :users

初期状態で、user モデルは以下のようになっています。devise の後ろを修正します。

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end

omniauthable を設定します。ログイン情報を確認したいので、trackable も追加で登録しておきます。

class User < ApplicationRecord
  devise :omniauthable, omniauth_providers: %i[google_oauth2]
  devise :trackable

  validates :uid, uniqueness: {scope: :provider}
end

これに合わせて migration ファイルを調整します。name 属性は存在しなかったのですが、追加しておきました。

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false, default: ""
      t.string :name, null: false, default: ""

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.string   :current_sign_in_ip
      t.string   :last_sign_in_ip

      # Omniauthable
      t.string :provider
      t.string :uid

      t.timestamps null: false
    end

    add_index :users, :email, unique: true
  end
end

migration しておきます。

$ bin/rails db:migrate
== 20240211070843 DeviseCreateUsers: migrating ================================
-- create_table(:users)
   -> 0.0258s
-- add_index(:users, :email, {:unique=>true})
   -> 0.0016s
== 20240211070843 DeviseCreateUsers: migrated (0.0275s) =======================

$ bin/rails db:migrate RAILS_ENV=test
== 20240211070843 DeviseCreateUsers: migrating ================================
-- create_table(:users)
   -> 0.0091s
-- add_index(:users, :email, {:unique=>true})
   -> 0.0068s
== 20240211070843 DeviseCreateUsers: migrated (0.0160s) =======================

routes.rb に users のルーティング情報が追加されていますが、後ろにコールバック用のコントローラを追加します。

devise_for :users, controllers: {omniauth_callbacks: "users/omniauth_callbacks"}

そのコントローラを作る必要があるので、generator で作成します。私はいつも users/omniauth_callbacks という感じで作成するのですが、:: も使えるんですね。知りませんでした。

$ bin/rails g controller users::omniauth_callbacks
      create  app/controllers/users/omniauth_callbacks_controller.rb
      invoke  haml
      create    app/views/users/omniauth_callbacks
      invoke  rspec
      create    spec/requests/users/omniauth_callbacks_spec.rb
$ bin/rails g integration_test users::omniauth_callbacks
      invoke  rspec
    conflict    spec/requests/users/omniauth_callbacks_spec.rb
  Overwrite /Users/hkob/Library/CloudStorage/Dropbox/rails/hkob_blog/spec/requests/users/omniauth_callbacks_spec.rb? (enter "h" for help) [Ynaqdhm] 
       force    spec/requests/users/omniauth_callbacks_spec.rb

このコントローラの中身は以下のようになります。ほとんど Copilot が書いてくれました。

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token, only: :google_oauth2

  def google_oauth2
    @user = User.from_omniauth request.env["omniauth.auth"]

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication
      set_flash_message :notice, :success, kind: "Google" if is_navigational_format?
    else
      session["devise.google_data"] = request.env["omniauth.auth"].except :extra
      redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
    end
  end

  def failure
    redirect_to root_path, alert: "Authentication failed, please try again."
  end

  private

  def auth
    request.env["omniauth.auth"]
  end
end

User.from_omniauth が存在しないとエラーになっていました。models/user.rb に以下のメソッドを追加します。

  def self.from_omniauth(auth)
    find_or_create_by(uid: auth.uid, provider: auth.provider) do |user|
      user.email = auth.info.email
      user.name = auth.info.name
      user.skip_confirmation!
    end
  end

ほとんどの OAuth2 の説明は、すでにパスワードによるアカウント認証を作成した上で説明していました。今回は、OAuth2 のみの認証にするので、うまくリダイレクトできないようです。調べたところ、カスタマイズしたリダイレクトを設定すればいいとのことです。

deviseでログイン必須ページ、リダイレクトページ指定 - Qiita

まず、config/initializers/devise.rb に以下の設定を追加します。

  config.warden do |manager|
    manager.failure_app = CustomAuthenticationFailure
  #   manager.intercept_401 = false
  #   manager.default_strategies(scope: :user).unshift :some_external_strategy
  end

lib/custom_authentication_failure.rb を追加します。

touch lib/custom_authentication_failure.rb

ファイルの中身は以下のように記載しました。

class CustomAuthenticationFailure < Devise::FailureApp
  def redirect_url
    user_google_oauth2_omniauth_authorize_path
  end
end

ここまでの実装で実行したところ、画面に以下のようなメッセージが出てきました。

Not found. Authentication passthru.

このメッセージで探したところ以下のページにぶつかりました。

SNS認証におけるNot found. Authentication passthru.エラーについて - Qiita

これに従って、config/initializers/omniauth.rb を作成します。

touch config/initializers/omniauth.rb

内容は以下のようにします。

Rails.application.config.middleware.use OmniAuth::Builder do
  OmniAuth.config.allowed_request_methods = [:post, :get]
end

ここまで実装しましたが、「このアプリが無効なリクエストを送信したため、ログインできません。」となってしまいました。明日、Google に認証に飛ばすためのページを一枚追加したいと思います。

おわりに

今日は時間切れで途中になってしまいました。明日続きを記述します。