Devise+OmniAuthで複数サービスから同じユーザーでログインする方法

plataformatec/deviseintridea/omniauth を使って、Twitterでも、Facebookでも、GitHubでもログインできるような機能を作る方法を書いておく。

最初に

devise には Omniauthable という「これ使えば簡単に実装できるよ!」って雰囲気のmoduleがいます。
しかし、これは罠です。使うと死にます。

Currently, Devise's Omniauthable module does not work with multiple models. No need to worry though, as the Omniauthable module is but a simple wrapper around OmniAuth.

引用: https://github.com/plataformatec/devise/wiki/OmniAuth-with-multiple-models

ソースコード

# db/migrate/20160102181416_create_authentications.rb
class CreateAuthentications < ActiveRecord::Migration
  def change
    create_table :authentications do |t|
      t.references :user, index: true, foreign_key: true, null: false
      t.string :provider, null: false
      t.string :uid, null: false
      t.string :token, null: false

      t.timestamps null: false
    end

    add_index :authentications, [:provider, :uid], unique: true
  end
end

# app/models/authentication.rb
class Authentication < ActiveRecord::Base
  belongs_to :user

  validates :user_id, presence: true
  validates :provider, presence: true
  validates :uid, presence: true, uniqueness: { scope: :provider }
  validates :token, presence: true

  def self.from_omniauth(auth, user)
    obj = find_or_initialize_by provider: auth.provider, uid: auth.uid
    obj.token = auth.credentials.token
    transaction do
      obj.user ||= user || User.create!(email: auth.info.email, password: Devise.friendly_token[0, 20])
      obj.save! if obj.changed?
    end
    obj
  end
end

# app/controllers/authentications_controller.rb
class AuthenticationsController < ApplicationController
  def create
    @authentication = Authentication.from_omniauth(auth_hash, current_user)

    if user_signed_in?
      flash[:notice] = 'Successfully linked that account'
    else
      sign_in @authentication.user, event: :authentication
      flash[:notice] = 'Signed in'
    end

    redirect_to request.env['omniauth.origin'] || root_path
  end

  private

  def auth_hash
    request.env['omniauth.auth']
  end
end

説明

いくつかメモ残しておかないと忘れそうなので、メモ書いておく。

ユーザーの情報

deviseのwikiには下記のようなコードが書かれています。

def self.from_omniauth(auth)
  where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
    user.email = auth.info.email
    user.password = Devise.friendly_token[0,20]
    user.name = auth.info.name   # assuming the user model has a name
    user.image = auth.info.image # assuming the user model has an image
  end
end

これ、最初の罠です。
first_or_create のブロックは初回しか実行されません。このコードだと、ユーザーが各サービスでプロフィールなどを変更しても、アプリ側のログイン時に更新されなくなってしまいます。

↓みたいに書いておけば、ログイン時に毎回情報が更新されるようになります。

def self.from_omniauth(auth)
  user = find_or_initialize_by(provider: auth.provider, uid: auth.uid)
  user.attributes = {
    email: auth.info.email,
    password: Devise.friendly_token[0, 20],
    name: auth.info.name, # assuming the user model has a name
    image: auth.info.image # assuming the user model has an image
  }
  user.save if user.changed?
  user
end

find_or_initialize_byif user.changed? で無駄なSQLが発行されないようにしてる。

ログイン状態による分岐

@authentication = Authentication.from_omniauth(auth_hash, current_user)

if user_signed_in?
  flash[:notice] = 'Successfully linked that account'
else
  sign_in @authentication.user, event: :authentication
  flash[:notice] = 'Signed in'
end

flash[:notice] に書いてある通りだけど、ログイン時はログインユーザーの連携サービスとして関連付けるようにしている。 実際に関連付けている処理はmodelで行っている。

transaction do
  obj.user ||= user || User.create!(email: auth.info.email, password: Devise.friendly_token[0, 20])
  obj.save! if obj.changed?
end

リダイレクト

redirect_to request.env['omniauth.origin'] || root_path

OmniAuthでは認証後に表示するページをorigin というパラメータで渡すことができる。 この動作に対応するため、上記のようなコードにしている。

参考にしたページ