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

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

追記(2022/11/11)

本記事の更新版は以下です。

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

こんにちは、hachi8833です。

今回は、「Railsアプリの認証システムをセキュアにする4つの方法」でも取り上げられていたRodauthのREADMEを翻訳しました。

現時点では、残念ながらRodauthをRailsで使うためのroda-rails gemがRails 4.2までしか対応していないのと、ルーティングにDSLを使うことから、おそらくRails 5で使うには一手間かける必要がありそうです。

追記(2022/10/24)

roda-railsはその後アーカイブされました。
現在はjankoさんがrodauth-railsを精力的に開発しているのでこちらをチェックしてみてください。

janko/rodauth-rails - GitHub

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

しかし、認証のセキュリティを考えるうえで参考になる情報が多く、ドキュメントの質が(少なくともDeviseと比べて)非常に高いのが特徴です。大きな概要をまず示し、必要な概念も適宜示しながら、先に進むに連れて詳細に説明するという書き方が見事です。

さらに、READMEから読み取れる筋のよい認証システム設計も参考になると思います。パスワードハッシュの保存場所を完全に隔離した上で可能な限り自由度を高めている点や、機能ごとにカラムを足したり減らしたりするのではなくテーブルを足したり減らしたりする点など、学ぶところが多そうです。

概要

MITライセンスに基いて翻訳・公開します。


http://rodauth.jeremyevans.net/より

Rodauth README(翻訳)

RodauthはRackアプリケーション向けの認証・アカウント管理フレームワークです。ビルドにはRodaとSequelを使っていますが、他のWebフレームワーク・データベースライブラリ・データベースでも利用できます。PostgreSQL・MySQL・Microsoft SQL Serverをデフォルト設定で使うと、データベース関数経由でのアクセスが保護されるようになり、パスワードハッシュのセキュリティを強化できます。

設計上のゴール

  • セキュリティ: デフォルト設定で最大のセキュリティを利用できること
  • 簡潔性: DSLで簡単に設定できること
  • 柔軟性: フレームワークのどの部分でも機能をオーバーライドできること

機能リスト

  • ログイン
  • ログアウト
  • パスワードの変更
  • ログインの変更
  • パスワードのリセット
  • アカウントの作成
  • アカウントの無効化
  • アカウントのバリデーション
  • パスワードの確認
  • パスワードの保存(トークン経由での自動ログイン)
  • ロックアウト(総当たり攻撃からの保護)
  • OTP (TOTP経由の2要素認証)
  • リカバリーコード(バックアップコード経由の2要素認証)
  • SMSコード(SMS経由の2要素認証)
  • ログイン変更のバリデーション(ログイン変更前の新規ログインバリデーション)
  • アカウントの許容期間(ログイン前のバリデーションを不要にする)
  • パスワードの許容期間(パスワードを最近入力した場合はパスワード入力を不要にする)
  • パスワードの強度(より洗練されたチェック)
  • パスワードの使い回し禁止
  • パスワードの有効期限
  • アカウントの有効期限
  • セッションの有効期限
  • シングルセッション(アカウントのアクティブセッションを1つに限定)
  • JWT(他のすべての機能でJSON APIをサポート)
  • パスワードハッシュの更新(ハッシュのcostが変更された場合)
  • HTTP BASIC認証

リソース

Webサイト
http://rodauth.jeremyevans.net
デモサイト
http://rodauth-demo.jeremyevans.net
ソースコード
http://github.com/jeremyevans/rodauth
バグ報告
http://github.com/jeremyevans/rodauth/issues
Google Group
https://groups.google.com/forum/#!forum/rodauth
IRC(チャット)
irc://chat.freenode.net/#rodauth

依存関係

Rodauthがデフォルトで依存しているgemが若干ありますが、それらについてはRodauthの開発上依存しているものであり、運用上はgemなしで実行することも可能です。

tilt、rack_csrf
すべての機能で利用(JSON API onlyモードの場合を除く)
bcrypt
パスワードの一致チェックでデフォルトで利用(カスタム認証でpassword_match?をオーバーライドすればスキップ可能)
mail
パスワードリセット時・アカウント確認時・ロックアウト機能のメール送信で利用
rotp、rqrcode
OTP機能で利用
jwt
JWT機能で利用

セキュリティ

データベース関数経由でのパスワードハッシュアクセス

RodauthでPostgreSQL・MySQL・Microsoft SQL Serverを利用する場合、デフォルトでパスワードハッシュにアクセスするためのデータベース関数を使います。これにより、アプリケーションを実行するユーザーはパスワードハッシュに直接アクセスできないようになっています。この機能によって攻撃者がパスワードハッシュにアクセスするリスクや、パスワードハッシュを他のサイトの攻撃に利用されるリスクを減らします。

本セクションでは以後この機能についてもっと詳しく説明します。なお、Rodauthはこの機能を使わなくても利用できます。異なるデータベースを利用している場合や、データベースの権限が不足している場合などには、この機能を利用できないことがあります。

パスワードはbcryptでハッシュ化され、アカウントテーブルとは別のテーブルに保存されます。また、2つのデータベース関数を追加します。1つはパスワードで使うsaltを取得する関数、もう1つは渡されたパスワードハッシュがユーザーのパスワードハッシュと一致するかどうかをチェックする関数です。

Rodauthでは2つのデータベースアカウントを使います。1つはアプリで使うアカウント用(以下「appアカウント」)、もう1つはパスワードハッシュ用(以下「phアカウント」)です。phアカウントは、渡されたパスワードのsaltを取得するデータベース関数と、渡されたアカウントでパスワードハッシュが一致するかどうかをチェックする関数を設定します。2つの関数は、appアカウントでphアカウントのパーミッションを使って実行されるようになっています。これにより、appアカウントでパスワードハッシュを読み取らずにパスワードをチェックできます。

appアカウントではパスワードハッシュを読み出すことはできない代わりに、パスワードハッシュのINSERT、UPDATE、DELETEはできるので、この機能によって追加されたセキュリティで大きな不便は生じません。

appアカウントでのパスワードハッシュ読み取りを禁止したことによって、仮にアプリのSQLインジェクションやリモートでのコード実行の脆弱性を攻撃された場合であっても、攻撃者によるパスワードハッシュの読み取りはさらに難しくなっています。

パスワードハッシュにこのようなセキュリティ対策を追加した理由は、弱いパスワードを使うユーザーやパスワードを使いまわすユーザーが後を絶たず、あるデータベースのパスワードハッシュが盗み出されると他のサイトのアカウントにまでアクセスされる可能性があるからです。そのため、たとえ保存されている他のデータの重要性が低いとしても、パスワードハッシュの保存方法はセキュリティ上きわめて重要度が高くなっています。

アプリのデータベースに機密情報が他にも保存されているのであれば、他の情報(あるいはすべての情報)についてもパスワードハッシュと同様のアプローチを行うことを検討すべきです。

トークン

アカウントの検証トークン、パスワードリセットのトークン、パスワード保存のトークン、ロックアウトのトークンでは同様のアプローチを採用しています。これらはすべてアカウントID_長いランダム文字列形式のトークンを提供します。トークンにアカウントIDを含めることで、攻撃者は全アカウントに対してトークンを総当り(ブルートフォース)攻撃で推測できなくなり、一度に1人のユーザーに対してしか総当たり攻撃を行えなくなります。なお、トークンがランダム文字列だけで構成されていると、全アカウントに対して総当たり攻撃が可能になる場合があります。

さらに、トークンの比較にはタイミング攻撃に対して安全な関数を採用し、タイミング攻撃のリスクを低減しています。

PostgreSQLデータベースの設定

PostgreSQLでRodauthのセキュリティ設計をすべて利用するには、複数のデータベースアカウントを使います。

  1. データベースのsuperuserアカウント(通常はpostgres)
  2. appアカウント(実際はアプリと同じ名前にします)
  3. phアカウント(実際はアプリ名に_passwordを追加した名前にします)

データベースのsuperuserアカウントは、データベースに関連する拡張(extension)の読み込みに使われます。アプリでは絶対にデータベースのsuperuserアカウントを使ってはいけません。

HerokuのPostgreSQLデータベースについては、上のようなシンプルな方法で複数のデータベースアカウントを設定する方法がありません。もちろん、HerokuでRodauthを使うことはできますが、セキュリティ上の利点は同じにはなりません。ただしこれはセキュリティ上危険ということではなく、パスワードハッシュの保存方法が他のメジャーな認証ソリューションと同じレベルになるということです。

データベースアカウントの作成

アプリがデータベースのsuperuserアカウントを使って実行されているのであれば、最初にappデータベースアカウントの作成が必要です。このアカウント名はデータベース名と同じにしておくのが多くの場合ベストです。

続いてphデータベースアカウントを作成します。このアカウントはパスワードハッシュへのアクセスに使われます。

  • PostgreSQLでの実行例
createuser -U postgres ${DATABASE_NAME}
createuser -U postgres ${DATABASE_NAME}_password

superuserアカウントがデータベース内の全アイテムの所有者になっている場合、上で作成したオーナーシップの変更が必要です。詳しくはhttps://gist.github.com/jeremyevans/8483320をご覧ください。

データベースの作成

一般に、アプリのアカウントはほとんどのテーブルを所有するので、アプリのアカウントがデータベースのオーナーとなります。

createdb -U postgres -O ${DATABASE_NAME} ${DATABASE_NAME}

上の方法はアプリ開発方法として最もセキュアとは言えないため、注意が必要です。セキュリティを最大化したい場合は、テーブルのオーナーとして独自のデータベースアカウントを使い、アプリのアカウントはテーブルのオーナーにならないようにし、正常動作に必要な最小限のアクセス権だけをアプリのアカウントに許可します。

拡張の読み込み

Rodauthのログイン機能で大文字小文字を区別しないログインをサポートするには、citext拡張を読み込む必要があります。

例:

psql -U postgres -c "CREATE EXTENSION citext" ${DATABASE_NAME}

Herokuの場合、citextは標準のデータベースアカウントで読み込まれます。ログインで大文字小文字を区別したいのであれば(ただし一般にはよくないとされています)、PostgreSQLのcitext拡張を読み込む必要はありません。その場合は、マイグレーション内のcitextStringに変更し、メールアドレスに対応できるようにしてください。

デフォルト以外のスキーマを使う

PostgreSQLは、デフォルトでパブリックなスキーマで新規テーブルをセットアップします。ユーザーごとに個別のスキーマを使いたい場合は、次のようにします。

psql -U postgres -c "DROP SCHEMA public;" ${DATABASE_NAME}
psql -U postgres -c "CREATE SCHEMA ${DATABASE_NAME} AUTHORIZATION ${DATABASE_NAME};" ${DATABASE_NAME}
psql -U postgres -c "CREATE SCHEMA ${DATABASE_NAME}_password AUTHORIZATION ${DATABASE_NAME}_password;" ${DATABASE_NAME}
psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME} TO ${DATABASE_NAME}_password;" ${DATABASE_NAME}
psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME}_password TO ${DATABASE_NAME};" ${DATABASE_NAME}

スキーマを指定する拡張の読み込み部分のコードの変更が必要です。

psql -U postgres -c "CREATE EXTENSION citext SCHEMA ${DATABASE_NAME}" ${DATABASE_NAME}

phユーザーでマイグレーションを実行する場合、スキーマ変更に対応するいくつかの変更が必要です。

create_table(:account_password_hashes) do
  foreign_key :id, Sequel[:${DATABASE_NAME}][:accounts], :primary_key=>true, :type=>:Bignum
  String :password_hash, :null=>false
end
Rodauth.create_database_authentication_functions(self, :table_name=>"${DATABASE_NAME}_password.account_password_hashes")

# if using the disallow_password_reuse feature:
create_table(:account_previous_password_hashes) do
  primary_key :id, :type=>:Bignum
  foreign_key :account_id, Sequel[:${DATABASE_NAME}][:accounts], :type=>:Bignum
  String :password_hash, :null=>false
end
Rodauth.create_database_previous_password_check_functions(self, :table_name=>"${DATABASE_NAME}_password.account_previous_password_hashes")

また、次のRodauth設定メソッドを使って、アプリのアカウントが個別のスキーマで関数を呼び出すようにします。

function_name do |name|
  "${DATABASE_NAME}_password.#{name}"
end
password_hash_table Sequel[:${DATABASE_NAME}_password][:account_password_hashes]

# disallow_password_reuse でパスワード再利用を禁止する場合:
previous_password_hash_table Sequel[:${DATABASE_NAME}_password][:account_previous_password_hashes]

MySQLデータベースの設定

MySQLにはオブジェクトの所有者という概念がなく、MySQLのGRANTやREVOKEのサポートはPostgreSQLと比べて限定されています。MySQLを使う場合、以下のようにphアカウントにGRANT ALLしてすべてのパーミッションを与え、さらにWITH GRANT OPTIONphアカウントからappアカウントにGRANTできるようにすることをおすすめします。

CREATE USER '${DATABASE_NAME}'@'localhost' IDENTIFIED BY '${PASSWORD}';
CREATE USER '${DATABASE_NAME}_password'@'localhost' IDENTIFIED BY '${OTHER_PASSWORD}';
GRANT ALL ON ${DATABASE_NAME}.* TO '${DATABASE_NAME}_password'@'localhost' WITH GRANT OPTION;

マイグレーションの実行は常にphアカウントで行い、appアカウントには必要に応じてGRANTで特定のアクセス権を与えなければなりません。

MySQLでデータベース関数を追加するには、MySQLの設定にlog_bin_trust_function_creators=1が必要になることがあります。

Microsoft SQL Serverデータベースの設定

Microsoft SQL Serverにはデータベースの所有者という概念はありますが、MySQLの場合と同様、phアカウントをデータベースのスーパーユーザーとして使い、phからGRANTでappアカウントにパーミッションを与えられるようにすることをおすすめします。

CREATE LOGIN rodauth_test WITH PASSWORD = 'rodauth_test';
CREATE LOGIN rodauth_test_password WITH PASSWORD = 'rodauth_test';
CREATE DATABASE rodauth_test;
USE rodauth_test;
CREATE USER rodauth_test FOR LOGIN rodauth_test;
GRANT CONNECT, EXECUTE TO rodauth_test;
EXECUTE sp_changedbowner 'rodauth_test_password';

マイグレーションの実行は常にphアカウントで行い、appアカウントには必要に応じてGRANTで特定のアクセス権を与えなければなりません。

テーブルの作成

異なる2種類のデータベースアカウントを使っているため、マイグレーションもデータベースアカウントごとに実行する必要があります(2つの異なるマイグレーションを実行します)。マイグレーションの例を以下に示します。このマイグレーションを変更して追加カラムをサポートしたり、Rodauthの不要な機能に関連するカラムやテーブルを削除することもできます。

1回目のマイグレーション

PostgreSQLの場合はappアカウントで実行する必要があります。MySQLやMicrosoft SQL Serverの場合はphアカウントで実行する必要があります。

マイグレーションの実行にはSequel 4.35.0以降が必要です。これより前のバージョンのSequelを使っている場合は、:BignumシンボルをBignum定数に変更してください。

Sequel.migration do
  up do
    extension :date_arithmetic

    # アカウントの検証やアカウントの無効化機能で使用
    create_table(:account_statuses) do
      Integer :id, :primary_key=>true
      String :name, :null=>false, :unique=>true
    end
    from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']])

    db = self
    create_table(:accounts) do
      primary_key :id, :type=>:Bignum
      foreign_key :status_id, :account_statuses, :null=>false, :default=>1
      if db.database_type == :postgres
        citext :email, :null=>false
        constraint :valid_email, :email=>/^[^,;@ rn]+@[^,@; rn]+.[^,@; rn]+$/
        index :email, :unique=>true, :where=>{:status_id=>[1, 2]}
      else
        String :email, :null=>false
        index :email, :unique=>true
      end
    end

    deadline_opts = proc do |days|
      if database_type == :mysql
        {:null=>false}
      else
        {:null=>false, :default=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :days=>days)}
      end
    end

    # パスワードのリセット機能で使用
    create_table(:account_password_reset_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # アカウントの検証機能で使用
    create_table(:account_verification_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :requested_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # ログイン変更の検証機能で使用
    create_table(:account_login_change_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      String :login, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # パスワード保存機能で使用
    create_table(:account_remember_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[14]
    end

    # ロックアウト機能で使用
    create_table(:account_login_failures) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      Integer :number, :null=>false, :default=>1
    end
    create_table(:account_lockouts) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # パスワードの有効期限機能で使用
    create_table(:account_password_change_times) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      DateTime :changed_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # アカウントの有効期限機能で使用
    create_table(:account_activity_times) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      DateTime :last_activity_at, :null=>false
      DateTime :last_login_at, :null=>false
      DateTime :expired_at
    end

    # シングルセッション機能で使用
    create_table(:account_session_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
    end

    # OTP機能で使用
    create_table(:account_otp_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      Integer :num_failures, :null=>false, :default=>0
      Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # リカバリーコード機能で使用
    create_table(:account_recovery_codes) do
      foreign_key :id, :accounts, :type=>:Bignum
      String :code
      primary_key [:id, :code]
    end

    # SMSコード機能で使用
    create_table(:account_sms_codes) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :phone_number, :null=>false
      Integer :num_failures
      String :code
      DateTime :code_issued_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    case database_type
    when :postgres
      user = get{Sequel.lit('current_user')} + '_password'
      run "GRANT REFERENCES ON accounts TO #{user}"
    when :mysql, :mssql
      user = if database_type == :mysql
        get{Sequel.lit('current_user')}.sub(/_password@/, '@')
      else
        get{DB_NAME{}}
      end
      run "GRANT ALL ON account_statuses TO #{user}"
      run "GRANT ALL ON accounts TO #{user}"
      run "GRANT ALL ON account_password_reset_keys TO #{user}"
      run "GRANT ALL ON account_verification_keys TO #{user}"
      run "GRANT ALL ON account_login_change_keys TO #{user}"
      run "GRANT ALL ON account_remember_keys TO #{user}"
      run "GRANT ALL ON account_login_failures TO #{user}"
      run "GRANT ALL ON account_lockouts TO #{user}"
      run "GRANT ALL ON account_password_change_times TO #{user}"
      run "GRANT ALL ON account_activity_times TO #{user}"
      run "GRANT ALL ON account_session_keys TO #{user}"
      run "GRANT ALL ON account_otp_keys TO #{user}"
      run "GRANT ALL ON account_recovery_codes TO #{user}"
      run "GRANT ALL ON account_sms_codes TO #{user}"
    end
  end

  down do
    drop_table(:account_sms_codes,
               :account_recovery_codes,
               :account_otp_keys,
               :account_session_keys,
               :account_activity_times,
               :account_password_change_times,
               :account_lockouts,
               :account_login_failures,
               :account_remember_keys,
               :account_login_change_keys,
               :account_verification_keys,
               :account_password_reset_keys,
               :accounts,
               :account_statuses)
  end
end

2回目のマイグレーション

phアカウントで実行します。

require 'rodauth/migrations'

Sequel.migration do
  up do
    create_table(:account_password_hashes) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :password_hash, :null=>false
    end
    Rodauth.create_database_authentication_functions(self)
    case database_type
    when :postgres
      user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
      run "REVOKE ALL ON account_password_hashes FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT(id) ON account_password_hashes TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO #{user}"
    when :mysql
      user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
      db_name = get(Sequel.function(:database))
      run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT (id) ON account_password_hashes TO #{user}"
    when :mssql
      user = get(Sequel.function(:DB_NAME))
      run "GRANT EXECUTE ON rodauth_get_salt TO #{user}"
      run "GRANT EXECUTE ON rodauth_valid_password_hash TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT ON account_password_hashes(id) TO #{user}"
    end

    # disallow_password_reuse 機能で使われる
    create_table(:account_previous_password_hashes) do
      primary_key :id, :type=>:Bignum
      foreign_key :account_id, :accounts, :type=>:Bignum
      String :password_hash, :null=>false
    end
    Rodauth.create_database_previous_password_check_functions(self)

    case database_type
    when :postgres
      user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
      run "REVOKE ALL ON account_previous_password_hashes FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_get_previous_salt(int8) FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_previous_password_hash_match(int8, text) FROM public"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT(id, account_id) ON account_previous_password_hashes TO #{user}"
      run "GRANT USAGE ON account_previous_password_hashes_id_seq TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_get_previous_salt(int8) TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_previous_password_hash_match(int8, text) TO #{user}"
    when :mysql
      user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
      db_name = get(Sequel.function(:database))
      run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT (id, account_id) ON account_previous_password_hashes TO #{user}"
    when :mssql
      user = get(Sequel.function(:DB_NAME))
      run "GRANT EXECUTE ON rodauth_get_previous_salt TO #{user}"
      run "GRANT EXECUTE ON rodauth_previous_password_hash_match TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT ON account_previous_password_hashes(id, account_id) TO #{user}"
    end
  end

  down do
    Rodauth.drop_database_previous_password_check_functions(self)
    Rodauth.drop_database_authentication_functions(self)
    drop_table(:account_previous_password_hashes, :account_password_hashes)
  end
end

マイグレーションを複数ユーザーで手分けして行いたい場合、SequelのマイグレーションAPIを使ってパスワードユーザーのマイグレーションを実行できます。

Sequel.extension :migration
Sequel.postgres('DATABASE_NAME', :user=>'PASSWORD_USER_NAME') do |db|
  Sequel::Migrator.run(db, 'path/to/password_user/migrations', :table=>'schema_info_password')
end

PostgreSQL・MySQL・Microsoft SQL Server以外のデータベースを使う場合や、(データベースの)ユーザーアカウントを複数使えない場合は、単に2つのマイグレーションを1つのマイグレーションにまとめます。

上のマイグレーションを読むとわかるように、Rodauthでは1つのテーブルにさまざまなカラムを追加するのではなく、追加機能ごとにテーブルを追加する設計になっています。

使い方

基本的な使い方

RodauthはRodaのプラグインなので、以下のように他のRodaプラグインと同じ方法で読み込みます。

plugin :rodauth do
end

plugin呼び出しでは、Rodauthの設定用DSLをブロックとして受け取ります。読み込む機能を指定するenableという設定メソッドは省略できません。

plugin :rodauth do
  enable :login, :logout
end

機能が読み込まれた後は、その機能でサポートされる設定用メソッドをすべて利用できるようになります。設定用メソッドには次の2種類があります。

    1. 認証系メソッド

1つ目は認証系メソッド(auth methods)と呼ばれます。これらのメソッドはブロックを1つ取り、Rodauthのデフォルトメソッドをオーバーライドします。ブロック内でsuperを呼ぶとデフォルトの動作を取得できますが、superには明示的に引数を渡す必要があります。なお、beforeフックやafterフックではsuperを呼ぶ必要はありません。

たとえば、ユーザーのログイン時にログ出力を追加したい場合は次のようにします。

plugin :rodauth do
  enable :login, :logout
  after_login do
    LOGGER.info "#{account[:email]} logged in!"
  end
end

ブロック内は、リクエストに関連付けられたRodauth::Authインスタンスのコンテキストになります。このオブジェクトで次のメソッドを使うと、リクエストに関連するあらゆるデータにアクセスできます。

request
RodaRequestのインスタンス
response
RodaResponseのインスタンス
scope
Rodaのインスタンス
session
セッションのハッシュ
flash
flashメッセージのハッシュ
account
アカウントモデルのインスタンス(Rodauthのメソッドで事前に設定済みの場合)

ログイン中のユーザーのIPアドレスをログ出力したい場合は次のようにします。

plugin :rodauth do
  enable :login, :logout
  after_login do
    LOGGER.info "#{account[:email]} logged in from #{request.ip}"
  end
end
    1. 認証値系メソッド

設定用メソッドの2つ目は認証値(auth value)のメソッドです。認証値系メソッドは認証系メソッドと似ていますが、単にブロックを受け取るほかに、ブロック無しで引数を1つ受け取ることもできます。受け取った引数は、その値を単に返すブロックとして扱われます。

たとえば、データベースのテーブルにアカウントを保存するaccounts_tableの場合、次のようにテーブル名をシンボルで渡すことでオーバーライドできます。

plugin :rodauth do
  enable :login, :logout
  accounts_table :users
end

認証値系メソッドはブロックを1つ受け取ることもできるので、リクエストから得られる情報を使ってすべての挙動を上書きできます。

plugin :rodauth do
  enable :login, :logout
  accounts_table do
    request.ip.start_with?("192.168.1") ? :admins : :users
  end
end

Rodauthではどの設定メソッドもブロックを受け取れるので、多くのレガシーシステムを統合するのに十分な柔軟性を備えています。

各機能のドキュメント

サポートされている各機能のオプションやメソッドについては、機能ごとに別ページを設けています。もしリンクが切れていたら、ドキュメントのディレクトリで必要なファイルを参照してください。

Rodauthをルーティングツリーで呼び出す

一般に、以下のようにrodauthをルーティングブロックの早い段階で呼び出すのが普通です。

route do |r|
  r.rodauth

  # ...
end

Rodauthはこれで実行できます。ただしこのままでは、アクセスするユーザーのログインを必須にしたり、サイトにセキュリティを追加したりできません。すべてのユーザーに対してログインを必須にするには、ログインしていないユーザーを以下のように強制的にログインページにリダイレクトします。

route do |r|
  r.rodauth
  rodauth.require_authentication

  # ...
end

ログインを必須にしたいページがサイトの一部に限られている場合は、以下のようにすると、ルーティングツリーの特定のブランチについてだけユーザーがログインしていない場合にリダイレクトできます。

route do |r|
  r.rodauth

  r.on "admin" do
    rodauth.require_authentication

    # ...
  end

  # ...
end

Rodauthをルーティングツリーのルートではなく、ルーティングのブランチ内でだけ実行したい場合があります。その場合は以下のようにRodauthの設定で:prefixを設定してから、ルーティングツリーの該当するブランチでr.rodauthを呼び出します。

plugin :rodauth do
  enable :login, :logout
  prefix "auth"
end

route do |r|
  r.on "auth" do
    r.rodauth
  end

  rodauth.require_authentication

  # ...
end

rodauthメソッド

Rodauthの機能のほとんどはr.rodauth経由で公開されています。これを使って、Rodauthで自分が有効にした機能にルーティングできます(ログイン機能の/loginなど)。しかし、上述したようにこうしたメソッドをrodauthオブジェクトで呼び出したいこともあります(現在のリクエストが認証済みであるかどうかのチェックなど)。

以下のメソッドは、r.rodauthの外でもrodauthオブジェクトで呼び出せるように設計されています。

require_login
セッションでログインを必須にし、ログインしていないリクエストをログインページにリダイレクトします。
require_authentication
require_loginと似ていますが、アカウントが2要素認証用に設定されている場合は2要素認証も必須にします。ログイン済みであっても2要素認証されていない場合は、リクエストを2要素認証ページにリダイレクトします。
logged_in?
セッションがログイン中であるかどうかを返します。
authenticated?
logged_in?と似ていますが、アカウントが2要素認証用に設定されている場合はセッションが2要素認証されているかどうかを返します。
require_two_factor_setup
(2要素認証用)セッションで2要素認証を必須にします。2要素認証されていない場合は、リクエストを2要素認証ページにリダイレクトします。
uses_two_factor_authentication?
(2要素認証用)現在のセッションのユーザーが2要素認証を使えるよう設定されているかどうかを返します。
update_last_activity
(アカウント有効期限用)現在のアカウントの最終活動時刻を更新します。最終活動時刻を基にアカウントの有効期限が切れるようにしてある場合にのみ意味があります。
require_current_password
(アカウント有効期限用)アカウントのパスワードの有効期限が切れた場合に、パスワード変更ページにリダイレクトして現在のパスワードを入力しないと継続できないようにします。
load_memory
(パスワード保存機能用)セッションが認証されていない場合に、remember cookieがあるかどうかをチェックします。有効なremember cookieがある場合はセッションに自動ログインしますが、rememberキー経由でログインしたというマークを付けます。
logged_in_via_remember_key?
(パスワード保存機能用) rememberキーを使って現在のセッションにログインしたかどうかを返します。セキュリティ上重要な操作でパスワードの再入力を必須にしたい場合は、confirm_passwordを使えます。
check_session_expiration
(セッション有効期限用) 現在のセッションの有効期限が切れているかどうかをチェックし、期限切れの場合は自動的にログアウトします。
check_single_session
(シングルセッションの有効期限) 現在のセッションがまだ有効かどうかをチェックし、無効な場合はセッションからログアウトします。
verified_account?
(許容期間の確認の延長) 現在のアカウントが(訳注: メールなどで)確認済みかどうかを返します。falseの場合、ユーザーが「許容期間」に該当しているためにログインを許されていることを示します。
locked_out?
(ロックアウト機能) 現在のセッションのユーザーがロックアウトされているかどうかを返します。

複数の設定を使う

Rodauthでは、同じアプリケーションで複数のrodauth設定の利用をサポートしています。これは、プラグインを読み込んで2度目のログインで別設定の名前を指定するだけで行なえます。

plugin :rodauth do
end
plugin :rodauth, :name=>:secondary do
end

その後は、いつでもルーティングでrodauthを呼び、使いたい設定名を引数で指定できるようになります。

route do |r|
  r.on 'secondary' do
    r.rodauth(:secondary)
  end

  r.rodauth
end

パスワードハッシュをアカウントのテーブルに保存する

Rodauthでは、パスワードハッシュをアカウントと同じテーブルに保存することもできます。これは、パスワードハッシュを保存するカラムを指定するだけで行なえます。

plugin :rodauth do
  account_password_hash_column :password_hash
end

Rodauthでこのオプションを設定すると、パスワードハッシュのチェックをRubyで行うようになります。

PostgreSQL/MySQL/Microsoft SQL Serverでデータベース関数を使わないようにする

RodauthとPostgreSQL/MySQL/Microsoft SQL Serverで、認証用のデータベース関数を使いたくないがハッシュテーブルは従来どおり別テーブルに保存したい場合は、次のようにします。

plugin :rodauth do
  use_database_authentication_functions? false
end

言い換えると、rodauth_get_salt関数とrodauth_valid_password_hash関数を独自に実装すれば、PostgreSQL/MySQL/Microsoft SQL Server以外のデータベースでもこの値をtrueにできます。

認証をカスタマイズする

Rodauthの設定用メソッドの中には、他の種類の認証方法を使えるようにできるものもあります。

認証をカスタマイズすると、ログインの変更やパスワードの変更などのRodauthの機能の使い方がわからなくなったり、カスタム設定を追加する必要が生じたりするかもしれません。ただし以下のカスタマイズ例では、ログイン機能とログアウト機能は正常に機能します。

  • LDAP認証を使う

アカウントがデータベースに保存されている状態でLDAP認証したい場合は、simple_ldap_authenticatorライブラリを利用できます。

require 'simple_ldap_authenticator'
plugin :rodauth do
  enable :login, :logout
  require_bcrypt? false
  password_match? do |password|
    SimpleLdapAuthenticator.valid?(account.username, password)
  end
end

データベースにアカウントがない状態でLDAPの有効なユーザーがログインできるようにしたい場合は、次のようにします。

require 'simple_ldap_authenticator'
plugin :rodauth do
  enable :login, :logout

  # LDAPで認証するのでbcryptライブラリをrequireしない
  require_bcrypt? false

  # セッションの値を:loginキーに保存する
  # (デフォルトの:account_idキーだとわかりにくいため)
  session_key :login

  # セッションの値で与えられたログインを使う
  account_session_value{account}

  # このログインそのものをアカウントとして使う
  account_from_login{|l| l.to_s}

  password_match? do |password|
    SimpleLdapAuthenticator.valid?(account, password)
  end
end
  • Facebook認証を使う

JSON APIでのFacebook認証の例を以下に示します。この設定では、クライアント側にJSONでPOSTリクエストを送信するコードがあることが前提です。このPOSTリクエストは/loginに送信され、FacebookでユーザーのOAuthアクセストークンを設定するaccess_tokenパラメータを含むとします。

 require 'koala'
 plugin :rodauth do
  enable :login, :logout, :jwt

  require_bcrypt? false
  session_key :facebook_email
  account_session_value{account}

  login_param 'access_token'

  account_from_login do |access_token|
    fb = Koala::Facebook::API.new(access_token)
    if me = fb.get_object('me', :fields=>[:email])
      me['email']
    end
  end

  # there is no password!
  password_match? do |pass|
    true
  end
end
  • その他のWebフレームワーク

Rodauthは、アプリケーションでRoda Webフレームワークが使われていなくても利用できます。これは、Rodauthを使うRodaミドルウェアを追加することで行なえます。

require 'roda'

class RodauthApp < Roda
  plugin :middleware
  plugin :rodauth do
    enable :login
  end

  route do |r|
    r.rodauth
    rodauth.require_authentication
    env['rodauth'] = rodauth
  end
end

use RodauthApp

RodauthはRodaアプリに対し、Rodaがレイアウト提供の目的で使われることを期待します。そのため、Rodauthを他のアプリ用のミドルウェアとして使う場合、Rodauthから使えるviews/layout.erbファイルがないのであれば、おそらくRodaのrenderプラグインの追加も必要になります。その場合、Rodauthがアプリと同じレイアウトを使えるようプラグインを適切に設定する必要もあるでしょう

ミドルウェア内部のルーティングブロックでenv['rodauth'] = rodauthを設定すると、Rodauthメソッドを簡単に呼び出せる方法をアプリに導入できるようになります。

Rodaを使わないアプリでのRodauth導入例をいくつか示します。

Rodauthでは、TOTP(Time-Based One-Time Passwords: RFC 6238)経由での2要素認証を使えます。Rodauthで2要素認証をアプリに統合する方法は、アプリでの必要に応じてさまざまなものがあります。

2要素認証のサポートはOTP機能の一部なので、ログイン機能に加えてOTP機能も有効にする必要があります。一般に、2要素認証を実装する場合は2要素認証を2種類用意し、プライマリの2要素認証が利用できない場合にセカンダリの2要素認証を提供するべきです。RodauthではSMSコードとリカバリーコードをセカンダリ2要素認証としてサポートします。

アプリで2要素認証をサポートし、かつ2要素認証を必須にしたくない場合は次のようにします。

plugin :rodauth do
  enable :login, :logout, :otp, :recovery_codes, :sms_codes
end
route do |r|
  r.rodauth
  rodauth.require_authentication

  # ...
end

OTP認証を全ユーザーで必須にし、アカウントを持っていないユーザーに対してOTP認証の設定を要求する場合の設定です。

route do |r|
  r.rodauth
  rodauth.require_authentication
  rodauth.require_two_factor_authentication_setup

  # ...
end

認証を必須にする場合の一般的な方法と同様に、特定のブランチでのみ2要素認証を必須にし、サイトの他の場所ではログイン認証を必須することもできます。

route do |r|
  r.rodauth
  rodauth.require_login

  r.on "admin" do
    rodauth.require_two_factor_authenticated
  end

  # ...
end

JSON APIサポート

プラグインに:jsonオプションを渡してJWT機能を有効にすると、JSONレスポンス取り扱いのサポートを追加できます。

plugin :rodauth, :json=>true do
  enable :login, :logout, :jwt
end

JSON APIをビルドするのであれば、:json => :onlyを渡すことでRodauthで通常読み込まれるHTML関連のプラグイン(render、csrf、flash、h)を読み込まないようにできます。

plugin :rodauth, :json=>:only do
  enable :login, :logout, :jwt
end

ただし、メール送信機能はデフォルトでrenderプラグインに依存していることにご注意ください。:json=>:onlyを使う場合は、renderプラグインを手動で読み込むか、*_email_body設定オプションでメールの本文を指定する必要があります。

JWT機能を導入すると、Rodauthに含まれるその他のJSON APIサポートもすべて利用できるようになります。

rodauthオブジェクトにカスタムメソッドを追加する

設定のブロック内でauth_class_evalを使うと、rodauth`オブジェクトから呼び出せるカスタムメソッドを追加できます。

plugin :rodauth do
  enable :login

  auth_class_eval do
    def require_admin
      request.redirect("/") unless account[:admin]
    end
  end
end

route do |r|
  r.rodauth

  r.on "admin" do
    rodauth.require_admin
  end
end

外部の機能を使う

有効にする設定メソッドは、Rodauthの外部にある機能を読み込めます。この外部機能のファイルは、rodauth/features/feature_nameからrequireできるディレクトリに置く必要があります。このファイルは以下の基本構造をとる必要があります。

module Rodauth
  # :feature_nameは、有効にしたい機能を指定する引数
  # :FeatureNameはオプションで、inspect出力を読みやすくする定数名を設定するのに使う
  Feature.define(:feature_name, :FeatureName) do
    # 認証値系メソッドを固定値で定義するショートカット
    auth_value_method :method_name, 1 # method_value

    auth_value_methods # 認証値メソッドごとに1つの引数

    auth_methods       # 認証メソッドごとに1つの引数

    route do |r|
      # この機能のルーティングへのリクエストをこのブロックで受ける
      # ブロックはRodauth::Authインスタンスのスコープで評価される
      # rはリクエストのRoda::RodaRequestインスタンス

      r.get do
      end

      r.post do
      end
    end

    configuration_eval do
      # メソッド固有の追加設定を必要に応じてここで定義する
    end

    # auth_methodsとauth_value_methodsのデフォルトの挙動を定義する
    # ...
  end
end

機能の構成方法の例については、Rodauthの機能のコードを参照してください。

ルーティングレベルの挙動をオーバーライドする

Rodauthのすべての設定メソッドは、Rodauth::Authインスタンスの挙動を変更します。しかし場合によってはルーティング層の扱いをオーバーライドしたくなることもあります。これは、r.rodauthを呼び出す前に以下のように適切なルーティングを追加するだけで簡単に行なえます。

route do |r|
  r.post 'login' do
    # ここにカスタム POST /login ハンドリングを記述する
  end

  r.rodauth
end

Rodauthテンプレートをプリコンパイルする

Rodauthは自分自身のgemフォルダにあるテンプレートを使います。fork型のWebサーバーを使っていて、コンパイル済みテンプレートを事前に読み込んでメモリを節約したい場合や、アプリをchrootしたい場合は、Rodauthのテンプレートをプリコンパイルすることでメリットを得られます。

plugin :rodauth do
  # ...
end
precompile_rodauth_templates

0.9.xからのアップグレード

Rodauthを0.9.xから現在のバージョンにアップグレードする場合の注意点です。

account_valid_passwordデータベース関数を使っていた場合はこれを削除し、上のマイグレーションに記載されている2つのデータベース関数を追加する必要があります。以下のコードをマイグレーションに追加することでこの作業を行えます。

require 'rodauth/migrations'
run "DROP FUNCTION account_valid_password(int8, text);"
Rodauth.create_database_authentication_functions(self)
run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO ${DATABASE_NAME}"
run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO ${DATABASE_NAME}"

類似のプロジェクト

以下はすべてRailsに特化しています。

  • Devise
  • Authlogic
  • Sorcery

著者

Jeremy Evans (code@jeremyevans.net

関連記事

Railsアプリの認証システムをセキュアにする4つの方法(翻訳)

[Rails] Devise Wiki日本語もくじ1「ワークフローのカスタマイズ」(概要・用途付き)


CONTACT

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