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


Deviseというgemのomniauthableを利用して、いろんなOAuth提供元サービスと連携orそのサービスを用いたログインを実現する方法。
こういうことやりたい人結構いるんじゃないかと思って、[Wantedly](https://www.wantedly.com/)で実際にやってみた経験を大公開！！


## Gemのインストール

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

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

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

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

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


```ruby: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
```


生成は、

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

した後、

```ruby:db/migrate/20141203000000_create_social_profiles.rb
add_index :social_profiles, [:provider, :uid], unique: true
```

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

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

```ruby: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
```

## ルーティング設定

```ruby: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'));
)
```