こんにちは!!
フィヨルドブートキャンプでWebエンジニアを目指してプログラミング習得に励んでいるふーがです。
現在Ruby on Railsを勉強中なのですが、その中で「omniauth」というGemを扱う機会がありました。
omniauthは、OAuthを使用した認証の仕組みを導入するためのGemで、よく見る「Twitterでログイン」「Fascebookでログイン」といった機能を実装することができます。
復習もかねて、僕が学んだ使用方法などをまとめました。
2021年4月現在の内容で作成しています。内容の間違いや、認識の誤りがあればぜひ(やさしめに)ご指摘いただけますと幸いです。
環境
- Ruby 3.0.0
- Ruby on Rails 6.1.0
- omniauth 1.9.0
- omniauth-github 1.4.0
- dotenv-rails 2.7.6
前提
前回書いた記事である、【Rails】deviseを使ってユーザー認証を実装する - No day younger than todayの内容を実装していることが前提になります。
具体的には、「deviseで実装したユーザー認証機能にomniauthでGitHub認証を導入する」ということになります。
omniauthでできること
冒頭で述べたとおり、他のサービス(サービスプロバイダー)の登録情報を利用して、自分のアプリケーションへの登録・ログインができるようになります。
例えば、「GitHubでログイン」というリンクを押すと、 こんな画面が出てきて、「Authorize」をクリックすると登録が完了する、というサービスを一度は見かけたことがあると思います。
omniauthで実現できるのは、まさにそれです。
そして、登録やログインだけではなく、サービスプロバイダーで登録されている情報を取得して扱うこともできるようになります。
OAuthとは
omniauthを利用するにあたって、知っておかなければいけないのが「OAuth(オーオース)」です。
OAuthは認証のためのプロトコルで、RFCで標準化もされています。 具体的には、アクセストークンの要求と発行に関するプロトコルです。
OAuthの仕組みや流れを理解するには、以下の記事がとてもわかりやすかったです。
導入手順
前置きが長くなりました…。
では実際に導入の手順を見ていきたいと思います。
GitHubアカウントを使っての登録・ログインを実装していきます。 2021年4月時点では以下の方法でできましたが、特にClient IDの取得などはプロバイダーによって名称や取得方法が違いますし、同じGItHubでも時期によって変わったりするので注意してください。
GitHubでClient IDとClients secretsを取得する
まずは認証に必要となる「Client ID」と「Clients secrets」を取得していきます。
これについては別で記事を作成しているので、そちらを参照ください。
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つほど問題が残っています。
これらの問題を解決していきます。
なぜ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_resource
とupdate_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:≒認証等に使われる情報の総称