14
7

More than 1 year has passed since last update.

【devise-token-auth x OmniAuth2.0 x SPA】でログイン機能を実装

Last updated at Posted at 2022-02-22

devise-token-authを使用して以下のようなログイン機能を実装する方法についてのまとめ。

  1. ログイン用ボタンを押下
  2. 新規タブでプロバイダーの認証画面に遷移
  3. 認証が完了した後、コールバックにてユーザー登録
  4. コールバックのウィンドウを閉じて、フロント側に認証情報を渡す

gem

  • devise:
    Railsで認証を実装するためのgem
  • omniauth: マルチプロバイダー認証を行うためのgem
  • omniauth-rails-csrf-protection: CVE-2015-9284の脆弱性に対処するためのgem。OmniAuth2.0からはCSRF対策のためプロバイダーの認証画面にはPOSTメソッドを使用してアクセスする。
  • devise-token-auth:
    deviseとomniauthを使用してトークンによる認証を実装するためのgem

API側の実装

インストールと修正

Gemfile
gem 'devise_token_auth', git: 'https://github.com/lynndylanhurley/devise_token_auth.git'

gem 'omniauth-rails_csrf_protection'

devise_token_authにdevie及びomniauthが包含されているので、追記する必要はない。また、CSRF対策のためプロバイダーの認証にはPOSTメソッドでアクセスしたいので、23d6b81b1を適用するためにgithubから取得するようにしている('22/2/22)。

bundle installをしたら

$ rails g devise_token_auth:install User auth

をしてdevise-token-auth用のモデルを生成する。このコマンドで以下の変更が加えられる

  • An initializer will be created at config/initializers/devise_token_auth.rb. Read more.
  • A model will be created in the app/models directory. If the model already exists, a concern (and fields for Mongoid) will be included at the file. Read more.
  • Routes will be appended to file at config/routes.rb. Read more.
  • A concern will be included by your application controller at app/controllers/application_controller.rb. Read more.
  • For ActiveRecord a migration file will be created in the db/migrate directory. Inspect the migrations file, add additional columns if necessary, and then run the migration:
rake db:migrate

apiとして実装するためconfig/routes.rbを以下に修正してdevise-token-authのコントローラーをネームスペースに収容する。

config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for(
        'User',
        at: 'auth'
      )
    end
  end
end

今回はomniauthによる認証のみを実装するので、deviseのomniauthableモージュルのみを使用するため、生成されたマイグレーションファイル及びモデルの内容を以下のように修正する。

db/migrate/*_devise_token_auth_create_users.rb
class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table(:users) do |t|
      ## Required
      t.string :provider, null: false
      t.string :uid, null: false

      ## 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 :nickname
      t.string :image
      t.string :email
      t.text :description

      ## Tokens
      t.text :tokens

      t.timestamps
    end

    # add_index :users, :email,                unique: true
    add_index :users, %i[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

モデルには登録の際にAuth Hash Schemaに従ったハッシュが渡され、assing_provider_attrsメソッドによって、モデルがinfo内の属性を持っていれば代入される。deviseとOmniAuthを使用した登録については以下を参照。

deviseを別途インストールしていないのでuser.rbextend Devise::Modelsdeviseの前に追記。

app/models/user.rb
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  extend Devise::Models
  devise :omniauthable 
  include DeviseTokenAuth::Concerns::User
end

またUserモデルにencrypted_passwordをもたせていないので、omniauth_collbacks_controller.rb及びconfig/routes.rbを以下のように上書き。

app/controllers/api/v1/auth/omniauth_collbacks_controller.rb
class Api::V1::Auth::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
  protected

  def handle_new_resource
    @oauth_registration = true
    # don't set password
    # set_random_password
  end
end
config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for(
        'User',
        at: 'auth',
        controllers: {
          omniauth_callbacks: 'api/v1/auth/omniauth_callbacks',
        },
      )
    end
  end
end

最後にRailsのCSRF機能オフにするため以下の設定を追記。

config/application.rb
module AppName
  class Application < Rails::Application
    # Don't use csrf tokens
    config.action_controller.allow_forgery_protection = false
  end
end

プロバイダーの登録

以下を参照。コールバック用のパス(デフォルトの場合/omniauth)と実際にリクエストするパス(/auth)が異なるので注意。

ここまでで/api/v1/auth/:providerにPOSTメソッドでアクセスすれば認証ページにリダイレクト、コールバックによる登録が可能になる。

SPA側の実装

次にSPA側の実装を行う。POSTメソッドでプロバイダー認証画面を新規タブで開くための処理は以下のようになる。authURL(provider){{APIのベースURL}}/api/v1/auth/:providerを返す関数。

type Provider = 'github'
const openAuth = (provider: Provider): Window | null => {
  // open with post method to protect from csrf
  let blankForm = document.createElement('form')
  blankForm.target = provider
  blankForm.method = 'post'
  blankForm.action = `${authURL(provider)}?omniauth_window_type=newWindow`

  // connect form
  blankForm.style.display = 'none'
  document.body.appendChild(blankForm)

  let authWindow = window.open('', provider)
  blankForm.submit()

  // cut form
  document.body.removeChild(blankForm)

  return authWindow
}

新規タブで開くウィンドウ名(window.openの第2引数)をform.targetの値にすることで、一時的にabout:blankで開かれたタブにform.submit()でPOSTメソッドを送信している。window.openの詳細は以下。

認証用URLにomniauth_window_type=newWindowを渡すことで、コールバックの際に外部ウィンドウ用のページがdevise-token-authによって返される。

omniauth_external_window.html.erb
<!DOCTYPE html>
<html>
  <head>
    <script>
      /*
        The data is accessible in two ways:

        1. Using the postMessage api, this window will respond to a
            'message' event with a post of all the data. (This can
            be used by browsers other than IE if this window was
            opened with window.open())
        2. This window has a function called requestCredentials which,
            when called, will return the data. (This can be
            used if this window was opened in an inAppBrowser using
            Cordova / PhoneGap)
      */

      var data = JSON.parse(decodeURIComponent('<%= ERB::Util.url_encode( @data.to_json ) %>'));

      window.addEventListener("message", function(ev) {
        if (ev.data === "requestCredentials") {
          ev.source.postMessage(data, '*');
          window.close();
        }
      });
      function requestCredentials() {
        return data;
      }
      setTimeout(function() {
        document.getElementById('text').innerHTML = (data && data.error) || 'Redirecting...';
      }, 1000);
    </script>
  </head>
  <body>
    <pre id="text">
    </pre>
  </body>
</html>

このページにコメントとして記載されている1の方法で、dataにアクセスする。dataには@auth_params及びuserのハッシュが展開されたオブジェクトが代入されている。

このウィンドウに定期的にpostMessageを行い、コールバックページから返されたメッセージのe.dataに認証用の情報が含まれていれば情報をクライアント側に保存し、インターバルを抜ける。Reactの場合は以下のようなフックを使用する。postMessageの詳細は以下。

  const [authProvider, setAuthProvider] = useState<Provider | null>(null)
  const [openAuthWindow, setOpenAuthWindow] = useState<Window | null>(null)
  const { setCurrentUser } = useAuth()

  useEffect(() => {
    if (!openAuthWindow) return
    // See https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/views/devise_token_auth/omniauth_external_window.html.erb
    window.addEventListener('message', (e) => {
      const data = camelcaseKeys(e.data) as { [key: string]: any }
      // See https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L189
      const authParamKeys = ['authToken', 'clientId', 'uid']
      if (
        !authParamKeys.reduce(
          (acc: boolean, cur: string): boolean =>
            acc && Object.keys(data).includes(cur),
          true
        )
      ) {
        return
      }

      setAuthHeaders({
        accessToken: data.authToken,
        client: data.clientId,
        uid: data.uid,
      })
      setCurrentUser(data as User)

      clearInterval(timer)
      setAuthProvider(null)
      setOpenAuthWindow(null)
    })

    const timer = setInterval(() => {
      openAuthWindow.postMessage('requestCredentials', domainURL)
    }, 200)
  }, [openAuthWindow])

devise-token-authについての詳細は以下及びソースコードを参照。

14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7