Rails: RodauthでSNSログインを行う(翻訳)
OmniAuthは、さまざまな外部プロバイダの認証に標準化されたインターフェイスを提供します。ユーザーがいったん外部プロバイダで認証されれば、コールバックの処理は私たち開発者に任される形となり、実際のログインやアプリへの登録を実装することになります。
複数のプロバイダをサポートしたい場合には、OmniAuthのwikiページにさまざまなシナリオが記載されていますが、決して簡単な作業ではないことがわかります。
DeviseはOmniAuthの周囲に便利なレイヤを提供しますが、実際にユーザーをアプリにサインインさせるときは何も行いません。私がRodauthのOmniAuth統合を開始したとき、そこから一歩進んで外部IDの永続化やアカウント作成、ログインなどの処理を実際に行えるようにし、かつ開発者が動作をカスタマイズ可能にしたいと思いました。rodauth-omniauthはこうして誕生したのです ✨
本記事では、既存のRailsアプリにRodauthでSNSログインを追加し、デフォルトの動作をカスタムロジックで拡張する方法を紹介します。Rodauthの使い方については過去記事を参照してください。それではさっそく始めましょう。
🔗 セットアップ
最初に、Rodauth拡張機能と、ここで使いたいOmniAuth戦略をインストールします。
$ bundle add rodauth-omniauth omniauth-facebook omniauth-google_oauth2
OmniAuthのリクエストエンドポイントをCSRFで保護する特別なgemをインストールする必要はありません。rodauth-omniauthは、Rodauthで設定済みのCSRF保護メカニズム(Railsの場合はActionController::RequestForgeryProtection
)を自動的に利用します。
未設定の場合は、必要なOAuthアプリを作成し、コールバックURLをhttps://localhost:3000/auth/{provider}/callback
に設定してください({provider}
はfacebook
またはgoogle
のいずれかです)。OAuthアプリのcredentials(資格情報)は以下のようにプロジェクトに保存することにします。
$ rails credentials:edit
# ...
facebook:
app_id: "<YOUR_APP_ID>"
app_secret: "<YOUR_APP_SECRET>"
google:
client_id: "<YOUR_CLIENT_ID>"
client_secret: "<YOUR_CLIENT_SECRET>"
次に、rodauth-omniauthが外部アカウントIDを保存するのに使うテーブルを作成する必要があります。
$ rails generate migration create_account_identities
$ rails db:migrate
class CreateAccountIdentities < ActiveRecord::Migration
def change
create_table :account_identities do |t|
t.references :account, null: false, foreign_key: { on_delete: :cascade }
t.string :provider, null: false
t.string :uid, null: false
t.index [:provider, :uid], unique: true
end
end
end
これで、omniauth機能をRodauthの設定で以下のように有効にし、戦略を登録できるようになります。
# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
configure do
enable :omniauth
omniauth_provider :facebook,
Rails.application.credentials.facebook[:app_id],
Rails.application.credentials.facebook[:app_secret],
scope: "email"
omniauth_provider :google_oauth2,
Rails.application.credentials.google[:client_id],
Rails.application.credentials.google[:client_secret],
name: :google # "google_oauth2"をリネーム
end
end
最後に以下を実行してログインフォームのビューテンプレートをインポートし、SNSログインのリンクを追加します。
$ rails generate rodauth:views login
# create app/views/rodauth/_login_form.html.erb
# create app/views/rodauth/_login_form_footer.html.erb
# create app/views/rodauth/_login_form_footer.html.erb
# create app/views/rodauth/_login_form_header.html.erb
# create app/views/rodauth/login.html.erb
# create app/views/rodauth/multi_phase_login.html.erb
<!-- app/views/rodauth/_login_form_footer.html.erb -->
<!-- ... -->
<li>
<%= button_to "Login via Facebook", rodauth.omniauth_request_path(:facebook),
method: :post, data: { turbo: false }, class: "btn btn-link p-0" %>
</li>
<li>
<%= button_to "Login via Google", rodauth.omniauth_request_path(:google),
method: :post, data: { turbo: false }, class: "btn btn-link p-0" %>
</li>
<!-- ... -->
OmniAuthは、リクエストフェーズでのGETリクエストをデフォルトでは許可しなくなったため、ここではPOSTフォーム送信を使います。また、リクエストリンクに対してTurboを無効にする必要があることに気付くでしょう。それらのリンクは外部の認可URLにリダイレクトされるため、AJAXの遷移はサポートされていません。
一部のOAuth認可(authorizations)では、WebアプリをHTTPS経由で提供する必要があります。Pumaを使う場合、ローカルでHTTPSを有効にする簡単な方法は、localhost gemをインストールして、以下のようにRailsサーバーでSSL(TLS)を使うよう指定することです。
$ bundle add localhost --group development
$ rails server -b ssl://localhost:3000
これで、https://localhost:3000/login
をブラウザで開けるようになったはずです。以下のようにRodauthのログインページが表示され、SNSログインリンクも表示されます。
🔗 ログインと登録
Facebookのログインリンクをクリックして、OAuthアプリを認可してからアプリに戻ると、Facebookのメールアドレスで新しい確認済みアカウントが自動的に作成され、外部の認証情報(identity)も追加してログインします。
データベースでは、おそらく次のように表示されるはずです。
account = Account.last
#=> #<Account id: 123, status: "verified", email: "janko.marohnic@gmail.com">
account.identities
#=> [#<Account::Identity id: 456, account_id: 123, provider: "facebook", uid: "350872771">]
私がユーザーとしてよく経験した問題は、特定のアプリで最初にどのSNSプロバイダでログインしたかを忘れてしまうことです。ひどいときは、SNSプロバイダでログインしたかどうかすら思い出せなかったりします(後者についてはパスワードマネージャーをチェックすればすぐわかりますが)。間違ったプロバイダでサインインすると、通常は新しいアカウントが作成されてしまいます。
既存のアカウントのメールアドレスと外部の認証情報の一致をアプリが自動的に検出して、その認証情報を既存のアカウントに自動的に割り当てられるようにできたら素晴らしいと思いませんか?これはrodauth-omniauthが自動的に行ってくれます。次回Googleで認証する場合、新しいGoogleの認証情報に紐づいたままで既存のアカウントにログインできます。
account.identities #=> [
# #<Account::Identity id: 456, account_id: 123, provider: "facebook", uid: "350872771">,
# #<Account::Identity id: 789, account_id: 123, provider: "google", uid: "987349876343">,
# ]
何らかの理由でこの振る舞いを変更または無効にしたい場合は、最初にプロバイダで認証するときに既存のアカウントを検索するaccount_from_omniauth
メソッドを以下のようにオーバーライドできます。
class RodauthMain < Rodauth::Rails::Auth
configure do
# ...
account_from_omniauth do
# これはざっくりしたデフォルト実装
account_table_ds.first(email: omniauth_info["email"])
end
# または
account_from_omniauth {} # 新しい認証情報 = 新しいアカウント
end
end
🔗 追加データを保存する
たとえば、ユーザーが私たちのアプリでフルネームを入力可能になっていて、(可能な場合は)外部の認証情報からフルネームを継承することでユーザーが余分な作業を省けるようにすることを決定したとしましょう。
ここでは、アカウントに紐付けられたprofiles
テーブルを別途持っていて、通常のユーザー登録ではプロファイルのレコードを既に作成していることを前提とします。
# マイグレーション
create_table :profiles do |t|
t.references :account, null: false, foreign_key: true
t.string :name
t.timestamps
end
class RodauthMain < Rodauth::Rails::Auth
configure do
# 通常のユーザー登録の後でプロファイルを作成する
after_create_account { Profile.create!(account_id: account_id) }
end
end
OmniAuthログインを介してアカウントを作成するフックを以下のようにオーバーライドすることで、名前を事前入力済みのプロファイルを作成できるようになります。
class RodauthMain < Rodauth::Rails::Auth
configure do
# create profile after registration through OmniAuth login
after_omniauth_create_account do
Profile.create!(account_id: account_id, name: omniauth_info["name"])
end
end
end
今度は、認証情報に追加データを保存したいとしましょう。デフォルトのカラムは必須のprovider
とuid
カラムだけなので、created_at
やupdated_at
のタイムスタンプ、email
も認証情報ごとに保存したくなるかもしれません。
最初に必要なカラムを作成します。
# マイグレーション
add_timestamps :account_identities
add_column :account_identities, :email, :string
これで以下のように、プロバイダで最初に認証するときにcreated_at
が設定されるようにし、プロバイダで認証するたびにupdated_at
とemail
が更新されるようにできます。
class RodauthMain < Rodauth::Rails::Auth
configure do
# 認証情報を作成した場合にのみ`created_at`を保存する
omniauth_identity_insert_hash do
super().merge(created_at: Time.now)
end
# 認証情報が更新されるたびに`updated_at`と`email`を更新する
omniauth_identity_update_hash do
super().merge(updated_at: Time.now, email: omniauth_email)
end
end
end
これで、私たちのアカウントと認証情報データは次のような感じになるでしょう。
account = Account.last
#=> #<Account id: 123,
# status: "verified",
# email: "janko@hey.com",
# name: "Janko Marohnić">
account.identities.first
#=> #<Account::Identity
# id: 789,
# account_id: 123,
# provider: "google",
# uid: "987349876343",
# email: "janko.marohnic@gmail.com",
# created_at: Fri, 11 Nov 2022 13:11:85 UTC,
# updated_at: Fri, 02 Dec 2022 08:01:26 UTC>
🔗 複数のアカウント種別
アプリにアカウント種別が複数存在し、それぞれ異なる認証ルールが必要になる可能性がある場合、rodauth-omniauthではアカウント種別ごとの設定をサポートしていることを知っておくとよいでしょう。
仮に管理者アカウントが存在し、管理者アカウントにはGitHub経由のログインを提供したいとしましょう。OAuthアプリを既に作成済みという前提で、OmniAuth戦略のgemを以下のようにインストールします。
$ bundle add omniauth-github
$ rails credentials:edit
# ...
github:
client_id: "<YOUR_CLIENT_ID>"
client_secret: "<YOUR_CLIENT_SECRET>"
adminセクションを保護するため、企業のGitHub Organizationのメンバーだけがアクセス可能にします。このときのRodauth設定は以下のような感じになるでしょう。
# app/misc/rodauth_admin.rb
class RodauthAdmin < Rodauth::Rails::Auth
configure do
enable :omniauth
prefix "/admin"
session_key_prefix "admin_"
omniauth_provider :github,
Rails.application.credentials.github[:client_id],
Rails.application.credentials.github[:client_secret]
before_omniauth_callback_route do
if omniauth_provider == :github && !organization_member?(omniauth_info["nickname"])
set_redirect_error_flash "User is not a member of our GitHub organization"
redirect "/admin"
end
end
end
private
def organization_member?(username)
# ... ユーザーが企業のGitHub Organizationのメンバーかどうかをチェック
end
end
adminルーティングのプレフィックスは/admin
で設定されているため、OmniAuthのルーティングにも同様にプレフィックスが付きます。そのため、リクエストフェーズは/admin/auth/github
で行われます。これにより、認証がメインのアカウント種別と重複しないようになります。
管理者アカウント用に別のテーブル(例:admins
)を使うことを決定した場合は、認証情報用に別のテーブルも利用できます。
# マイグレーション
create_table :admin_identities do |t|
t.references :admin, null: false, foreign_key: { on_delete: :cascade }
t.string :provider, null: false
t.string :uid, null: false
t.index [:provider, :uid], unique: true
end
class RodauthAdmin < Rodauth::Rails::Auth
configure do
# ...
omniauth_identities_table :admin_identities
omniauth_identities_account_id_column :admin_id
end
end
🔗 今後の作業について
🔗 登録ステップの分離
現在の自動登録では、外部ログインが常にユーザーのメールアドレスを返すことを前提としていますが、常に返すとは限りません(Twitterなど)。また、アカウント作成前にユーザーが情報を追加する必要がないことも前提としています。
私は、外部ログイン後に別の登録ステップをサポートしたいと思います。メールアドレスなどのフィールドは既に入力済みの状態であるべきです。主な課題は、攻撃者が他人の認証情報で登録するのを防ぐことです。私は皆さんからの貢献を歓迎します 🙏
🔗 追加の認証情報への紐づけ
ユーザーがサインインに成功した後で、追加の外部認証情報に接続したり、既に紐付けられた外部認証情報を切断できるようになれば、将来のログインの信頼性を高められます。
GitLabのアカウントインターフェイス
こちらの機能は比較的素直に実装できそうに思えますが、ログイン済みのユーザーが別のSNSプロバイダを介して認証したい場合のシナリオを処理するのが困難になります(Rodauthはこの場合でもログインページへのアクセスをデフォルトでは制限しないからです)。また、現在別のアカウントに紐付けられている認証情報を紐付ける方法も解決の必要があります。
🔗 しめくくり
このRodauth拡張機能は、私がこれまで作った中で最も手こずったのは間違いありません。2年もの間断続的に取り組み、上述の機能の延期を決めてからリリースにこぎつけました。OmniAuthの設定とRodauthの規約を尊重し、JWTによるJSON APIをサポートし、継承を処理し、OmniAuth 2.0へのアップグレード方法を見つけ出し、カスタマイズ可能なコールバックフェーズを実装するのに時間を費やしました。
これらをすべて実現できたのは、ひとえにRodauthが堅固な基盤を提供しているおかげです。Rodauthのレイヤ構造によって、他の認証機能とスムーズに連携する適切なレベルでフックをかけられました。また、設定DSLのおかげで、どんな部分でも手軽にカスタマイズ可能になりました。
Rodauthは機能の依存関係をサポートしているので、データベースロジックが不要なユーザー向けに純粋なOmniAuth統合を単独で抽出することに成功しました。
OmniAuthは以前から直接利用可能でしたが、RodauthにSNSログイン機能が追加されたことを嬉しく思っています。Deviseの提供するSNSログイン機能と比べて、Rodauthのソリューションははるかに統合が進んでいます。他の開発者は独自の認証フローを使っているかもしれないので、皆さんからのフィードバックをお待ちしています。
概要
原著者の許諾を得て翻訳・公開いたします。