Rails+DeviseへのOmniauthの導入（ざっくり仕組み、CSRF対策、単体テスト含む）

##内容
　RailsアプリにOmniauth認証(google_oauth2, facebook）を導入する方法とその過程で学んだことを紹介する記事です。全体の流れやコードの意図を説明した記事はあまり見つからなかったと思ったので、その辺りを中心に解説したいと思います。

##対象
　rails初心者でOmniauth認証の導入に挑む人。

　もし某スクールの後輩で見てくれた人がいた場合、
　非常に面白い機能なので、まずは自力で挑戦することをお勧めします。
　(そもそも違う環境でちゃんと動くとも、正解とも限りません。。。)
　少しでもご参考になれば、と思って書きます。

##前提条件
-ruby 2.5.1p57
-Rails 5.2.3
-gem 'devise' 4.7.1
（ローカル環境のみの対応です）
　
##1.そもそも
　omniauthでは、あるアプリにおけるログイン認証の代わりや、外部機能の使用許可をすることができます。
本実装においては、前者の機能を使用しています。
流れとしては、本アプリのパスワード入力を、SNSへのログイン(≒Cookieによりほぼ自動ログイン)で代用するイメージです。以下に全体図のイメージを示します。
　![Qiita Oauth用資料.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542326/d3256499-0e18-f4be-aaae-99c77e29ffd1.jpeg)
青矢印のフローを実装していきます！

##2.実装
###2-1.gemの導入
まず、gemfileに以下を追記後、bundle installを実施します。

```ruby:Gemfile
gem 'omniauth-facebook'
gem 'omniauth-google-oauth2'
```

###2-2.設定
各SNSサイト(Google developers console, facebook for developers)にて、
URLの登録および、ID, SECRET KEYを取得します。
(ご参考サイト様：https://qiita.com/hidepino/items/a1eb9d2f32ce33389f20）

環境変数を設定します。

```ruby:config/initializers/devise.rb
config.omniauth :facebook, ENV['FACEBOOK_ID'], ENV['FACEBOOK_KEY']
config.omniauth :google_oauth2, ENV['GOOGLE_ID'], ENV['GOOGLE_KEY']
```

ターミナルにて、"vim ~/.bash_profile"を実行し、取得したIDとキーを記入します。

```ruby:bash_profile
export FACEBOOK_ID="取得したID"
export FACEBOOK_KEY="取得したキー"
export GOOGLE_ID="取得したID"
export GOOGLE_KEY="取得したキー"
```
   
"source ~/.bash_profile"を実行し、環境変数を有効化しましょう。


###2-3.routingの設定
　SNS側からcallbackが来た際に使用するコントローラーを定義してあげます。

```ruby:routes.rb
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
```


###2-4.callbackコントローラーの作成、記述 (イメージ図の手順④⑤に当たります）

　"rails g devise:controllers users"を実行し、users/omniauth_callbacks_controllerを作成し、以下を記述します。ここでは、callbackが来た際に行うアクションを設定しています。SNS側から来た情報であるauth_hashは、request.env["omniauth.auth"]として使用していきます。
クラスメソッド"from_omniauth"は次のステップでUser.rbに定義します。

```ruby:controllers/users/omniauth_callbacks_controller.rb
def facebook
    @user = User.from_omniauth(request.env["omniauth.auth"])

    if @user.persisted?  #もし@userがDBに既にいたら、ログイン状態にします  
      sign_in_and_redirect @user, event: :authentication 
      set_flash_message(:notice, :success, kind: 'Facebook') if is_navigational_format?
    else　#もし@userがDBにいない場合、新規登録ページにリダイレクトします
      session["devise.facebook_data"] = request.env["omniauth.auth"]
　　　　#データをsessionに入れることによって、新規登録ページの入力欄に、予め情報を入れておくなどが可能になります。
      redirect_to 新規登録ページ
    end
  end

  def google_oauth2
    @user = User.from_omniauth(request.env["omniauth.auth"])

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication 
      set_flash_message(:notice, :success, kind: 'google') if is_navigational_format?
    else
      session["devise.google_data"] = request.env["omniauth.auth"][:info]
      #google認証の場合は、なぜかauth_hashの容量が大きく、一瞬で容量オーバーとなるため、新規登録時に必要な情報のみをsessionに渡すこととしました。（おそらく画像データのせい？）
      redirect_to 新規登録ページ
    end
  end
```



###2-5.メソッドの定義　(イメージ図の手順④⑤に当たります）
####ユーザー登録の流れを設定します。 ここは設計により異なります！
既存ユーザーであるかの識別は,uidやemailアドレスにて、実施されている記事を多く見受けましたが、
本実装では、usersテーブルとsns_credentialsテーブルを別で用意したため、
（1人のuserが複数のsns_credentialsを持つことを想定しています。）
ユーザーの識別はemailで行うことにしました。

さらに、既存ユーザーがいなかった場合に関して、
ここでuser, sns_credentialをDBへ登録することもできますが、
本アプリにて必要な情報が、auth_hash上で欠けている場合を想定し、
インスタンスの作成に留めました。

```ruby:models/user.rb
def self.from_omniauth(auth)
    user = User.where(email: auth.info.email).first
    sns_credential_record = SnsCredential.where(provider: auth.provider, uid: auth.uid)
    if user.present?
      unless sns_credential_record.present?
        SnsCredential.create(
          user_id: user.id,
          provider: auth.provider,
          uid: auth.uid
        )
      end
    elsif
      user = User.new(
        id: User.all.last.id + 1,
        email: auth.info.email,
        password: Devise.friendly_token[0, 20],
        nickname: auth.info.name,
        last_name: auth.info.last_name,
        first_name: auth.info.first_name,
      )
      SnsCredential.new(
        provider: auth.provider,
        uid: auth.uid,
        user_id: user.id
      )
    end 
  user
  end
```

###2-6.リンクの導入

最後にViewにリンク先を記入して終了です！！

```ruby:view.html.erb
<%= link_to "Sign in with Facebook", user_facebook_omniauth_authorize_path %>
<%= link_to "Sign in with Google", user_google_oauth2_omniauth_authorize_path %>
```

###2-7.CSRF対策

と言いたいところですが、Omniauth認証はCSRF脆弱性が指摘されているので、
以下の対策用のgemを導入し、リンクの書き方を変更して、本当の終了です。

```ruby:Gemfile
gem "omniauth-rails_csrf_protection"
```

```ruby:rspec/view.html.erb
<%= link_to "Sign in with Facebook", user_facebook_omniauth_authorize_path, method: :post %>
<%= link_to "Sign in with Google", user_google_oauth2_omniauth_authorize_path, method: :post %>
```



##3.テストコード（一例）
　対象：sns_credential.rbのuidのunique制約が作動するか
  ダミーのauth_hashを作成したり、omniauthをtestモードにするなど、少し設定が必要です。
　
↓設定

```ruby:rails_helper.rb
module OmniauthMocks
  def facebook_mock
    OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new(
      {
        provider: 'facebook',
        uid: '12345',
        info: {
          name: 'mockuser',
          email: 'sample@test.com'
        },
        credentials: {
          token: 'hogefuga'
        }
      }
    )
  end
end


RSpec.configure do |config|
  OmniAuth.config.test_mode = true
  config.include OmniauthMocks
end
```

↓テストコード

```ruby:spec/models/sns_credentials_spec.rb

RSpec.describe SnsCredential, type: :model do
  describe  '#facebook validation' do
    before do
      Rails.application.env_config['omniauth.auth'] = facebook_mock
    end
    context '認可サーバーから返ってきたメールアドレスを、すでに登録済みのuserが持っていた場合' do
      before do
        user = create(:user, email: 'sample@test.com')
      end
      context '認可サーバーから帰ってきた情報とprovider名が異なるが、同じuidを持つSnsCredentialレコードがあった場合' do
        before do
          SnsCredential.create(provider: 'google_oauth2', uid: '12345', user_id: '1')
        end
          example 'uidのvalidation(unique制約）が機能するか' do
            expect(SnsCredential.create(provider: 'facebook', uid: '12345', user_id: '1').errors[:uid]).to include('はすでに存在します')
          end         
      end
    end
  end
end
```


##4.考察
・設計が良くなかったと思いますが、結局、"本アプリに登録したemailアドレス"と"SNS側からトークンで帰って来るemailアドレス"の照合をしているだけと言えます。結果、SNSに登録したemailとパスワードがあれば、本アプリの認証をパスされてしまう事になるので、セキュリティ的な甘さを感じました。。。
(SNS側は別デバイスでのログインを見張る、SMS認証等、強固なようなので、そこは安心と思います)
対策としては、認証時にもう１ハードルが必要かもしれません。

・また、アドレスや住所等の個人情報がサーバーサイド側に飛ぶので、ユーザー目線としては、信頼できないサイトでは使うべきではない、と思いました。。。


##5.参考にさせて頂いた記事様
https://github.com/plataformatec/devise/wiki/OmniAuth%3A-Overview
https://github.com/mkdynamic/omniauth-facebook/blob/master/README.md
https://github.com/zquestz/omniauth-google-oauth2/blob/master/README.md
https://github.com/cookpad/omniauth-rails_csrf_protection
https://qiita.com/hidepino/items/a1eb9d2f32ce33389f20

####長文にも関わらず、最後までお読みいただきありがとうございました。:bow_tone3:
####初投稿記事なので、ご意見、修正点などいただけましたら、幸いです！:blush: