LoginSignup
258

Devise+OmniAuthでQiita風の複数プロバイダ認証

Last updated at Posted at 2015-08-08

経緯

  • QiitaのようなOAuth認証を実装しようと試みた。
  • ググってみると、ユーザーをログインさせた後どうするかについて言及されていないものが多かった。
  • いろんな記事からアイデアを吸収して試行錯誤した結果、自分の思い通りの仕様に仕上がったので今後のためにメモ。

スタートの時点でこれらの記事が非常に参考になった。

やりたいこと

  • Devise認証付Railsアプリに、OmniAuthを追加し各ユーザーを複数のプロバイダで認証できる様にしたい。(ユーザーと全てのプロバイダーを紐付けする)
  • Qiitaの仕様を目標とする。
  • プロバイダーを増やせるように拡張性を持たせる。

Screenshot 2015-08-13 20.04.34.png

Screenshot 2015-08-15 10.11.13.png

新規登録(3パターン)

  1. ユーザー名、email、パスワードを入力し、認証。
  2. Facebookで認証。
  3. Twitterで認証。

OAuth認証時のユーザーの状況(3パターン)

  1. 新規ユーザーの場合、新規ユーザーアカウントを作成。
  2. ユーザーがログイン済みの場合、認証されたプロバイダで今後ログインに使用できるようにする。
  3. 以前OAuth認証したことのあるユーザーがログインしていない場合、認証データに基づきユーザーアカウントをクエリし、ログインさせる。

email確認

  • どのパターンで新規登録しても、必ずemailを実際に送信して確認する。
  • 確認emailのリンクをクリックすると即ログインされる。
  • emailを変更する場合も、毎回emailを実際に送信して確認する。

ユーザーとプロバイダーとの紐付け

  • ログイン中のユーザーが、プロフィールページにある各プロバイダーへのリンクボタンを押し、OmniAuthの認証をクリアすれば、そのプロバイダーが紐付けされる。次回ログイン時に紐付けされたプロバイダーが利用できる。
  • ログイン前に、予めユーザーに紐付けされていないプロバイダー経由でログインしようとすると、新規ユーザーとみなされ、新規アカウントが生成される。

パスワード

  • OmniAuthの認証ログインユーザーは、パスワード入力が免除される。

実装

関連gemをインストール

deviseと各providerのomniauth関連Gemをインストール。

Gemfile
...
# ruby 2.3.1

gem 'rails', '>= 5.0.0.rc2', '< 5.1'
gem 'devise', '4.2'
gem 'omniauth', '~> 1.3', '>= 1.3.1'
gem 'omniauth-facebook', '~> 3.0'
gem 'omniauth-twitter', '~> 1.2', '>= 1.2.1'
...

Deviseをセットアップ

  • 公式ドキュメントに従って設定する。
  • 僕の設定は、confirmablereconfirmableを有効にしてある。
app/models/user.rb
class User < ApplicationRecord
  ...
  # Devise modules.
  devise :database_authenticatable, :registerable, :recoverable, :rememberable,
         :trackable, :validatable, :confirmable, :omniauthable
  ...
/config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.reconfirmable = true
  ...
end
db/migrate/20160701172600_devise_create_users.rb
class DeviseCreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

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

      ## 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


      t.timestamps null: false
    end

    add_index :users, :email,                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

各providerのキー・シークレットトークンを入手

各providerのOAuth設定

/config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.omniauth :facebook, "KEY", "SECRET"
  config.omniauth :twitter, "KEY", "SECRET"
  ...
end

キー等の管理方法は色々あるようです。例えば、この記事では
config/omniauth.ymlという別のファイルで管理する方法を紹介されています。

モデル

Userモデル

  • 例ではusernameカラムを追加してあるがなくても良いと思う。
/app/models/user.rb
# == Schema Information
#
# Table name: users
#
#  id                     :integer          not null, primary key
#  email                  :string           default(""), not null
#  encrypted_password     :string           default(""), not null
#  reset_password_token   :string
#  reset_password_sent_at :datetime
#  remember_created_at    :datetime
#  sign_in_count          :integer          default(0), not null
#  current_sign_in_at     :datetime
#  last_sign_in_at        :datetime
#  current_sign_in_ip     :inet
#  last_sign_in_ip        :inet
#  confirmation_token     :string
#  confirmed_at           :datetime
#  confirmation_sent_at   :datetime
#  unconfirmed_email      :string
#  created_at             :datetime         not null
#  updated_at             :datetime         not null
#  username               :string
#

class User < ActiveRecord::Base
  #...
  has_many :social_profiles, dependent: :destroy

  # deviseモジュールの設定
  devise :database_authenticatable, :registerable, :recoverable, :rememberable,
         :trackable, :validatable, :confirmable, :omniauthable
  #...

  TEMP_EMAIL_PREFIX = 'change@me'
  TEMP_EMAIL_REGEX = /\Achange@me/

  # emailの登録状況を判定するカスタムvalidatorを使用するためのおまじない。
  validates :email, presence: true, email: true

  def social_profile(provider)
    social_profiles.select{ |sp| sp.provider == provider.to_s }.first
  end

  # 本物のemailがセットされているか確認。
  def email_verified?
    self.email && self.email !~ TEMP_EMAIL_REGEX
  end

  # email確認がされていない状態にする。
  def reset_confirmation!
    self.update_column(:confirmed_at, nil)
  end

  # Userモデル経由でcurrent_userを参照できるようにする。
  def self.current_user=(user)
    # Set current user in Thread.
    Thread.current[:current_user] = user
  end

  # Userモデル経由でcurrent_userを参照する。
  def self.current_user
    # Get current user from Thread.
    Thread.current[:current_user]
  end
end

emailの登録状況を判定するカスタムvalidatorを作る。

app/validators/email_validator.rb
require 'mail'
class EmailValidator < ActiveModel::EachValidator
  def validate_each(record,attribute,value)
    begin
      m = Mail::Address.new(value)
      # We must check that value contains a domain, the domain has at least
      # one '.' and that value is an email address
      r = m.domain!=nil && m.domain.match('\.') && m.address == value
    rescue Exception => e
      r = false
    end
    record.errors[attribute] << (options[:message] || "is invalid") unless r

    # 仮emailから変更しないとエラーになるようにする。
    record.errors[attribute] << 'must be given. Please give us a real one!!!' unless value !~ User::TEMP_EMAIL_REGEX
  end
end

SocialProfileモデル

rails g model SocialProfile user:references provider uid name nickname email url image_url description others:text credentials:text raw_info:text

生成されたマイグレーションに、インデックスを追加。

db/migrate/20160709210000_create_social_profiles.rb
class CreateSocialProfiles < ActiveRecord::Migration[5.0]
  def change
    create_table :social_profiles do |t|
      t.references :user, foreign_key: true
      t.string :provider
      t.string :uid
      t.string :name
      t.string :nickname
      t.string :email
      t.string :url
      t.string :image_url
      t.string :description
      t.text :others
      t.text :credentials
      t.text :raw_info

      t.timestamps
    end
    add_index :social_profiles, [:provider, :uid], unique: true
  end
end

そしてrake db:migrate

/app/models/social_profile.rb
# == Schema Information
#
# Table name: social_profiles
#
#  id          :integer          not null, primary key
#  user_id     :integer
#  provider    :string
#  uid         :string
#  name        :string
#  nickname    :string
#  email       :string
#  url         :string
#  image_url   :string
#  description :string
#  others      :text
#  credentials :text
#  raw_info    :text
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#

class SocialProfile < ApplicationRecord
  belongs_to :user
  store      :others

  validates_uniqueness_of :uid, scope: :provider

  def self.find_for_oauth(auth)
    profile = find_or_create_by(uid: auth.uid, provider: auth.provider)
    profile.save_oauth_data!(auth)
    profile
  end

  def save_oauth_data!(auth)
    return unless valid_oauth?(auth)

    provider = auth["provider"]
    policy   = policy(provider, auth)

    self.update_attributes( uid:         policy.uid,
                            name:        policy.name,
                            nickname:    policy.nickname,
                            email:       policy.email,
                            url:         policy.url,
                            image_url:   policy.image_url,
                            description: policy.description,
                            credentials: policy.credentials,
                            raw_info:    policy.raw_info )
  end

  private

    def policy(provider, auth)
      class_name = "#{provider}".classify
      "OAuthPolicy::#{class_name}".constantize.new(auth)
    end

    def valid_oauth?(auth)
      (self.provider.to_s == auth['provider'].to_s) && (self.uid == auth['uid'])
    end
end

認証データの処理

OAuthPolicy

  • 各プロバイダーが似たようで微妙に異なるデータを返してくるので、OAuthPolicyオブジェクトを介してOAuthデータを加工する。
  • これでSocialProfileモデルでは一貫した処理が可能になり、データの永続化に専念できる。
/app/helpers/o_auth/o_auth_policy.rb
module OAuthPolicy
  class Base
    attr_reader :provider, :uid, :name, :nickname, :email, :url, :image_url,
                :description, :other, :credentials, :raw_info
  end

  class Facebook < OAuthPolicy::Base
    def initialize(auth)
      @provider    = auth["provider"]
      @uid         = auth["uid"]
      @name        = auth["info"]["name"]
      @nickname    = ""
      @email       = ""
      @url         = "https://www.facebook.com/"
      @image_url   = auth["info"]["image"]
      @description = ""
      @credentials = auth["credentials"].to_json
      @raw_info    = auth["extra"]["raw_info"].to_json
      freeze
    end
  end

  class Twitter < OAuthPolicy::Base
    def initialize(auth)
      @provider    = auth["provider"]
      @uid         = auth["uid"]
      @name        = auth["info"]["name"]
      @nickname    = auth["info"]["nickname"]
      @email       = ""
      @url         = auth["info"]["urls"]["Twitter"]
      @image_url   = auth["info"]["image"]
      @description = auth["info"]["description"].try(:truncate, 255)
      @credentials = auth["credentials"].to_json
      @raw_info    = auth["extra"]["raw_info"].to_json
      freeze
    end
  end
end

OAuthService

認証データに基づきユーザーアカウントを探したりする諸々の処理をOAuthServiceとしてまとめた。

/app/helpers/o_auth/o_auth_service.rb
module OAuthService
  class GetOAuthUser

    def self.call(auth)
      # 認証データに対応するSocialProfileが存在するか確認し、なければSocialProfileを新規作成。
      # 認証データをSocialProfileオブジェクトにセットし、データベースに保存。
      profile = SocialProfile.find_for_oauth(auth)
      # ユーザーを探す。
      # 第1候補:ログイン中のユーザー、第2候補:SocialProfileオブジェクトに紐付けされているユーザー。
      user = current_or_profile_user(profile)
      unless user
        # 第3候補:認証データにemailが含まれていればそれを元にユーザーを探す。
        user = User.where(email: email).first if verified_email_from_oauth(auth)
        # 見つからなければ、ユーザーを新規作成。
        user ||= find_or_create_new_user(auth)
      end
      associate_user_with_profile!(user, profile)
      user
    end

    private

      class << self

        def current_or_profile_user(profile)
          user = User.current_user.presence || profile.user
        end

        # 見つからなければ、ユーザーを新規作成。emailは後に確認するので今は仮のものを入れておく。
        # TEMP_EMAIL_PREFIXを手掛かりに後に仮のものかどうかの判別が可能。
        # OmniAuth認証時はパスワード入力は免除するので、ランダムのパスワードを入れておく。
        def find_or_create_new_user(auth)
          # Query for user if verified email is provided
          email = verified_email_from_oauth(auth)
          user = User.where(email: email).first if email
          if user.nil?
            temp_email = "#{User::TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com"
            user = User.new(
              username: auth.extra.raw_info.name,
              email:    email ? email : temp_email,
              password: Devise.friendly_token[0,20]
            )
            # email確認メール送信を延期するために一時的にemail確認済みの状態にする。
            user.skip_confirmation!
            # email仮をデータベースに保存するため、validationを一時的に無効化。
            user.save(validate: false)
            user
          end
        end

        def verified_email_from_oauth(auth)
          auth.info.email if auth.info.email && (auth.info.verified || auth.info.verified_email)
        end

        # ユーザーとSocialProfileオブジェクトを関連づける。
        def associate_user_with_profile!(user, profile)
          profile.update!(user_id: user.id) if profile.user != user
        end
      end
    end
end

ルーティング

/config/routes.rb
Rails.application.routes.draw do
  ...

  # Deviseのコントローラを上書きするため。
  devise_for :users, controllers: { omniauth_callbacks: 'omniauth_callbacks',
                                    registrations: "registrations",
                                    confirmations: "confirmations" }

  # OmniAuth認証後、email入力を求める処理のため。
  match '/users/:id/finish_signup' => 'users#finish_signup', via: [:get, :patch], as: :finish_signup
  ...
end

コントローラ

omniauth_callbacks_controller

/app/controllers/omniauth_callbacks_controller.rb
class OmniauthCallbacksController < Devise::OmniauthCallbacksController

  # いくつプロバイダーを利用しようが処理は共通しているので本メソッドをエイリアスとして流用。
  def callback_for_all_providers
    unless env["omniauth.auth"].present?
      flash[:danger] = "Authentication data was not provided"
      redirect_to root_url and return
    end
    provider = __callee__.to_s
    user = OAuthService::GetOAuthUser.call(env["omniauth.auth"])
    # ユーザーがデータベースに保存されており、且つemailを確認済みであれば、ユーザーをログインする。
    if user.persisted? && user.email_verified?
      sign_in_and_redirect user, event: :authentication
      set_flash_message(:notice, :success, kind: provider.capitalize) if is_navigational_format?
    else
      user.reset_confirmation!
      flash[:warning] = "We need your email address before proceeding."
      redirect_to finish_signup_path(user)
    end
  end
  alias_method :facebook, :callback_for_all_providers
  alias_method :twitter,  :callback_for_all_providers
end

users_controller

/app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :authenticate_user!, except: :finish_signup

  ...

  # OAuth認証による新規登録の締めを司るアクション。
  # ユーザーデータを更新に成功したら、email確認メールを送付する。
  # GET   /users/:id/finish_signup - 必要データの入力を求める。
  # PATCH /users/:id/finish_signup - ユーザーデータを更新。
  def finish_signup
    @user = User.find(params[:id])
    if request.patch? && @user.update(user_params)
      @user.send_confirmation_instructions unless @user.confirmed?
      flash[:info] = 'We sent you a confirmation email. Please find a confirmation link.'
      redirect_to root_url
    end
  end

  ...

  private

    # user_paramsにアクセスするため。
    def user_params
      accessible = [ :username, :email ]
      accessible << [ :password, :password_confirmation ] unless params[:user][:password].blank?
      params.require(:user).permit(accessible)
    end
    ...
end

フォーム

app/views/users/finish_signup.html.slim
.row
  .col-sm-offset-3.col-sm-6
    h1 Add Email
    = simple_form_for(@user, url: finish_signup_path(@user)) do |f|
      = f.input :username, autofocus: true, class: 'form-control', placeholder: "Username"
      = f.input :email, autofocus: true, class: 'form-control', placeholder: "Email"
      .form-group
        = f.submit 'Add email', class: 'btn btn-primary'

social_profiles_controller

Facebook/Twitterへの接続を解除する。

/app/controllers/social_profiles_controller.rb
class SocialProfilesController < ApplicationController
  before_action :authenticate_user!
  before_action :correct_user!

  def destroy
    @profile.destroy
    flash[:success] = "Disconnected from #{@profile.provider.capitalize}"
    redirect_to root_url
  end

  private

    def correct_user!
      @profile = SocialProfile.find(params[:id])
      redirect_to root_url and return unless @profile.user_id == current_user.id
    end
end

confirmations_controller

email確認メールのリンクをクリックしたら即、ログインするため上書き。

/app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController

  # Override
  def show
    self.resource = resource_class.confirm_by_token(params[:confirmation_token])
    yield resource if block_given?

    if resource.errors.empty?
      set_flash_message(:notice, :confirmed) if is_flashing_format?

      sign_in(resource) #<== この一行を加えるのみ

      respond_with_navigational(resource){ redirect_to after_confirmation_path_for(resource_name, resource) }
    else
      respond_with_navigational(resource.errors, :status => :unprocessable_entity){ render :new }
    end
  end
end

registrations_controller

OmniAuthで認証のユーザーに対して、パスワード入力を免除させるため上書き。

/app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController

  protected

  # Override
  def update_resource(resource, params)
    resource.update_without_password(params)
  end
end

テスト

参考資料

Devise

OmniAuth

実装技術

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
258