Deviseというgemのomniauthableを利用して、いろんなOAuth提供元サービスと連携orそのサービスを用いたログインを実現する方法。
こういうことやりたい人結構いるんじゃないかと思って、Wantedlyで実際にやってみた経験を大公開!!
Gemのインストール
devise
と各providerのomniauth
関連Gemをインストール
gem 'devise'
gem 'omniauth'
gem 'omniauth-facebook'
gem 'omniauth-github'
gem 'omniauth-google-oauth2'
gem 'omniauth-hatena'
gem 'omniauth-linkedin'
gem 'omniauth-mixi'
gem 'omniauth-twitter'
とりあえず、omniauth-'provider'でググって出てきたのを使えばいいと思うが、googleだけomniauth-google
は古いのでomniauth-google-oauth2
を使うよう気をつけること
各providerのOAuth設定
とりあえず、各providerでOAuthの申請をして、AccessKeyとAccessSecretを得る。CallbackURLはhttps://hogehoge.com/user/auth/[provider]/callback
とすること。
基本的には全部Web上で完結するが、Mixi先生だけ書類送付が必要だった。
production: &production
facebook:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
github:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
google:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
hatena:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
linkedin:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
mixi:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
twitter:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
development: &development
<<: *production
facebook:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
github:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
mixi:
key: xxxxxxxxxxx
secret: XXXXXXXXXXXXXXXXXXXXXXXXX
test:
<<: *development
Facebook, Github, Mixiは、OAuthするとき、どこからのアクセスかを判定してくる。そのため、開発用のlocalhost:3000
のURLを別個に登録しないといけなく、productionとdevelopmentで違うAccessKeyとAccessSecretを使う必要がある。
test環境に関してはmockを使うと思うので実際は何でもいいのだが、念のためdevelopment環境と同じ物を入れておく。
さて、後は各サービスでどういった情報を取得するか設定する。
Devise.setup do |config|
OAUTH_CONFIG = YAML.load_file("#{Rails.root}/config/omniauth.yml")[Rails.env].symbolize_keys!
# https://github.com/mkdynamic/omniauth-facebook
# https://developers.facebook.com/docs/concepts/login/
config.omniauth :facebook, OAUTH_CONFIG[:facebook]['key'], OAUTH_CONFIG[:facebook]['secret'], scope: 'email,publish_stream,user_birthday'
# https://github.com/intridea/omniauth-github
# http://developer.github.com/v3/oauth/
# http://developer.github.com/v3/oauth/#scopes
config.omniauth :github, OAUTH_CONFIG[:github]['key'], OAUTH_CONFIG[:github]['secret'], scope: 'user,public_repo'
# https://github.com/zquestz/omniauth-google-oauth2
# https://developers.google.com/accounts/docs/OAuth2
# https://developers.google.com/+/api/oauth
config.omniauth :google_oauth2,
OAUTH_CONFIG[:google]['key'], OAUTH_CONFIG[:google]['secret'], scope: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/plus.me https://www.google.com/m8/feeds', name: :google
# https://github.com/mururu/omniauth-hatena
# http://developer.hatena.ne.jp/ja/documents/auth/apis/oauth
config.omniauth :hatena, OAUTH_CONFIG[:hatena]['key'], OAUTH_CONFIG[:hatena]['secret']
# https://github.com/skorks/omniauth-linkedin
# https://developer.linkedin.com/documents/authentication
# https://developer.linkedin.com/documents/profile-fields
config.omniauth :linkedin, OAUTH_CONFIG[:linkedin]['key'], OAUTH_CONFIG[:linkedin]['secret'], scope: 'r_basicprofile r_emailaddress r_network',
fields: [
"id", "first-name", "last-name", "formatted-name", "headline", "location", "industry", "summary", "specialties", "positions", "picture-url", "public-profile-url", # in r_basicprofile
"email-address", # in r_emailaddress
"connections" # in r_network
]
# https://github.com/pivotal-sushi/omniauth-mixi
# http://developer.mixi.co.jp/connect/mixi_graph_api/api_auth/
config.omniauth :mixi, OAUTH_CONFIG[:mixi]['key'], OAUTH_CONFIG[:mixi]['secret']
# https://github.com/arunagw/omniauth-twitter
# https://dev.twitter.com/docs/api/1.1
config.omniauth :twitter, OAUTH_CONFIG[:twitter]['key'], OAUTH_CONFIG[:twitter]['secret']
end
googleはデフォルトでgoogle_oauth2という名前になるので、name: :google
として、名前にoauth2を付けなくて使えるようにしておこう。
scopeはそれぞれのproviderのドキュメントを読んで適切に設定して欲しいが、scopeの区切りが','なものと' 'なものの2種類あるので、気をつけてほしい。
さらにLinkedInはscopeを設定した上に、更にどのfieldを取得するかを明示しないと結果を返してくれない設計だった。
Modelの設計
それぞれのproviderに対し、1つ1つモデルを作ってもいいが、統一的に扱えるように、SocialProfile
という名前のモデルを作ろう。
まず、oauthの結果には、provider
と各ユーザーを識別するID:uid
が確実に含まれる。
次に、データにアクセスするためのaccess_token
ももちろんついてくる。access_secret
はproviderによっては返ってくる。
さて、その他にDBに入れるべきデータだが、自分が色々調べた結果、name
, nickname
(twitter id等ハンドルネーム), email
, url
, image_url
, description
(軽い紹介文)は多くのproviderに共通してあるようだった。これらはDBのカラムとして作っておくと良いだろう。他にもlocation
の情報などは結構なものに共通しているので入れたければ入れればいいと思う。
もちろんプロバイダ固有のフィールドもあるが、自分はother
というtext型のカラムを作って、store型で保存すると良いと思う。
後は、念のためcredentials
の情報とraw_info
の情報をJSON形式で保存しておけば、もし何か保存し忘れた時も復元できるので安心。
# == Schema Information
#
# Table name: social_profiles
#
# id :integer not null, primary key
# user_id :integer not null, indexed
# provider :string(255) not null, indexed => [uid]
# uid :string(255) not null, indexed => [provider]
# access_token :string(255)
# access_secret :string(255)
# name :string(255)
# nickname :string(255)
# email :string(255)
# url :string(255)
# image_url :string(255)
# description :string(255)
# other :text
# credentials :text
# raw_info :text
# created_at :datetime not null
# updated_at :datetime not null
class SocialProfile < ActiveRecord::Base
belongs_to :user
store :other
validates_uniqueness_of :uid, scope: :provider
def set_values(omniauth)
return if provider.to_s != omniauth['provider'].to_s || uid != omniauth['uid']
credentials = omniauth['credentials']
info = omniauth['info']
self.access_token = credentials['token']
self.access_secret = credentials['secret']
self.credentials = credentials.to_json
self.email = info['email']
self.name = info['name']
self.nickname = info['nickname']
self.description = info['description'].try(:truncate, 255)
self.image_url = info['image']
case provider.to_s
when 'hatena'
self.url = "https://www.hatena.ne.jp/#{uid}/"
when 'github'
self.url = info['urls']['GitHub']
self.other[:blog] = info['urls']['Blog']
when 'google'
self.nickname ||= info['email'].sub(/(.+)@gmail.com/, '\1')
when 'linkedin'
self.url = info['urls']['public_profile']
when 'mixi'
self.url = info['urls']['profile']
when 'twitter'
self.url = info['urls']['Twitter']
self.other[:location] = info['location']
self.other[:website] = info['urls']['Website']
end
self.set_values_by_raw_info(omniauth['extra']['raw_info'])
end
def set_values_by_raw_info(raw_info)
case provider.to_s
when 'google'
self.url = raw_info['link']
when 'twitter'
self.other[:followers_count] = raw_info['followers_count']
self.other[:friends_count] = raw_info['friends_count']
self.other[:statuses_count] = raw_info['statuses_count']
end
self.raw_info = raw_info.to_json
self.save!
end
end
生成は、
rails g model SocialProfile user:references provider uid name nickname email url image_url description other:text credentials:text raw_info:text
した後、
add_index :social_profiles, [:provider, :uid], unique: true
をmigrationファイルに加えて、rake db:migrate
。
Userモデルは以下のようにする。
class User < ActiveRecord::Base
devise :omniauthable
has_many :social_profiles, dependent: :destroy
def social_profile(provider)
social_profiles.select{ |sp| sp.provider == provider.to_s }.first
end
end
ルーティング設定
App::Application.routes.draw do
devise_for :user, controllers: {
omniauth_callbacks: "omniauth_callbacks",
sessions: "sessions"
}
#== Route Map
# user_omniauth_authorize /user/auth/:provider(.:format) omniauth_callbacks#passthru {:provider=>/facebook|github|google|hatena|linkedin|mixi|twitter/}
# user_omniauth_callback /user/auth/:action/callback(.:format) omniauth_callbacks#(?-mix:facebook|github|google|hatena|linkedin|mixi|twitter)
# new_user_session GET /user/sign_in(.:format) sessions#new
# user_session POST /user/sign_in(.:format) sessions#create
# destroy_user_session GET /user/sign_out(.:format) sessions#destroy
SessionControllerは適当に。特に上書きする必要がなければ、Devise::SessionsControllerをそのまま使ったんでOK
class SessionsController < Devise::SessionsController
def new
unless Rails.env.test?
redirect_to root_url
end
end
def destroy
super
session[:keep_signed_out] = true # Set a flag to suppress auto sign in
end
end
OmniauthCallbacksControllerの一例。以下のViewと微妙に関連しているのでViewも参照。
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def facebook; basic_action; end
def github; basic_action; end
def google; basic_action; end
def hatena; basic_action; end
def linkedin; basic_action; end
def mixi; basic_action; end
def hatena; basic_action; end
private
def basic_action
@omniauth = request.env['omniauth.auth']
if @omniauth.present?
@profile = SocialProfile.where(provider: @omniauth['provider'], uid: @omniauth['uid']).first
unless @profile
@profile = SocialProfile.where(provider: @omniauth['provider'], uid: @omniauth['uid']).new
@profile.user = current_user || User.create!(name: @omniauth['name'])
@profile.save!
end
if current_user
raise "user is not identical" if current_user != @profile.user
else
sign_in(:user, @profile.user)
end
@profile.set_values(@omniauth)
end
render :close, layout: false
end
View関係の設定
Viewなんて好みの問題なので、好きにしたらいいと思うが、自分はwindow.openを使ってsubwindowを開いてAuthorizeする方法が綺麗だと思う。
Oauthサービスと連携する部分のView
.social-block-wrapper
= render 'social_connect', provider: :facebook, display_name: 'Facebook', icon: 'icon-facebook-sign'
= render 'shared/social_connect', provider: :twitter, display_name: 'Twitter', icon: 'icon-twitter-sign'
= render 'shared/social_connect', provider: :google, display_name: 'Google+', icon: 'icon-google-plus-sign'
= render 'shared/social_connect', provider: :hatena, display_name: 'Hatena', icon: 'icon-hatena-sign'
= render 'shared/social_connect', provider: :github, display_name: 'GitHub', icon: 'icon-github-sign'
= render 'shared/social_connect', provider: :linkedin, display_name: 'LinkedIn', icon: 'icon-linkedin-sign'
= render 'shared/social_connect', provider: :mixi, display_name: 'Mixi', icon: 'icon-mixi-sign'
# take `provider`, `display_name` and `icon` as local variable
- connected = current_user.try(:social_profile, provider)
.social-block.cf
.title
%i{ class: "#{icon} #{provider}"}
%h3= display_name
.body
= t("auth.#{provider}")
= button_tag (connected ? t('auth.connected') : t('auth.connect')), id: "#{provider}-auth", class: "social-connect", disabled: (connected ? 'disabled' : nil)
OAuthが完了(失敗)した時、表示されるView
!!!
%html
%head
%meta{ charset: "utf-8" }
= render 'head_meta'
= stylesheet_link_tag 'application'
= javascript_include_tag 'header'
%body
%h1
- if @omniauth
Authorized
- else
Authorization Failed
= javascript_include_tag 'footer'
:javascript
success = #{@omniauth.present?};
function reflectOAuthResult() {
var $linkElement, linkElementId, provider;
provider = window.name;
linkElementId = "#" + provider + "-auth";
$linkElement = window.opener.$(linkElementId);
App.Utils.Auth.afterCallback($linkElement, success);
window.opener.authWaiting[provider] = false
};
$(window).on("beforeunload", reflectOAuthResult());
setTimeout(window.close, 1000);
Auth関係のJavascript
if (!(typeof App !== "undefined" && App !== null)) {
window.App = {};
}
App.Utils.Auth = {}
config = {
facebook: { width: 980, height: 630 },
github: { width: 1060, height: 500 },
google: { width: 800, height: 450 },
hatena: { width: 940, height: 600 },
linkedin: { width: 420, height: 630 },
mixi: { width: 980, height: 600 },
twitter: { width: 600, height: 600 },
}
App.Utils.Auth.windowOpen = (linkElementId) ->
window.authWaiting ||= {}
$linkElement = $('#' + linkElementId)
provider = $linkElement.data('provider')
authUrl = $linkElement.data('url')
# This function is necessary since Chrome cannot triger beforeunload when user clicks close button
checkSubwindowClosed = ->
if window.authWaiting[provider]
if subwindow.closed
App.Utils.Auth.afterCallback($linkElement, false)
window.authWaiting[provider] = false
else
setTimeout checkSubwindowClosed, 1000
App.Utils.Auth.beforeCallback($linkElement)
params = "width=#{config[provider]['width']},height=#{config[provider]['height']},resizable,scrollbars=yes,status=1"
subwindow = window.open(authUrl, provider, params) # set provider to subwindow name
window.authWaiting[provider] = true
checkSubwindowClosed()
App.Utils.Auth.beforeCallback = ($linkElement) ->
$linkElement.text(I18n.t('auth.connecting'))
$linkElement.attr('disabled', 'disabled')
App.Utils.Auth.afterCallback = ($linkElement, success) ->
if success
$linkElement.text(I18n.t('auth.connected'))
$linkElement.attr('disabled', 'disabled')
else
$linkElement.text(I18n.t('auth.connect'))
$linkElement.removeAttr('disabled')
$(document).on('click', '.social-connect', () ->
App.Utils.Auth.windowOpen($(this).attr('id'));
)