Rails: 認証gem 'Rodauth'を統合するrodauth-railsを開発しました(翻訳)
Rodauthが登場した当時の既存のソリューションは、Rails(DeviseやSorceryの場合)か少なくともActive Record(Authlogicの場合)が必要だったので、ついにRailsに縛られないフル機能の認証フレームワークが使えるようになったのかと興奮したものでした。私は主にRailsで開発していますが、現実的な代替手段として他のRuby Webフレームワークも欲しいので、誰でも利用できる汎用的なソリューションに惹きつけられたのは自然な流れでした。
RodauthはRodaやSequelの上に構築されていますが、Rackミドルウェアとして任意のRuby Webフレームワーク上で動かせます。なお、かつてはroda-railsというgemを用いてRodauthをRailsで使う方法を紹介するデモアプリもありました(現在はどちらも廃止)が、このRails統合はかなり未熟で、Rails開発者が慣れ親しんでいる人間工学を明らかに欠いていました。
Rodauthには実に多くの機能セットがありますが、他の認証ソリューションと互角に競争するには、Railsで使うときの利便性をそれらと同じ水準まで高める必要がありました。つまり、Railsフレームワークに深いレベルで統合し、コードの置き場所を明確にし、手軽に始められるデフォルト値を用意する必要があったのです。私は2020年初頭から、RailsでRodauthを手軽に使えるようにするという使命を自分に課しました。
初期段階
最初はデモRailsアプリを作成し、そこでRodauthの設定を始めました。初期段階のコード(8affb94やe69f9bd)では、ビューのレンダリング、Flashメッセージ、CSRF保護、メール配信に(Rodaではなく)Railsを用いてフックをかけました。これによって、roda-railsよりもコード量を著しく削減できました。また、Rodaアプリを呼び出すだけのプロキシ用Rackミドルウェアを挿入して、Rodauthのコードを再読み込み可能にすることにも成功しました。
# app/misc/rodauth_app.rb
class RodauthApp < Roda
plugin :rodauth, csrf: false, flash: false do
enable :rails, :login, :create_account, :verify_account, :reset_password, :logout
# ... rodauthのコンフィグ ...
end
route do |r|
r.rodauth # rodauthのリクエストを扱う
end
end
# lib/rodauth/features/rails.rb
module Rodauth
Feature.define(:rails, :Rails) do
# ... Rails統合 ...
end
end
# config/initializers/rodauth.rb
class RodauthMiddleware
def initialize(app)
@app = app
end
def call(env)
RodauthApp.new(@app).call(env) # RodauthAppを再読み込み可能にしておく
end
end
Rails.application.config.middleware.use RodauthMiddleware
十分動くようになったと思えた時点で、rodauth-rails gemにグルーコードを展開してテストを追加しました。インストールジェネレータも追加して、常識的なデフォルト設定で初期スケルトンを作成できるようにしました。新しいRodaのスーパークラスは、Rodauthプラグインをrails
コマンドの機能で読み込める便利なconfigure
メソッドを提供します。
$ bundle add rodauth-rails
$ rails generate rodauth:install
# app/misc/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
configure do # Railsの機能を自動的に読み込む
enable :login, :create_account, :verify_account, :reset_password, :logout
# rodauthのコンフィグ
end
route do |r|
r.rodauth # rodauthのリクエストを扱う
end
end
デフォルトのRodauthは、パスワード照合にデータベースの認証関数を利用します。これと2ユーザーデータベース設定によって、仮にSQLインジェクション攻撃を受けたとしてもパスワードハッシュを保護できます(詳しくはこちらをお読みください)。しかし使い始めてみるとこの複雑さがつらく感じられたので、デフォルトではパスワードをRubyで照合するように変更しました。
use_database_authentication_functions? false
account_password_hash_column :password_hash
Rodauthにはトークン署名にHMACを用いてセキュリティを強化するオプションもあります。これは非常に重要なので、rodauth-railsではHMACの秘密情報をRailsの秘密キーベースに設定する形で有効にしています。
hmac_secret { Rails.application.secret_key_base }
Active Record
RodauthではSequelを用いてデータベースと通信しますが、ほとんどのRailsアプリではActive Recordが使われています。つまり、RodauthはActive Recordとシームレスに連携する必要があるということです。
考えられる方法の1つは、Rodauth拡張を開発してすべてのSequel呼び出しをActive Recordコードに置き換えることです。しかしRodauthにおけるSQLの利用法が高度であることを考えれば、この方法にしていたら途方も無い努力を要したでしょう。しかも新しいRodauthの変更によって拡張機能が動かなくなったので、Rodauthのサードパーティ拡張のメンテナーは独自のActive Record統合をメンテしなければならず、メンテナンス上の負荷が大きくなっていたでしょう。
そういうわけで、初期のRails統合ではActive Recordが接続するのと同じデータベースにSequelを接続し、それをRodauthが拾い上げるだけという実装にしました。
db_config = ActiveRecord::Base.connection_db_config
Sequel.connect(adapter: db_config.adapter, database: db_config.database)
しかしここで、テストスキーマのメンテナンスなどの機能が動かなくなっていることに気づきました。テストデータベースを再作成するためにはデータベースから一時的に切断する必要がありますが、Active Recordコネクションを切断してもSequelがコネクションをオープンにしたままなので、DROP DATABASEがブロックされてしまいます。この問題に対処するため、Active Recordと連携してSequelの接続と切断を行うようActive Recordを拡張しました(2b54cba)。
これはうまくいきましたが、すぐさま次の新しい問題にぶち当たりました。Active Recordが利用するデータベースコネクションはSequelのデータベースコネクションと切り離されているので、Sequelのトランザクション内で作成されたレコードをActive Recordから参照できませんでした。理由は単純で、そのコネクションにレコードが存在しないからです(データベーストランザクションはコネクションと密接に結びついていることを思い出しましょう)。
class Profile < ActiveRecord::Base
belongs_to :account
end
class RodauthApp < Rodauth::Rails::App
configure do
# ここではまだaccountレコードを作成したSequelトランザクション内にいる
after_create_account do
# Sequelで関連レコードを作成することは可能
# db[:profiles].insert(account_id: account_id, name: "New User")
# しかしActive Recordでは外部キー制約違反で失敗する
Profile.create!(account_id: account_id, name: "New User") # ~> account record not found
end
end
end
私の目標はRodauthがSequelを使っていることを開発者が気にせずに済むようにすることなので、Rodauth内でActive Recordを呼び出せばそれだけで動くはずです。さらに、Bruno Suticが「Sequelのコネクションが独立しているということは、production環境のデータベースがオープンしなければならないコネクション数が倍増するだろう」と警告してくれました。そういうわけで、この方法では望ましい開発体験を達成しようがないことがわかってきました。
Just browsed the repo. Sequel connection is a bummer.
— Josef Strzibny (@strzibnyj) April 23, 2020
Rails統合を成功させるには、SequelがActive Recordのデータベースコネクションを再利用するように仕向ける必要があります。このアイデアについてSequelのリードメンテナーであるJeremy Evansと議論していくつかの指導をいただき(sequel-talk)、そのおかげでsequel-activerecord_connectionというソリューションを思いつきました。このSequel拡張はActive Recordコネクションを取得し、SequelとActive Record間でトランザクションのステートとコールバックを同期し、SQL instrumentation(計測)を統合し、アダプタの違いを調整します(詳しくは過去記事をどうぞ)。
$ bundle add sequel-activerecord_connection
DB = Sequel.postgres(extensions: :activerecord_connection)
DB[:accounts].all # Active Recordのデータベースコネクションを使う
モデル
Rodauthは、DeviseやSorceryと異なり、モデルから完全に切り離されています。すべての呼び出しはRodauthオブジェクト経由で行う必要があります。Rodauthの操作をHTTPリクエストの外で行う必要がある場合は、internal_request
機能を利用できます。これが実際にRackでRodauthアプリを呼び出します。
# rodauthアクションの実行(クラスレベル)
RodauthApp.rodauth.create_account(login: "user@example.com", password: "secret123")
RodauthApp.rodauth.verify_account(account_login: "user@example.com")
# rodauthアクションの実行(インスタンスレベル)
rodauth = Rodauth::Rails.rodauth(account_login: "user@example.com")
rodauth.get_password_reset_key(account_id) #=> "DS6dtRNnvzSCWzm8jg4lltOzBE5vTN_xflNdToIPw3A"
rodauth.recovery_codes #=> ["30GRJkr1BheZztvFZcDeRSNy6yhzigXH6zB-yvzP4Io", ...]
私はこの分離が気に入っていますが、少なくともアカウント作成や関連付け取得はモデルで直接できるようにしてもいいと思ったので、rodauth-modelというgemも作成しました。これはActive Recordのhas_secure_password
に似たインターフェイスを提供して、Rodauthのコンフィグに基づく関連付けを(関連付けられるモデルとともに)定義します。
class Account < ActiveRecord::Base
include Rodauth::Rails.model
end
# パスワードハッシュを生成する
account = Account.create(email: "user@example.com", password: "secret123")
account.password_hash #=> "$2a$12$k/Ub1I2iomi84RacqY89Hu4.M0vK7klRnRtzorDyvOkVI.hKhkNw."
account.password_reset_key #=> #<Account::PasswordResetKey id: 1, key: "DS6dtRNnvzSCWzm8jg4lltOzBE5vTN_xflNdToIPw3A" ...>
account.recovery_codes #=> [#<Account::RecoveryCode id: 1, code: "30GRJkr1BheZztvFZcDeRSNy6yhzigXH6zB-yvzP4Io">, ...]
Rodauthはアカウントのステート(unverified、verified、closed)を整数値で保存しますが、ActiveRecord::Enum
で文字列と対応付けることも可能です。インストールジェネレータはまさにこれを行います。
class Account < ActiveRecord::Base
# ...
enum :status, unverified: 1, verified: 2, closed: 3
end
account = Account.find(123)
account.status #=> "unverified"
account.verified? #=> false
account.status = "verified"
account.verified? #=> true
Account.closed #=> [#<Account id: 456, status: "closed" ...>, ...]
Rodauthのルーティング表示
このアーキテクチャでは、Railsルーターよりも手前にあるRackミドルウェアでRodauthのルーティングを処理します。これによって、認証の要求、アクティブなセッションのチェック、cookieによる認証の記憶などを1箇所で扱えるようになり、認証ロジックのカプセル化がはかどるというメリットを得られます。
ただしRodauthのルーティングはRailsルーターに登録されないので、rails routes
では表示されません。現在のRailsルーティング表示にはカスタムルーティングを登録する機能がなく、Rodaのルーティングは動的なので、Rodaのルーティングをプログラムで取り出せません。
最終的にrodauth:routes
というrakeタスクを実装できました。これはRodauthアプリからルーティングパスを取得し、ソースコードを解析する形でHTTP verbを調べます。理想的ではないものの、さしあたってはこれで十分でしょう。
$ rails rodauth:routes
# Routes handled by RodauthApp:
#
# GET/POST /login rodauth.login_path
# GET/POST /create-account rodauth.create_account_path
# POST /email-auth-request rodauth.email_auth_request_path
# GET/POST /email-auth rodauth.email_auth_path
# GET/POST /logout rodauth.logout_path
# GET/POST /reset-password-request rodauth.reset_password_request_path
# GET/POST /reset-password rodauth.reset_password_path
# GET/POST /change-password rodauth.change_password_path
# GET/POST /change-login rodauth.change_login_path
# GET/POST /verify-login-change rodauth.verify_login_change_path
# GET/POST /close-account rodauth.close_account_path
# GET/POST /verify-account-resend rodauth.verify_account_resend_path
# GET/POST /verify-account rodauth.verify_account_path
#
# GET /admin/multifactor-manage rodauth(:admin).two_factor_manage_path
# GET /admin/multifactor-auth rodauth(:admin).two_factor_auth_path
# GET/POST /admin/multifactor-disable rodauth(:admin).two_factor_disable_path
# GET/POST /admin/otp-auth rodauth(:admin).otp_auth_path
# GET/POST /admin/otp-setup rodauth(:admin).otp_setup_path
# GET/POST /admin/otp-disable rodauth(:admin).otp_disable_path
# GET/POST /admin/recovery-auth rodauth(:admin).recovery_auth_path
# GET/POST /admin/recovery-codes rodauth(:admin).recovery_codes_path
# POST /admin/unlock-account-request rodauth(:admin).unlock_account_request_path
# GET/POST /admin/unlock-account rodauth(:admin).unlock_account_path
ジェネレータ
私はこのgemに取り組んだことで、コードジェネレータが利便性のためにどれほど重要か、そしてコードジェネレータを正しく動かすのがどれほど難しいかを初めて思い知らされました。現在のrodauth-railsには以下の3つのジェネレータが付属しています。
rodauth:install
- デフォルトの認証機能やマイグレーションを備えた初期スケルトンをセットアップする
rodauth:views
- カスタマイズ用にERBビューテンプレートをインポートする
rodauth:migration
- 指定の認証機能に対応したマイグレーションを生成する
ジェネレータは以下のようなさまざまなシナリオを扱う必要があります。
- RSpec vs Minitest
- fixture vs factory_bot
- Active Record vs Sequel
- さまざまなSQLアダプタ
- API-onlyモード
- UUID主キー
- その他
スキーマのマイグレーション
私はSequelのコードが一切目に入らないようにしたかったので、SequelのマイグレーションコードをActive Recordのコードに変換して、Active Recordのマイグレーションではこれらを生成するようにしました。Sequelがメインのデータベースライブラリに用いられる場合は、代わりにSequelのマイグレーションを生成します。
$ rails generate rodauth:migration otp active_sessions
# create db/migrate/20221012110706_create_rodauth_otp_active_sessions.rb
class CreateRodauthOtpActiveSessions < ActiveRecord::Migration[7.0]
def change
# OTP機能で使われる
create_table :account_otp_keys do |t|
t.foreign_key :accounts, column: :id
t.string :key, null: false
t.integer :num_failures, null: false, default: 0
t.datetime :last_use, null: false, default: -> { "CURRENT_TIMESTAMP" }
end
# アクティブセッション機能で使われる
create_table :account_active_session_keys, primary_key: [:account_id, :session_id] do |t|
t.references :account, foreign_key: true
t.string :session_id
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
t.datetime :last_use, null: false, default: -> { "CURRENT_TIMESTAMP" }
end
end
end
ビューテンプレート
Rodauthに組み込まれているビューテンプレート機能では、Tilt gemによる文字列式展開エンジンを利用することでERBへの依存を回避していますが、その分Railsへの適合作業が欠かせません。そこでrodauth-railsのビュージェネレータでは、Railsでおなじみのフォームヘルパーを利用する変換済みERBビューテンプレートをインポートします。
$ rails generate rodauth:views login create_account lockout
# create app/views/rodauth/_login_form.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
# create app/views/rodauth/create_account.html.erb
# create app/views/rodauth/unlock_account_request.html.erb
# create app/views/rodauth/unlock_account.html.erb
<%= form_with url: rodauth.unlock_account_path, method: :post, data: { turbo: false } do |form| %>
<%== rodauth.unlock_account_explanatory_text %>
<% if rodauth.unlock_account_requires_password? %>
<div class="mb-3">
<%= form.label "password", rodauth.password_label, class: "form-label" %>
<%= form.password_field rodauth.password_param, value: "", id: "password", autocomplete: rodauth.password_field_autocomplete_value, required: true, class: "form-control #{"is-invalid" if rodauth.field_error(rodauth.password_param)}", aria: ({ invalid: true, describedby: "password_error_message" } if rodauth.field_error(rodauth.password_param)) %>
<%= content_tag(:span, rodauth.field_error(rodauth.password_param), class: "invalid-feedback", id: "password_error_message") if rodauth.field_error(rodauth.password_param) %>
</div>
<% end %>
<div class="mb-3">
<%= form.submit rodauth.unlock_account_button, class: "btn btn-primary" %>
</div>
<% end %>
Rodauth操作の一部がTurbo互換でないため(multi-phaseログインとリカバリーコード表示がフォーム送信のレスポンスで200を返す)、すべてが確実に動作するようにデフォルトではすべてのHTMLフォームのTurboを無効にしておくことにしました。
今後の計画
- 開発者がパスワードハッシュの安全性を手軽に高められるように、マイグレーションジェネレータでデータベースの認証関数をサポートしたいと思います。これまでも折を見て取り組んできましたが、適切なマイグレーションコードの生成はかなり複雑で、特にSQLデータベースごとに異なるセットアップが必要です(PostgreSQL、MySQL、SQL Server)。
-
RodauthのデフォルトのビューテンプレートではBootstrapのマークアップを用いていますが、Ben Koshyが追加作業を進めているTailwind CSSのサポート(#114)はいい感じです。
tailwindcss-rails
gemを使う場合のデフォルトである--css=tailwind
をそのうち渡せるようになるでしょう(訳注:--css=tailwind
はその後利用可能になりました)。 -
サードパーティのRodauth拡張で、Railsジェネレータ用のマイグレーションやビューを今よりも簡単に提供できるようにしたいと思います。現時点のrodauth-oauth gemは独自のジェネレータ(
rodauth:oauth:install
とrodauth:oauth:views
)を提供していますが、ジェネレータの開発が重複しなくなったら嬉しいですね。
- RodauthのメソッドはRodauthオブジェクト経由で呼び出され、アプリケーションのニーズに応じてコントローラやビューの便利なヘルパーを定義することが推奨されています。とはいうものの、Deviseにあるようなデフォルトのヘルパー(さらにDeviseのグループ機能など)をRodauthに同梱するには、おそらく時間がかかりそうです。他にも、より便利なルーティングヘルパーや、できればカスタムヘルパーを定義するビルダーも検討しています。
# config/routes.rb
Rails.application.routes.draw do
# 今使える機能
constraints Rodauth::Rails.authenticated do
# ...
end
# しかしDevise構文にも価値がありそう
authenticate do
# ...
end
end
最後に
Rails統合で扱うべきことは以下のようにまだまだありますが、私は十分取り組んできたと思います。
- SprocketsやPropshaftからのアセットリクエストを無視する
- Rodauthリクエストのinstrumentationやログ出力
- コントローラコールバックやrescueハンドラの実行
- バックグラウンドでのメール処理
- テスト用ヘルパー
- Rails 4.2以降やJRubyのサポート
- その他
本記事の目的は、私の成果を誇ることではなく、汎用ライブラリをRailsに統合して便利にしようとすると実際にどれだけの労力が必要か、特にRailsエンジンの場合はどうなるかという私の気づきを皆さんと共有することです。私と議論を繰り返し、私がRodauthに追加したあらゆるプルリクをレビュー・マージしてくれたJeremy Evansにはどれほど感謝しても足りません。JeremyのおかげでスムーズなRails統合が可能になりました!🙏
RailsでRodauthを手軽に使えるようにするという私の目標は達成できたと思いますが、Rodauthユーザーの皆さんから継続的にフィードバックをいただいていることにも感謝いたします。現在、私からの回答の多くをwikiやガイドやscreencastにまとめて、よくあるユースケースを扱うための知識を大きく育てる作業を試みているところです。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: 週刊Railsウォッチ20221025 RodauthをRailsと統合するのに必要だったこと
以下のrodauth-rails READMEもどうぞ。