604
593

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Ruby on RailsAdvent Calendar 2014

Day 3

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

Last updated at Posted at 2014-12-03

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'));
)
604
593
3

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
604
593

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?