Edited at

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

More than 1 year has passed since last update.


経緯


  • QiitaのようなOAuth認証を実装しようと試みた。

  • ググってみると、ユーザーをログインさせた後どうするかについて言及されていないものが多かった。

  • いろんな記事からアイデアを吸収して試行錯誤した結果、自分の思い通りの仕様に仕上がったので今後のためにメモ。

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

- RailsでいろんなSNSとOAuth連携/ログインする方法

- Rails4 で Devise と OmniAuth で、Twitter/Facebook の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

実装技術