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

Rails: Rodauthでパスキー認証を行う(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。


本記事では以下の用語を使います。

  • authentication: 認証
  • verification: 検証
  • validation: バリデーション
  • credential: (資格情報: 英ママ)
  • biometric identification: 生体認証

本記事では、rodauth-railsがRailsアプリでセットアップ済みであることが前提になっています。
新規Railsアプリでゼロから試したい場合は、以下の公式デモアプリを使うとよいでしょう。このアプリは多要素認証のやニックネームのセットアップまで完了しています(コードは本記事とごくわずかに異なっているようです)。

janko/rodauth-demo-rails - GitHub

なお、このデモアプリの画面でアカウントを作成すると、本来は確認用のメールが送信されますが、ローカルのdevelopmentではメールが送信されないので、Railsのログ↓から確認用URLを探してブラウザで実行することで確認完了できます。あるいはRailsコンソールで直接ユーザー登録してもよいでしょう。

[ActiveJob] [ActionMailer::MailDeliveryJob] [f1c0e074-ceca-431e-8492-21201a84cc52] Date: Thu, 31 Aug 2023 11:13:48 +0900
From: webmaster@localhost
To: hachi8833@gmail.com
Message-ID: <64eff75c81ceb_402d3840614d9@tsuki.local.mail>
Subject: Verify Account
Mime-Version: 1.0
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

Someone has created an account with this email address.  If you did not create
this account, please ignore this message.  If you created this account, please go to
http://localhost:3000/verify-account?key=1_LexM7GWAG1LEJ0yfZyCXI_DEV6vNWD-s-i0FLjC7PQY # 確認用URLの例
to verify the account.

Rais: Rodauthでパスキー認証を行う(翻訳)

パスキー(Passkeys)は、パスワードの現代的な代替手段であり、認証をユーザーのデバイスが行います。通常、パスキーでは何らかの形でユーザー認証(生体認証やPIN)が必要です。

パスキーはWebAuthn仕様に基づいており、公開鍵暗号方式を利用しています。Webサイトごとに鍵ペアが作成され、公開鍵はサーバーに送信されますが、秘密鍵はデバイス上で安全に保管されます。

このため、パスキーには以下のような特徴があります。

  • パスワード方式よりも強力
  • データ侵害に対して安全
  • フィッシング攻撃に対して安全

WebAuthnのcredentials(資格情報)は、それを作成したデバイスに紐づけられます(YubiKeyなどの物理セキュリティキーを想像してみましょう)。この方式では、自分のデータを企業が持たないので、プライバシーが保護されますが、その代わり、デバイスを紛失するとアカウントにアクセスできなくなる可能性があります。

パスキーはクラウド上でバックアップされ、複数のデバイス間で同期可能なので、パスキーが紛失するリスクを軽減できます。

Rodauthは、webauthn-rubyという優秀なgem上に実装されている一流のパスキーサポートを提供します。

jeremyevans/rodauth - GitHub

cedarcode/webauthn-ruby - GitHub

これにより、パスキーを多要素認証1の手段として使うことも、パスワードレスログインやパスワードレス登録に使うことも可能になります。ルーティング、ビュー、データベースストレージはもちろん、ゼロコンフィグで利用できるWeb認証APIと対話する完全なJavaScriptコードも提供されます。

本記事では、rodauth-railsを既に利用しているRailsアプリでこれらを設定する方法を紹介したいと思います。

janko/rodauth-rails - GitHub

なお、私の場合はmacOS Ventura上のSafariを使っていて、iCloudキーチェーンの同期を有効にしているので、Appleパスキーが必要です。

🔗 多要素認証

過去記事でも述べたように、Rodauthは多要素認証の手段としてTOTP(タイムベースドワンタイムパスワード)2、リカバリーコード、SMSコードを利用できますが、それらに加えてパスキーも登録できます。

最初に、必要なデータベーステーブルを作成します。

$ rails generate rodauth:migration webauthn
$ rails db:migrate
class CreateRodauthWebauthn < ActiveRecord::Migration
  def change
    # WebAuthnのユーザー識別情報を保存する
    create_table :account_webauthn_user_ids, id: false do |t|
      t.integer :id, primary_key: true
      t.foreign_key :accounts, column: :id
      t.string :webauthn_id, null: false
    end
    # WebAuthnのcredentialsを保存する
    create_table :account_webauthn_keys, primary_key: [:account_id, :webauthn_id] do |t|
      t.references :account, foreign_key: true
      t.string :webauthn_id
      t.string :public_key, null: false
      t.integer :sign_count, null: false
      t.datetime :last_use, null: false, default: -> { "CURRENT_TIMESTAMP" }
    end
  end
end

次に、Rodauthの設定でwebauthn機能を有効にします。

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    enable :webauthn
  end
end

以下を実行して、パスキーの設定、パスキー経由の認証、およびパスキー削除で使うルーティングが追加されていることを確認します。

$ rails rodauth:routes
# ...
# GET/POST  /webauthn-auth    rodauth.webauthn_auth_path
# GET/POST  /webauthn-setup   rodauth.webauthn_setup_path
# GET/POST  /webauthn-remove  rodauth.webauthn_remove_path
# ...

これで、ユーザーが多要素認証方法を管理するページ(/multifactor-manage)を開くと、以下のようなWebAuthn認証の設定リンクが表示されるはずです。

Rodauth WebAuthn setup link

このページでは、WebAuthnのcredentials(資格情報)を登録するボタンが表示されます。このボタンは、必要なJavaScriptコードと連携済みなので、クリックすると新しいパスキーを作成するブラウザネイティブのダイアログが表示されます。私の場合、生体認証の検証(verification)が終われば、2次要素のセットアップが完了します。

Rodauth passkey registration dialog

次回このユーザーがログインすると、最初に1次要素で認証された後、2次要素としてパスキーで認証するオプションが提供されるようになります。

Rodauth WebAuthn auth link

パスキーの登録時と同様に、必要なJavaScriptコードはパスキーを使った認証ページにも既に組み込まれています。したがって、送信ボタンをクリックすると、以前に作成したパスキーで認証するためのダイアログが表示されるはずです。生体認証の検証が終われば、2次要素の認証が完了します。

Rodauth passkey authentication dialog

🔗 「パスワードレス」ログイン

ユーザーがWebサイトのパスキー作成を完了したら、多要素認証に加えて、パスワードレスログインにもパスキーを利用できるようになります。この機能はwebauthn_login機能によって提供されます。

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    enable :webauthn_login
  end
end

これで、ユーザーが最初にメールアドレスを入力すると、パスワードとパスキーのどちらで認証するかを選択できるマルチフェーズログインが自動的に有効になります。ただし、ユーザーがアカウント作成時にパスワードを設定していなかった場合は、パスワードフィールドは表示されません。

Rodauth passkey login

パスキーが検証されると、ユーザーがログインします。

多要素認証を使っている場合、デフォルトで行われるのは1次要素による認証のみなので、2次要素(に必要なページ)も必要となります。

ただし、一般にパスキーは多要素認証と見なされます。その理由は、ユーザーが以下の2つの要素を何らかの形で提示するからです。

  • 「本人が所有しているもの(デバイス)」
  • 「本人確認(生体情報)」または「本人だけが知る情報(PINコード)」(検証が行われた場合)

Rodauthでは、ユーザー検証を2次要素として考慮するよう指定できます。ユーザー検証が行われなかった場合、たとえば生体認証やPINコードの代わりにユーザーの存在のみを必要とするセキュリティキーが使われれば、1次要素のみが認証されます。

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    webauthn_login_user_verification_additional_factor? true
  end
end

ユーザーのログイン体験をさらに快適なものにするため、WebAuthnプロトコルでは、メールアドレスのフィールドにフォーカスを置いたときのパスキー自動入力UIをサポートしています。

Rodauthでは、webauthn_autofill機能でこのUIを組み込んでいます(ちなみに私が追加した機能なんですけどね!😊)。

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    enable :webauthn_autofill
  end
end

ユーザーが再びログインページを開いて、メールアドレスのフィールドにフォーカスを置くと、以下のように自分のパスキーが表示されるはずです。理由は、保存済みのパスキーにはユーザー識別用のメールアドレスも含まれているためです。

Autofill UI on email field showing a dropdown with passkeyss

通常このUIには影が付くのですが、私のMacの画面キャプチャによって取り除かれています。

ユーザーがパスキーを選択して検証すると、メールアドレスを入力する必要なしに自動ログインします。実際には、パスキーを選択する必要すらありません。メールアドレスのフィールドにフォーカスしたときにすぐ指紋認証を行えば十分です。

Rodauthは、パスワードレスログインに加えて、webauthn_verify_account機能によるパスワードレス登録もサポートしています。
ユーザーはアカウント作成フォームではメールアドレスを入力するだけで済みます。そして配信された検証メール内のリンクを開くときには、アカウントを検証するためにパスキーの登録を要求されます。

🔗 複数のcredentialを扱う

Rodauthでは、1つのアカウントに複数のパスキーを登録できます。ユーザーは、WebAuthnの設定ページを再度開いて、別のcredential(資格情報)を作成するだけで済みます。この機能は、バックアップ用に複数のデバイスを登録したい人々にとって便利です。

credentialは、デフォルトでは最後に使ったときのタイムスタンプでのみ区別されます。WebAuthnリモートページでデフォルトで表示されるのが、このタイムスタンプです。

Rodauth WebAuthn remove page

ユーザーが自分のcredentialにニックネームを付けて選択できるようにする機能を追加して、ユーザーがcredentialを手軽に区別できるようにしましょう。

まず、account_webauthn_keysテーブルに新しいnicknameカラムを追加します。

$ rails generate migration add_nickname_to_account_webauthn_keys nickname:string
$ rails db:migrate
class AddNicknameToAccountWebauthnKeys < ActiveRecord::Migration
  def change
    add_column :account_webauthn_keys, :nickname, :string
  end
end

次に、WebAuthn機能で使うビューテンプレートをインポートし、設定フォームにnicknameフィールドを追加します。

$ rails generate rodauth:views webauthn
# create  app/views/rodauth/webauthn_auth.html.erb
# create  app/views/rodauth/webauthn_setup.html.erb
# create  app/views/rodauth/webauthn_remove.html.erb
<!-- app/views/rodauth/webauthn_setup.html.erb -->
<!-- ... -->
  <div class="form-group mb-3">
    <%= form.label :nickname, "Nickname", class: "form-label" %>
    <%= form.text_field :nickname, value: params[:nickname], class: "form-control #{"is-invalid" if rodauth.field_error("nickname")}", aria: ({ invalid: true, describedby: "nickname_error_message" } if rodauth.field_error("nickname")) %>
    <%= content_tag(:span, rodauth.field_error("nickname"), class: "invalid-feedback", id: "nickname_error_message") if rodauth.field_error("nickname") %>
  </div>
<!-- ... -->

Rodauth passkey nickname field

今度は、WebAuthnの設定リクエストにフックをかけて、nicknameパラメータが入力されていることをバリデーションし、新しい認証情報を永続化します。

# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    before_webauthn_setup do
      throw_error_status(422, "nickname", "must be set") if param("nickname").empty?
    end
    webauthn_key_insert_hash do |credential|
      super(credential).merge(nickname: param("nickname"))
    end
  end
end

最後に、削除フォームを変更して、最後に使われたタイムスタンプの代わりにニックネームを表示するようにしましょう(ここではrodauth-modelで定義されるAccount#webauthn_keys関連付けを使っています)。

janko/rodauth-model - GitHub

<!-- app/views/rodauth/webauthn_remove.html.erb -->
<!-- ... -->
  <fieldset class="form-group mb-3">
    <% current_account.webauthn_keys.each do |webauthn_key| %>
      <div class="form-check">
        <%= form.radio_button rodauth.webauthn_remove_param, webauthn_key.webauthn_id, id: "webauthn-remove-#{webauthn_key.webauthn_id}", class: "form-check-input #{"is-invalid" if rodauth.field_error(rodauth.webauthn_remove_param)}", aria: ({ invalid: true, describedby: "webauthn_remove_error_message" } if rodauth.field_error(rodauth.webauthn_remove_param)) %>
        <%= form.label "webauthn-remove-#{webauthn_key.webauthn_id}", webauthn_key.nickname, class: "form-check-label" %>
        <%= content_tag(:span, rodauth.field_error(rodauth.webauthn_remove_param), class: "invalid-feedback", id: "webauthn_remove_error_message") if rodauth.field_error(rodauth.webauthn_remove_param) && webauthn_key == current_account.webauthn_keys.last %>
      </div>
    <% end %>
  </fieldset>
<!-- ... -->

Rodauth passkey remove nicknames

🔗 JavaScriptについて

Rodauthと一緒に提供されるパスキー登録および認証用のJavaScriptコードは、初めて使うときには便利ですが、いずれカスタマイズしたくなるでしょう。オリジナルのWeb Authentication APIはユーザーフレンドリーではありませんが、GitHubが提供しているwebauthn-jsonパッケージを使えば非常にシンプルに利用できるようになります。

github/webauthn-json - GitHub

以下は、StimulusJSを用いて実装した、実際に動く最もシンプルなコードです。

// app/javascript/controllers/webauthn_controller.js
import { Controller } from "@hotwired/stimulus"
import * as WebAuthnJSON from "@github/webauthn-json"

export default class extends Controller {
  static targets = ["result"]
  static values = { data: Object }

  connect() {
    if (!WebAuthnJSON.supported()) alert("WebAuthn is not supported")
  }

  async setup() {
    const result = await WebAuthnJSON.create({ publicKey: this.dataValue })

    this.resultTarget.value = JSON.stringify(result)
    this.element.requestSubmit()
  }

  async auth() {
    const result = await WebAuthnJSON.get({ publicKey: this.dataValue })

    this.resultTarget.value = JSON.stringify(result)
    this.element.requestSubmit()
  }
}
<!-- app/views/rodauth/webauthn_setup.html.erb -->
<% cred = rodauth.new_webauthn_credential %>

<%= form_with url: request.path, method: :post, data: { controller: "webauthn", webauthn_data_value: cred.as_json.to_json } do |form| %>
  <%= form.hidden_field rodauth.webauthn_setup_param, data: { webauthn_target: "result" } %>
  <%= form.hidden_field rodauth.webauthn_setup_challenge_param, value: cred.challenge %>
  <%= form.hidden_field rodauth.webauthn_setup_challenge_hmac_param, value: rodauth.compute_hmac(cred.challenge) %>

  <% if rodauth.two_factor_modifications_require_password? %>
    <div class="mb-3">
      <%= form.label "password", rodauth.password_label, class: "form-label" %>
      <%= form.password_field rodauth.password_param, autocomplete: rodauth.password_field_autocomplete_value, required: true, class: "form-control #{"is-invalid" if rodauth.field_error(rodauth.password_param)}" %>
      <%= content_tag(:span, rodauth.field_error(rodauth.password_param), class: "invalid-feedback") if rodauth.field_error(rodauth.password_param) %>
    </div>
  <% end %>

  <%= form.submit rodauth.webauthn_setup_button, class: "btn btn-primary", data: { action: "webauthn#setup:prevent" } %>
<% end %>
<!-- app/views/rodauth/webauthn_auth.html.erb -->
<% cred = rodauth.webauthn_credential_options_for_get %>

<%= form_with url: rodauth.webauthn_auth_form_path, method: :post, data: { controller: "webauthn", webauthn_data_value: cred.as_json.to_json } do |form| %>
  <%= form.hidden_field rodauth.webauthn_auth_param, data: { webauthn_target: "result" } %>
  <%= form.hidden_field rodauth.webauthn_auth_challenge_param, value: cred.challenge %>
  <%= form.hidden_field rodauth.webauthn_auth_challenge_hmac_param, value: rodauth.compute_hmac(cred.challenge) %>

  <%= form.hidden_field rodauth.login_param, value: params[rodauth.login_param] if rodauth.valid_login_entered? %>

  <%= form.submit rodauth.webauthn_auth_button, class: "btn btn-primary", data: { action: "webauthn#auth:prevent" } %>
<% end %>

フローは次のように進みます。
最初に、サーバーはWeb Authentication APIのパラメータを生成し、ユーザーが送信ボタンをクリックすると、それらのパラメータを用いてクライアントデバイス上でパスキーを登録/認証するためのJavaScript呼び出しが行われます。
ブラウザ側のフローが完了したら、JavaScriptのレスポンスがフォーム付きで送信され、サーバーで検証されて結果を処理します(データベースにパスキー情報を保存する、ユーザーをログインさせるなど)。

🔗 終わりに

パスキーがメジャーになるには、ブラウザやOSでもっと広くサポートされる必要がありますが、私は非常に有望視しています。YubiKeyのようにハードウェアを別途購入する方式ではなく、既存のデバイスを利用できる点が気に入っています。

パスキーは自動的に同期されるので安心感もありますし、どのWebサイトで、どのAppleデバイスでパスキーを作成したかを覚えておく必要がないという点も便利です。

Rodauthが、パスキーに対してこのような高度なサポートをゼロコンフィグで提供しているという事実は、Rodauthが認証のトレンドをキャッチアップし続けていることを示しています。しかも既存の認証方法と自由に組み合わせ可能なので、1次要素としても2次要素としても機能する柔軟な設計であることもわかります。Rodauthにきわめて豊富な設定方法が用意されていることからも、全体のフローを極めて柔軟に設定可能であることは明らかです。

参考資料

関連記事

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

Rails: 認証gem 'rodauth-rails' README(翻訳)


CONTACT

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