【Rails】SorceryでTwitter認証時にaccess_tokenを取得してDBに保存する

## この記事ですること

TwitterAPIを使うときには、開発者アカウントのaccess_token, access_token_secret↓を使用することで、公開されているユーザー情報やツイートの取得などを行うことができます。
![スクリーンショット 2020-10-05 8.23.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/322882/c6fac189-9246-cdbf-f6d2-810fb0483d98.png)
しかし、非公開ユーザーやフォロワーの情報を取得したり、ユーザーがアプリを通してツイートをするためには、ユーザーごとに一意のaccess_tokenとaccess_token_secretを使用する必要があります。
この記事では、個別のユーザーのaccess_token, access_token_secretをデータベースに保存し、利用する実装を書きます。

[【Rails】SorceryでTwitter認証 \- Qiita](https://qiita.com/aiandrox/items/5435c8b285c7dc0c455f)でTwitter認証の実装ができていることを前提とします。

【注意】
この記事は、実装時点ではユーザーがいないことを前提としています。
既にユーザーがいる場合（Userデータをリセットできない場合）は、そのユーザーのaccess_tokenがnullということになるので、ちょっと面倒な条件分岐を追加する必要があります。


### 環境

Ruby 2.6.6
Rails 5.2.4.3
Sorcery 0.15.0

## モデルのアソシエーションの修正

私の場合は、外部認証はTwitterのみを想定していたので、以下のようにアソシエーションを`has_one`に変更しました。

```diff:app/models/user.rb
class User < ApplicationRecord
  authenticates_with_sorcery!
- has_many :authentications, dependent: :destroy
- accepts_nested_attributes_for :authentications
+ has_one :authentication, dependent: :destroy
+ accepts_nested_attributes_for :authentication
  ...
end
```

他のサービスとも連携していてuser has_many :authenticationsの場合は`user.authentication.hoge`などの記述を`user.authentications.find_by!(provider: 'twitter')`のように置き換えてください。


## マイグレーションファイルの作成

authenticationsテーブルにaccess_tokenとaccess_token_secretカラムを追加します。
Twitter以外の連携をしている場合は、オプションの`null: false`は外してください。

```db/migrate/20200613022618_add_access_token_columns_to_authentications.rb
class AddAccessTokenColumnsToAuthentications < ActiveRecord::Migration[5.2]
  def change
    add_column :authentications, :access_token, :string, null: false
    add_column :authentications, :access_token_secret, :string, null: false
  end
end
```


## コントローラを追記

前回の記事と変わったのは`create_user_from`メソッドの内部です。

```app/controllers/api/v1/oauths_controller.rb
class OauthsController < ApplicationController
  skip_before_action :require_login # applications_controllerでbefore_action :require_loginを設定している場合

  def oauth
    login_at(auth_params[:provider])
  end

  def callback
    provider = auth_params[:provider]
    if auth_params[:denied].present?
      redirect_to root_path, notice: "ログインをキャンセルしました"
      return
    end
    # 送られてきた認証情報でログインできなかったとき（該当するユーザーがいない場合）、新規ユーザーを作成する
    create_user_from(provider) unless (@user = login_from(provider))
    redirect_to root_path, notice: "#{provider.titleize}でログインしました"
  end

  private

  def auth_params
    params.permit(:code, :provider, :denied)
  end

  def create_user_from(provider)
    @user = build_from(provider) # ①
    @user.build_authentication(uid: @user_hash[:uid],
                               provider: provider,
                               access_token: access_token.token,
                               access_token_secret: access_token.secret) # ②
    @user.save! # ③
    reset_session
    auto_login(@user)
  end
```

①`build_from`はSorceryのメソッド。
`provider`（`:twitter`）からSorceryに渡されたデータをUserインスタンス（`@user`）のattributesとして入れる。

②認証インスタンスを作成する。
`@user_hash`, `access_token`にはTwitterから受け取ったデータが入っているので、それを使う。
ちなみに、`build_authentication`はhas_oneのメソッドなので、has_manyの場合は`user.authentications.build`にしてください。

```rb
[11] pry(#<OauthsController>)> @user_hash
=> {:token=>"111111111111111111111",
 :user_info=>
  {"id"=>1048451188209770497,
   "id_str"=>"1048451188209770497",
   "name"=>"END",
   "screen_name"=>"aiandrox",
   "location"=>"岡山ずっと→山梨ちょっと→東京イマココ",
   "description"=>"小学校の先生とか仲居とかやってて、最近エンジニアになった人。謎解きエンジョイ勢。 #RUNTEQ",
   "url"=>"https://t.co/zeP2KN6GMM",
    ...
  }
}

[12] pry(#<OauthsController>)> access_token
=> #<OAuth::AccessToken:0x00007f2fc41402d0
 @consumer=
  #<OAuth::Consumer:0x00007f2fc405c008
   @debug_output=nil,
   @http=#<Net::HTTP api.twitter.com:443 open=false>,
   @http_method=:post,
   @key="aaaaaaaaaaaaaaaaa",
   @options=
    {:signature_method=>"HMAC-SHA1",
     :request_token_path=>"/oauth/request_token",
     :authorize_path=>"/oauth/authenticate",
     :access_token_path=>"/oauth/access_token",
     :proxy=>nil,
     :scheme=>:header,
    ...
```

③関連付けられたauthenticationごと保存する。
`@user.build_authentication`や`@user.authentications.build`などで生成されたAuthenticationインスタンスはUserが保存されたときに一緒に保存するようになっている。


## モデルの修正

access_tokenやaccess_token_secretをそのままデータベースに保存すると危険なので、暗号化して保存する。

```app/models/authentication.rb
class Authentication < ApplicationRecord
  before_save :encrypt_access_token
  belongs_to :user

  validates :uid, presence: true
  validates :provider, presence: true

  def encrypt_access_token
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    crypt = ActiveSupport::MessageEncryptor.new(secret)
    self.access_token = crypt.encrypt_and_sign(access_token)
    self.access_token_secret = crypt.encrypt_and_sign(access_token_secret)
  end
end
```

## Twitter::REST::Clientをサービスクラスに切り出す

Twitterクライアントに関するロジックをサービスクラスに切り出す。
引数にuserがあるときはuserのaccess_tokenなどを使い、userが渡されないときはデフォルトのaccess_tokenなどを用いる。

（追記）多分本当はmoduleに切り出した方がいいです。検証していないのでコードはないですが……。

```app/services/twitter_api_client.rb
require 'twitter'

class TwitterApiClient
  def self.call(user = nil)
    new.call(user)
  end

  def call(user)
    @user = user
    @client ||= begin
      Twitter::REST::Client.new do |config|
        config.consumer_key        = Rails.application.credentials.twitter[:key]
        config.consumer_secret     = Rails.application.credentials.twitter[:secret_key]
        config.access_token        = access_token
        config.access_token_secret = access_token_secret
        config.dev_environment     = 'hoge' # Sandboxを使用する場合は設定した名前を書く
      end
    end
  end

  private

  attr_reader :user

  def access_token
    @access_token ||= user ? crypt.decrypt_and_verify(user.authentication.access_token)
                           : Rails.application.credentials.twitter[:access_token]
  end

  def access_token_secret
    @access_token_secret ||= user ? crypt.decrypt_and_verify(user.authentication.access_token_secret)
                                  : Rails.application.credentials.twitter[:access_token_secret]
  end

  def crypt
    key_len = ActiveSupport::MessageEncryptor.key_len
    secret = Rails.application.key_generator.generate_key('salt', key_len)
    ActiveSupport::MessageEncryptor.new(secret)
  end
end
```

`TwitterApiClient.call(user)`でClientのインスタンスを呼び出すことができる。
コードとして書くときは、`TwitterApiClient.call(user).update("I'm tweeting with @gem!")`のように使う。

- [sferik/twitter: A Ruby interface to the Twitter API\.](https://github.com/sferik/twitter#usage-examples)