Edited at

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

More than 3 years have passed since last update.

Deviseというgemのomniauthableを利用して、いろんなOAuth提供元サービスと連携orそのサービスを用いたログインを実現する方法。

こういうことやりたい人結構いるんじゃないかと思って、Wantedlyで実際にやってみた経験を大公開!!


Gemのインストール

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


Gemfile

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先生だけ書類送付が必要だった。


config/omniauth.yml

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環境と同じ物を入れておく。

さて、後は各サービスでどういった情報を取得するか設定する。


config/initializers/devise.rb

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形式で保存しておけば、もし何か保存し忘れた時も復元できるので安心。


app/models/social_profile.rb

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

した後、


db/migrate/20141203000000_create_social_profiles.rb

add_index :social_profiles, [:provider, :uid], unique: true


をmigrationファイルに加えて、rake db:migrate

Userモデルは以下のようにする。


app/models/user.rb

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



ルーティング設定


config/routes.rb

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


app/controllers/sessions_controller.rb

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も参照。


app/controllers/omniauth_callbacks_controller.rb

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


app/views/application/_social.html.haml

.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'


app/views/application/_social_connect.html.haml

# 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


app/views/omniauth_callbacks/close.html.haml

!!!

%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


app/assets/javascripts/header/init.js

if (!(typeof App !== "undefined" && App !== null)) {

window.App = {};
}


app/assets/javascripts/utils/auth.js.coffee

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'));
)