Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 認証gem ‘Rodauth’を統合するrodauth-railsを開発しました(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

janko/rodauth-rails - GitHub

参考: 週刊Railsウォッチ20221025 RodauthをRailsと統合するのに必要だったこと

Rails: 認証gem ‘Rodauth’を統合するrodauth-railsを開発しました(翻訳)

Rodauthが登場した当時の既存のソリューションは、Rails(DeviseやSorceryの場合)か少なくともActive Record(Authlogicの場合)が必要だったので、ついにRailsに縛られないフル機能の認証フレームワークが使えるようになったのかと興奮したものでした。私は主にRailsで開発していますが、現実的な代替手段として他のRuby Webフレームワークも欲しいので、誰でも利用できる汎用的なソリューションに惹きつけられたのは自然な流れでした。

jeremyevans/rodauth - GitHub

RodauthはRodaSequelの上に構築されていますが、Rackミドルウェアとして任意のRuby Webフレームワーク上で動かせます。なお、かつてはroda-railsというgemを用いてRodauthをRailsで使う方法を紹介するデモアプリもありました(現在はどちらも廃止)が、このRails統合はかなり未熟で、Rails開発者が慣れ親しんでいる人間工学を明らかに欠いていました。

jeremyevans/roda - GitHub

Rodauthには実に多くの機能セットがありますが、他の認証ソリューションと互角に競争するには、Railsで使うときの利便性をそれらと同じ水準まで高める必要がありました。つまり、Railsフレームワークに深いレベルで統合し、コードの置き場所を明確にし、手軽に始められるデフォルト値を用意する必要があったのです。私は2020年初頭から、RailsでRodauthを手軽に使えるようにするという使命を自分に課しました。

初期段階

最初はデモRailsアプリを作成し、そこでRodauthの設定を始めました。初期段階のコード(8affb94e69f9bd)では、ビューのレンダリング、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とシームレスに連携する必要があるということです。

jeremyevans/sequel - GitHub

考えられる方法の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環境のデータベースがオープンしなければならないコネクション数が倍増するだろう」と警告してくれました。そういうわけで、この方法では望ましい開発体験を達成しようがないことがわかってきました。

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のデータベースコネクションを使う

janko/sequel-activerecord_connection - GitHub

モデル

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のコンフィグに基づく関連付けを(関連付けられるモデルとともに)定義します。

janko/rodauth-model - GitHub

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 %>

rtomayko/tilt - GitHub

Rodauth操作の一部がTurbo互換でないため(multi-phaseログインとリカバリーコード表示がフォーム送信のレスポンスで200を返す)、すべてが確実に動作するようにデフォルトではすべてのHTMLフォームのTurboを無効にしておくことにしました。

今後の計画

  • 開発者がパスワードハッシュの安全性を手軽に高められるように、マイグレーションジェネレータでデータベースの認証関数をサポートしたいと思います。これまでも折を見て取り組んできましたが、適切なマイグレーションコードの生成はかなり複雑で、特にSQLデータベースごとに異なるセットアップが必要です(PostgreSQL、MySQL、SQL Server)。

  • RodauthのデフォルトのビューテンプレートではBootstrapのマークアップを用いていますが、Ben Koshyが追加作業を進めているTailwind CSSのサポート(#114)はいい感じです。tailwindcss-rails gemを使う場合のデフォルトである--css=tailwindをそのうち渡せるようになるでしょう。

  • サードパーティのRodauth拡張で、Railsジェネレータ用のマイグレーションやビューを今よりも簡単に提供できるようにしたいと思います。現時点のrodauth-oauth gemは独自のジェネレータ(rodauth:oauth:installrodauth:oauth:views)を提供していますが、ジェネレータの開発が重複しなくなったら嬉しいですね。

HoneyryderChuck/rodauth-oauth - GitHub

  • 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にまとめて、よくあるユースケースを扱うための知識を大きく育てる作業を試みているところです。

関連記事

Ruby: 認証gem「Rodauth」README(更新翻訳)

『Polished Ruby Programming』(Jeremy Evans著)を読みました


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。