No day younger than today

RubyとかRailsとか蒙古タンメンとか

【Rails】omniauthを使ってgithub認証を実装する

こんにちは!!
フィヨルドブートキャンプでWebエンジニアを目指してプログラミング習得に励んでいるふーがです。

bootcamp.fjord.jp

現在Ruby on Railsを勉強中なのですが、その中で「omniauth」というGemを扱う機会がありました。

omniauthは、OAuthを使用した認証の仕組みを導入するためのGemで、よく見る「Twitterでログイン」「Fascebookでログイン」といった機能を実装することができます。
復習もかねて、僕が学んだ使用方法などをまとめました。

2021年4月現在の内容で作成しています。内容の間違いや、認識の誤りがあればぜひ(やさしめに)ご指摘いただけますと幸いです。

環境

前提

前回書いた記事である、【Rails】deviseを使ってユーザー認証を実装する - No day younger than todayの内容を実装していることが前提になります。
具体的には、「deviseで実装したユーザー認証機能にomniauthでGitHub認証を導入する」ということになります。

fuga-ch85.hatenablog.com

omniauthでできること

冒頭で述べたとおり、他のサービス(サービスプロバイダー)の登録情報を利用して、自分のアプリケーションへの登録・ログインができるようになります。

例えば、「GitHubでログイン」というリンクを押すと、 f:id:fuga__ch:20210404153918p:plain こんな画面が出てきて、「Authorize」をクリックすると登録が完了する、というサービスを一度は見かけたことがあると思います。

omniauthで実現できるのは、まさにそれです。

そして、登録やログインだけではなく、サービスプロバイダーで登録されている情報を取得して扱うこともできるようになります。

OAuthとは

omniauthを利用するにあたって、知っておかなければいけないのが「OAuth(オーオース)」です。

OAuthは認証のためのプロトコルで、RFCで標準化もされています。 具体的には、アクセストークンの要求と発行に関するプロトコルです。

OAuthの仕組みや流れを理解するには、以下の記事がとてもわかりやすかったです。

qiita.com

導入手順

前置きが長くなりました…。
では実際に導入の手順を見ていきたいと思います。

GitHubアカウントを使っての登録・ログインを実装していきます。 2021年4月時点では以下の方法でできましたが、特にClient IDの取得などはプロバイダーによって名称や取得方法が違いますし、同じGItHubでも時期によって変わったりするので注意してください。

GitHubでClient IDとClients secretsを取得する

まずは認証に必要となる「Client ID」と「Clients secrets」を取得していきます。

これについては別で記事を作成しているので、そちらを参照ください。

fuga-ch85.hatenablog.com

dotenvでクレデンシャルな情報を保存する

Client IDとClients secretsのようなクレデンシャル*1なものは、平文でファイルに記述すべきではありません。
GitHubなどにあげてしまうと、重大なセキュリティ事故にも繋がりかねないそうです😭

ですので、dotenv-railsという環境変数を管理する ことができるGemを使用して、ファイルに直接含めることなく重要な情報を管理します。

まずはGemfileにdotenv-railsを追加して、bundle installを実行します。

gem 'dotenv-rails'

プロジェクト直下に.envというファイルを作成して、内容を以下のようにします。

# .env
GITHUB_ID = 'CLIENT_ID'
GITHUB_SECRET = 'CLIENT_SECRET'

'CLIENT_ID'と'CLIENT_SECRET'は、それぞれ取得したものに置き換えてください。

Railsコンソールを起動して、ENVの実行結果が正しく返ってくればOKです。

$ rails c
irb(main):001:1* ENV['GITHUB_ID']
=> "CLIENT_ID"
irb(main):002:0> ENV['GITHUB_SECRET']
=> "CLIENT_SECRET"

omniauthを使ってGitHub認証登録・ログインを実装する

Gemfileに以下を追加して、bundle installを実行します。

# Gemfile
gem 'omniauth', '~> 1.9.1'
gem 'omniauth-github'

GitHubのClient IDとClient secretを設定します。

# config/initializers/devise.rb
config.omniauth :github, ENV['CLIENT_ID'], ENV['CLIENT_SECRET']

UserモデルにOmniAuthで利用するカラムを追加するためのマイグレーションファイルを作成します。

bin/rails g migration AddOmniauthToUsers provider:string uid:string

生成されたマイグレーションファイルを編集します。

# db/migrate/[timestamp]_add_omniauth_to_users.rb
class AddOmniauthToUsers < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :uid, :string, null: false, default: ''
    add_column :users, :provider, :string, null: false, default: ''

    add_index :users, %i[uid provider], unique: true
  end
end

このように、「uid」と「provider」というカラムを追加します。
そして、add_indexで2つのカラムにindexを張り、さらにunique制約を付けます。
これによって、2つのカラムの組み合わせに対して一意の制約を付けられます。

DBに合わせて、モデル側にもバリデーションを設定します。

# app/models/user.rb
validates :uid, presence: true, uniqueness: { scope: :provider }

uidを必須にした上で、providerカラムの範囲内でuidを一意にする、という制約を付けています。
これで、DB側、アプリ側ともに同様の制約をつけることができたので、万が一の重複を避けることができます。

さらに、モデルにomniauthableを追加します。

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :omniauthable, omniauth_providers: %i[github]
end

:omniauthable モジュールを追加しています。
さらに、omniauth_providerに:githubを指定して、githubでのOAuthに対応させています。

この段階で、

  • user_{provider}_omniauth_authorize_path
  • user_{provider}_omniauth_callback_path

というルーティングが生成されます。

続いて、OAuthのcallback用ルーティングを追加します。

# config/routes.rb
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }

devise_for :usersはdevise導入時に自動生成されるルーティングでdeviseに関係するルーティングを決めているものです。

これに対して、一部コントローラーを変更しています。
具体的には、devise/omniauth_callbacksコントローラーの代わりに、users/omniauth_callbacksコントローラーを使用する、という指定です。

✍🏻 Tips

callbackとは、「折り返し」のようなものです。

  • 手続きが完了したら折り返しでお電話を差し上げます
  • 書類の用意ができ次第、お客さまのご自宅宛てにお送りいたします
  • 認証結果を、ご登録の URL 宛てにお送りいたします

事前に登録しておいたURLでしか情報を受け取ることができないので、ある程度は安全性が担保されるようです。

つまり、クライアントキーとcallbackの仕組みという合わせ技で第三者が認証結果を受け取れないようにして、安全性を高めているイメージです。

⬆️こちらは、Jun OHWADA 🚿 (@june29) | Twitterさんに教えていただいてとてもわかりやすかったのでご紹介します。 悩んでいるとこうして現役のエンジニアさんから教えてもらえるフィヨルドはすごい…!

コントローラーの中身を書いていきます。

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token, only: :github

  def github
    @user = User.from_omniauth(request.env['omniauth.auth'])

    if @user.persisted?
      sign_in_and_redirect @user, event: :authentication
      set_flash_message(:notice, :success, kind: 'github') if is_navigational_format?
    else
      session['devise.github_data'] = request.env['omniauth.auth'].except(:extra)
      redirect_to new_user_registration_url
    end
  end

  def failure
    redirect_to root_path
  end
end

request.env['omniauth.auth']には、APIからのcallback時に様々な情報(API側で保持しているユーザー情報等)が返ってきます。
これを引数としてUser#from_omniauthに渡し、結果が@userに代入されます。

そして、persisted?メソッドで@userが保存済みかどうかを検証します。
保存済みの場合は、deviseのsign_in_and_redirectヘルパーでログインして、デフォルトのルートパスへリダイレクトされます。

その際、deviseのset_flash_messageメソッドでフラッシュメッセージがセットされます。

is_navigational_format?メソッドは、リクエストがRailsで設定されているフォーマット形式であるかどうかをチェックしています。

保存に失敗した場合は、セッションにcallback情報をつめて新規登録画面にリダイレクトさせています。

.exceptは引数で指定したキー以外のキーと値を含むHashを返します。
これは、セッションがオーバーフローするのを避けるためです。

failureメソッドは本家のものをオーバーライドしていて、認証に失敗した場合に指定のパスへリダイレクトさせています。

先ほどのfrom_omniauthメソッドを定義します。

# app/models/user.rb
def self.from_omniauth(auth)
  where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
    user.email = auth.info.email
    user.password = Devise.friendly_token[0, 20]
  end
end

first_or_createメソッドで、引数で渡された情報をもとに、providerカラムとuidカラムに同一のデータを持つユーザーがいないか検索して、いなければ新たにインスタンスを生成する処理を行っています。

この時注意する点は2つあります。

  • providerカラムとuidカラムは自動的に設定されます。
  • ブロック内で設定するカラムは実際の登録時のバリデーションに合わせないとエラーになります。

つまり、登録時はメールアドレスが必須なのにブロック内でメールアドレスを設定する処理を書いていないとエラーになります。

Devise#friendly_tokenメソッドは、ランダムな文字列を返すメソッドです。

これで概ねの実装は完了です。
ログインフォームの下部に、「GitHubでログイン」のリンクが出ているはずです。

導入後の問題を解決する

実装はできましたが、2つほど問題が残っています。

  • OAuth認証をせずに登録するユーザーは、2人目以降登録できない。
  • OAuth認証で登録したユーザーはパスワードが自動で設定されるにもかかわらず、パスワードなしでプロフィール編集ができない。

これらの問題を解決していきます。

なぜ2人目以降が登録できないのでしょうか?

irb(main):006:0> User.create(email: 'new@sample.com', password: 'password', password_confirmation: 'password', uid: 'aaa')
  TRANSACTION (0.0ms)  begin transaction
  User Exists? (0.2ms)  SELECT 1 AS one FROM "users" WHERE "users"."uid" = ? AND "users"."provider" = ? LIMIT ?  [["uid", "aaa"], ["provider", ""], ["LIMIT", 1]]
  User Exists? (0.0ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "new@sample.com"], ["LIMIT", 1]]
  User Create (2.5ms)  INSERT INTO "users" ("email", "encrypted_password", "created_at", "updated_at", "uid") VALUES (?, ?, ?, ?, ?)  [["email", "new@sample.com"], ["encrypted_password", "$2a$12$ZTHkAxuHS2ZKJ1BVkknz1.zn/wVdqwE5nVSlC/nigFUi4Bc5gwJXi"], ["created_at", "2021-04-01 13:22:56.258869"], ["updated_at", "2021-04-01 13:22:56.258869"], ["uid", "aaa"]]
  TRANSACTION (1.1ms)  commit transaction
=> #<User id: 54, email: "new@sample.com", created_at: "2021-04-01 13:22:56.258869000 +0000", updated_at: "2021-04-01 13:22:56.258869000 +0000", name: nil, postal_code: nil, address: nil, self_introduction: nil, uid: "aaa", provider: "">
irb(main):007:0> User.create(email: 'new2@sample.com', password: 'password', password_confirmation: 'password', uid: 'aaa')
  TRANSACTION (0.1ms)  begin transaction
  User Exists? (0.1ms)  SELECT 1 AS one FROM "users" WHERE "users"."uid" = ? AND "users"."provider" = ? LIMIT ?  [["uid", "aaa"], ["provider", ""], ["LIMIT", 1]]
  User Exists? (0.1ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "new2@sample.com"], ["LIMIT", 1]]
  TRANSACTION (0.0ms)  rollback transaction
=> #<User id: nil, email: "new2@sample.com", created_at: nil, updated_at: nil, name: nil, postal_code: nil, address: nil, self_introduction: nil, uid: "aaa", provider: "">

このように、uidとproviderの組み合わせにunique制約を付けていることで、uid、providerともに空という組み合わせは1人のユーザーしか持つことができないからです。

また、OAuthで登録したユーザーはランダムなパスワードを付与しています(実際のユーザーはパスワードを知り得ない状態)が、ユーザー編集をするにはパスワードが必要な状態です。

この問題を解消するために、新規登録時にOAuthを使用しない場合にuidを埋める処理と、パスワード変更以外のユーザー編集はパスワードなしでできるようにする処理を追加していきます。

本来、これらの処理はdevise/registrations_controller.rbで定義されていますが、部分的にメソッドをオーバーライドして実現していきます。

Devise::RegistrationsControllerを継承した、Users::RegistrationsControllerを作成します。

その中で、build_resourceupdate_resourceという2つのメソッドをオーバーライドしていきます。

class Users::RegistrationsController < Devise::RegistrationsController
  def build_resource(hash = {})
    hash[:uid] = User.create_unique_string
    super
  end

  protected

  def update_resource(resource, params)
    return super if params['password'].present?

    resource.update_without_password(params.except('current_password'))
  end
end

まずbuild_resourceですが、hashをもとにresourceの新しいインスタンスを作るメソッドです。

OAuthを使わない新規登録では通常このメソッドが呼ばれますが、ここにhash[:uid]を追加する処理をさしこんで、superで元のメソッドを呼んでいます。

こうすると、OAuthを使わない場合でもuidが埋まる形になり、unique制約をパスすることができます。

# app/models/user.rb
def self.create_unique_string
  SecureRandom.uuid
end

続いてupdate_resourceですが、このメソッドは本家ではupdate_with_passwordメソッドを呼んで現在のパスワードの検証処理をパスしたら更新する仕組みになっています。

なので、パスワードを変更する場合のみ本家のメソッドを呼ぶようにし、パスワードの変更を伴わない場合は、update_without_passwordを使用して、パスワードなしで更新できるようにしています。

これで、それぞれの問題を解消することができます。

*1:≒認証等に使われる情報の総称