Rails5のAPIモードでdevise_token_authとomniauthを使ったログインを試す

ログイン処理をOmniauth認証のみで行なう必要があるので最低限ログインが出来る部分までを試したときのことを忘れないために投稿
omniauthで使うのはGoogle/Twitter/Doorkeeper
環境はCentOS6.9とPostgreSQL9系

これはRails5(API)だけど実際にRailsでSPAを作るなら、Rails5のAPIモードとGrapeのどっちがいいんだろう

## プロジェクト作成
```
$ rails new Sample -d postgresql -B -T --api
```
使うgemは以下のとおり

```ruby:Gemfile
gem 'rack-cors'
gem 'devise'
gem 'devise_token_auth'
gem 'omniauth'
gem 'omniauth-oauth2'
gem 'omniauth-google-oauth2'
gem 'omniauth-twitter'
```
インストールしたらdatabase.ymlを編集してDBの作成をしておく

## devise_token_authのインストールと準備
```
$ rails g devise_token_auth:install User auth
```
生成されるmigrationファイルを以下のように編集して```$ rails db:migrate```

```ruby:db/migrate/XXXXXXXXX_devise_token_auth_create_users.rb
class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table(:users) do |t|
      ## Required
      t.string :provider, :null => false, :default => "email"
      t.string :uid, :null => false, :default => ""

      ## Database authenticatable
      t.string :encrypted_password, :null => false, :default => ""

      ## Recoverable
      #t.string   :reset_password_token
      #t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, :default => 0, :null => false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.string   :current_sign_in_ip
      t.string   :last_sign_in_ip

      ## Confirmable
      #t.string   :confirmation_token
      #t.datetime :confirmed_at
      #t.datetime :confirmation_sent_at
      #t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at
      ## User Info
      t.string :name
      t.string :nickname
      t.string :image
      t.string :email

      ## Tokens
      t.json :tokens

      t.timestamps
    end

    add_index :users, :email,                unique: true
    add_index :users, [:uid, :provider],     unique: true
    #add_index :users, :reset_password_token, unique: true
    #add_index :users, :confirmation_token,   unique: true
    #add_index :users, :unlock_token,       unique: true
  end
end
```
モデルは以下のように編集
```:rememberable```は不要な気もする
 

```ruby:app/models/user.rb
class User < ActiveRecord::Base
  # Include default devise modules.
  devise  :rememberable, :omniauthable
  include DeviseTokenAuth::Concerns::User
end
```

これでomniauthを使ったログインのみになる

```
$ rails routes
             Prefix Verb     URI Pattern                            Controller#Action
auth_validate_token GET      /auth/validate_token(.:format)         devise_token_auth/token_validations#validate_token
       auth_failure GET      /auth/failure(.:format)                users/omniauth_callbacks#omniauth_failure
                    GET      /auth/:provider/callback(.:format)     users/omniauth_callbacks#omniauth_success
                    GET|POST /omniauth/:provider/callback(.:format) users/omniauth_callbacks#redirect_callbacks
   omniauth_failure GET|POST /omniauth/failure(.:format)            users/omniauth_callbacks#omniauth_failure
                    GET      /auth/:provider(.:format)              redirect(301)
```

googleやtwitterでログインする際に呼び出される```omniauth_callbacks_controller.rb```の一部をオーバーライド

今回はお試しなのでユーザ情報の保存に成功したらその情報をjsonで返す。
本来なら```render_data_or_redirect()```を使うのが正しいやり方。
以下は変更したメソッドのみ抜粋

```ruby:app/controllers/users/omniauth_callbacks_controller.rb
module Users
  class OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
    include Devise::Controllers::Rememberable

    def omniauth_success
      get_resource_from_auth_hash
      create_token_info
      set_token_on_resource
      create_auth_params

      # ここは使わないのでコメントアウト
      #if resource_class.devise_modules.include?(:confirmable)
      #  # don't send confirmation email!!!
      #  @resource.skip_confirmation!
      #end

      sign_in(:user, @resource, store: false, bypass: false)

      # 動作確認用にユーザ情報を保存できたらjsonをそのまま返す処理
      if @resource.save!
        # update_token_authをつけることでレスポンスヘッダーに認証情報を付与できる。
        update_auth_header
        yield @resource if block_given?
        render json: @resource, status: :ok
      else
        render json: { message: "failed to login" }, status: 500
      end

      # 本実装時はこちらを使用する
      # @resource.save!
      #       
      # update_auth_header # これは自分で追加する
      # yield @resource if block_given?
      #
      # render_data_or_redirect('deliverCredentials', @auth_params.as_json, @resource.as_json)
      
    end

    protected
    def get_resource_from_auth_hash
      # find or create user by provider and provider uid
      @resource = resource_class.where({
        uid:      auth_hash['uid'],
        provider: auth_hash['provider']
      }).first_or_initialize

      if @resource.new_record?
        @oauth_registration = true
        # これが呼ばれるとエラーになるのでコメントアウトする
        #set_random_password
      end

      # sync user info with provider, update/generate auth token
      assign_provider_attrs(@resource, auth_hash)

      # assign any additional (whitelisted) attributes
      extra_params = whitelisted_params
      @resource.assign_attributes(extra_params) if extra_params

      @resource
    end
  end
end
```


上記の処理を呼び出すようにするためroutes.rbを編集 

```ruby:config/routes.rb
Rails.application.routes.draw do
  mount_devise_token_auth_for 'User', at: 'auth', controllers: { omniauth_callbacks: "users/omniauth_callbacks" }
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

```

initializerは以下のようにする

```ruby:config/initializers/devise_token_auth.rb
DeviseTokenAuth.setup do |config|
  config.change_headers_on_each_request = false
  config.token_lifespan = 1.month
  config.headers_names = {:'access-token' => 'access-token',
                         :'client' => 'client',
                         :'expiry' => 'expiry',
                         :'uid' => 'uid',
                         :'token-type' => 'token-type' }
end
```

## Omniauthの準備

application.rbに以下を追加
これをしないとAPIモードではエラーになる。
corsはクライアントとのやり取りのために追加

```ruby:config/application.rb
module Sample
  class Application < Rails::Application
    config.load_defaults 5.1
    config.api_only = true

    # 主にdeviseを使うのに必要
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
    config.middleware.use ActionDispatch::Flash
    
    # クロスドメイン対策は入れておいたほうが良い
    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins '*'
        resource '*',
                 :headers => :any,
                 :expose => ['access-token', 'expiry', 'token-type', 'uid', 'client'],
                 :methods => [:get, :post, :options, :delete, :put]
      end
    end
  end
end
```

各サービスの設定をomniauth.rbに記載
App_IDとSecret_keyについてはomniauth.ymlを作成して格納
取得方法については省略
もちろん環境変数に入れておいてそれを読み取る形でも問題ない

```ruby:config/initializer/omniauth.rb
require File.expand_path('lib/omniauth/strategies/doorkeeper', Rails.root)
Rails.application.config.middleware.use OmniAuth::Builder do
   OAUTH_CONFIG = YAML.load_file("#{Rails.root}/config/omniauth.yml")[Rails.env].symbolize_keys!
   provider :doorkeeper, OAUTH_CONFIG[:doorkeeper]['key'], OAUTH_CONFIG[:doorkeeper]['secret']

   provider :google_oauth2, OAUTH_CONFIG[:google]['key'], OAUTH_CONFIG[:google]['secret'], name: :google, scope: %w(email)

   provider :twitter, OAUTH_CONFIG[:twitter]['key'], OAUTH_CONFIG[:twitter]['secret]

end
```

```ruby:config/omniauth.yml
production: &production
  doorkeeper:
    key: XXXXXXXXXXXXXXXXXXXXXXXXXX
    secret: XXXXXXXXXXXXXXXXXXXXXXXX
  google:
    key: XXXXXXXXXXXXXXXXXX
    secret: XXXXXXXXXXXXXXXXXXXx
  twitter:
    key: XXXXXXXXXXXXXXx
    secret: XXXXXXXXXXXXXXXXXXX

development: &development
  <<: *production

test:
  <<: *development
```


SiteURLとCallbackURLはそれぞれの開発者向けのサービスで以下のように設定
またGoogleはIPアドレスでは登録できず、~~twitterはIPアドレスじゃないと登録できない~~
twitterはIPアドレスとlocalhostどっちでも登録できる

```text:Google
site_url     = http://localhost:3000
callback_url = http://localhost:3000/omniauth/google/callback
```

```text:twitter
site_url     = http://127.0.0.1:3000
callback_url = http://127.0.0.1:3000/omniauth/twitter/callback
```

```text:doorkeeper
site_url     = http://localhost:3000
callback_url = http://localhost:3000/omniauth/doorkeeper/callback
```

lib配下にdoorkeeper用のstrategyを作成

```ruby:lib/omniauth/strategies/doorkeeper.rb
require 'omniauth-oauth2'
module OmniAuth
  module Strategies
    class Doorkeeper < OmniAuth::Strategies::OAuth2
      RAW_INFO_URL = 'api/v1/me'
      option :name, :doorkeeper

      option :client_options, {
        site: 'doorkeeperのURL'
      }

      uid { raw_info['uid'] }

      info do
        {
          email: raw_info['email'],
          name: raw_info['name']
        }
      end

      extra do
        { raw_info: raw_info }
      end

      def raw_info
        @raw_info ||= JSON.parse(access_token.get(RAW_INFO_URL).response.body)
      end

      def callback_url
        full_host + script_name + callback_path
      end
    end
  end
end
```


## 動作確認
一通り設定できたはずなので``$ rails s``でサーバを立ち上げて
ブラウザで[http://localhost:3000/auth/google](http://localhost:3000/auth/google)と叩けば認証画面が表示されるので認証を行なうとDBに保存される
そしてResponseヘッダーにclient_idとuidとaccess_tokenとexpireが付与された状態で
DBに保存したユーザ情報がjsonで返ってくる。

本来は以下のように入力しないとだめ
[http://localhost:3000/auth/google?omniauth_window_type=newWindow](http://localhost:3000/auth/google?omniauth_window_type=newWindow)



え？クライアント側？知らない子ですね・・・

## 認証をキャンセルした場合の処理について【11/21追記】

facebookの認証も追加し、試しに「後で」をクリックしたらflashなんてメソッドねーぞ怒られた。
deviseのflashなんて使わないはずなのになぜ？と思ってログを見ると

```text:development.log
･･･
Started GET "/omniauth/facebook?resource_class=User"
･･･
Processing by Devise::OmniauthCallbacksController#failure as HTML
･･･
```

DeviseのOmniauthCallbacksController#failureが呼ばれていた。
そこでomniauthのWikiにある以下の方法を試したけど反映されたようには見えず・・・

```ruby:config/initializers/omniauth.rb
OmniAuth.config.on_failure = Proc.new { |env|
  OmniAuth::FailureEndpoint.new(env).redirect_to_failure
}
```
改めて調べていると[stackoverflowにこのような書き込みを見つけた。](https://github.com/lynndylanhurley/devise_token_auth/issues/282#issuecomment-255845204)
Google先生でざっくり翻訳してみると
「devise-token-authはdevies上のレイヤーであり、deviseはomniauth上のレイヤーなので、どこかon_failureコードはすでにmonkeypatchedされています。したがって、再monkeypatchするには、after_initalizeブロックに設定コードを入れなければなりません」
つ、つまりどういうことだってばよ・・・？
モンキーパッチがどこかで適用されているので、on_failureを使うにはモンキーパッチを再度当てる必要があるってこと？
えぇ・・・
とりあえず調べていく中で[見つけた](https://stackoverflow.com/a/40117386)以下の方法でうまくいった。

```ruby:config/initializers/devise.rb
Rails.application.config.to_prepare do
  Devise::OmniauthCallbacksController.class_eval do
    def failure
      # 認証をキャンセルした場合
      render json: { message: "Login failed." }, status: 401
    end
  end
end
```

また```/auth/:provider```というパスも```/api/v1/auth/:provider```とかに変更してしまうと、
Devise(多分)が/omniauth/:provider/callbackを見つけらずエラーになるということもわかったのでなるべくモデル作成時に設定したパスは弄らないほうが良い。

## 参考

[RailsでいろんなSNSとOAuth連携/ログインする方法](https://qiita.com/awakia/items/03dd68dea5f15dc46c15)
[DoorkeeperとDeviseでOAuth2によるログイン機能を作る](https://qiita.com/kyonsuke19101/items/407f3cdfec38d1108e9d)
[devise token auth を使って簡単に早くAPIを作る](http://clc.gonna.jp/2017/01/post-1306/)
[クライアントAngularJS サーバーサイドRails5 におけるOmniauth 認証を試してみる](http://chatora.lolipop.jp/%E6%8A%80%E8%A1%93%E7%B3%BB/rails/340/)
[OmniAuth OAuth2 1.4.0 以降で `Invalid Credentials` エラー](https://qiita.com/diskshima/items/ffe4f502c115fed04ce3)