Rais: Rodauthでパスキー認証を行う(翻訳)
パスキー(Passkeys)は、パスワードの現代的な代替手段であり、認証をユーザーのデバイスが行います。通常、パスキーでは何らかの形でユーザー認証(生体認証やPIN)が必要です。
パスキーはWebAuthn仕様に基づいており、公開鍵暗号方式を利用しています。Webサイトごとに鍵ペアが作成され、公開鍵はサーバーに送信されますが、秘密鍵はデバイス上で安全に保管されます。
このため、パスキーには以下のような特徴があります。
- パスワード方式よりも強力
- データ侵害に対して安全
- フィッシング攻撃に対して安全
WebAuthnのcredentials(資格情報)は、それを作成したデバイスに紐づけられます(YubiKeyなどの物理セキュリティキーを想像してみましょう)。この方式では、自分のデータを企業が持たないので、プライバシーが保護されますが、その代わり、デバイスを紛失するとアカウントにアクセスできなくなる可能性があります。
パスキーはクラウド上でバックアップされ、複数のデバイス間で同期可能なので、パスキーが紛失するリスクを軽減できます。
Rodauthは、webauthn-rubyという優秀なgem上に実装されている一流のパスキーサポートを提供します。
これにより、パスキーを多要素認証1の手段として使うことも、パスワードレスログインやパスワードレス登録に使うことも可能になります。ルーティング、ビュー、データベースストレージはもちろん、ゼロコンフィグで利用できるWeb認証APIと対話する完全なJavaScriptコードも提供されます。
本記事では、rodauth-railsを既に利用しているRailsアプリでこれらを設定する方法を紹介したいと思います。
なお、私の場合は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認証の設定リンクが表示されるはずです。
このページでは、WebAuthnのcredentials(資格情報)を登録するボタンが表示されます。このボタンは、必要なJavaScriptコードと連携済みなので、クリックすると新しいパスキーを作成するブラウザネイティブのダイアログが表示されます。私の場合、生体認証の検証(verification)が終われば、2次要素のセットアップが完了します。
次回このユーザーがログインすると、最初に1次要素で認証された後、2次要素としてパスキーで認証するオプションが提供されるようになります。
パスキーの登録時と同様に、必要なJavaScriptコードはパスキーを使った認証ページにも既に組み込まれています。したがって、送信ボタンをクリックすると、以前に作成したパスキーで認証するためのダイアログが表示されるはずです。生体認証の検証が終われば、2次要素の認証が完了します。
🔗 「パスワードレス」ログイン
ユーザーがWebサイトのパスキー作成を完了したら、多要素認証に加えて、パスワードレスログインにもパスキーを利用できるようになります。この機能はwebauthn_login
機能によって提供されます。
# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
configure do
enable :webauthn_login
end
end
これで、ユーザーが最初にメールアドレスを入力すると、パスワードとパスキーのどちらで認証するかを選択できるマルチフェーズログインが自動的に有効になります。ただし、ユーザーがアカウント作成時にパスワードを設定していなかった場合は、パスワードフィールドは表示されません。
パスキーが検証されると、ユーザーがログインします。
多要素認証を使っている場合、デフォルトで行われるのは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
ユーザーが再びログインページを開いて、メールアドレスのフィールドにフォーカスを置くと、以下のように自分のパスキーが表示されるはずです。理由は、保存済みのパスキーにはユーザー識別用のメールアドレスも含まれているためです。
通常このUIには影が付くのですが、私のMacの画面キャプチャによって取り除かれています。
ユーザーがパスキーを選択して検証すると、メールアドレスを入力する必要なしに自動ログインします。実際には、パスキーを選択する必要すらありません。メールアドレスのフィールドにフォーカスしたときにすぐ指紋認証を行えば十分です。
Rodauthは、パスワードレスログインに加えて、webauthn_verify_account
機能によるパスワードレス登録もサポートしています。
ユーザーはアカウント作成フォームではメールアドレスを入力するだけで済みます。そして配信された検証メール内のリンクを開くときには、アカウントを検証するためにパスキーの登録を要求されます。
🔗 複数のcredentialを扱う
Rodauthでは、1つのアカウントに複数のパスキーを登録できます。ユーザーは、WebAuthnの設定ページを再度開いて、別のcredential(資格情報)を作成するだけで済みます。この機能は、バックアップ用に複数のデバイスを登録したい人々にとって便利です。
credentialは、デフォルトでは最後に使ったときのタイムスタンプでのみ区別されます。WebAuthnリモートページでデフォルトで表示されるのが、このタイムスタンプです。
ユーザーが自分の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>
<!-- ... -->
今度は、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
関連付けを使っています)。
<!-- 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>
<!-- ... -->
🔗 JavaScriptについて
Rodauthと一緒に提供されるパスキー登録および認証用のJavaScriptコードは、初めて使うときには便利ですが、いずれカスタマイズしたくなるでしょう。オリジナルのWeb Authentication APIはユーザーフレンドリーではありませんが、GitHubが提供しているwebauthn-jsonパッケージを使えば非常にシンプルに利用できるようになります。
以下は、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にきわめて豊富な設定方法が用意されていることからも、全体のフローを極めて柔軟に設定可能であることは明らかです。
参考資料
- WebAuthn.io
- FIDO Allianceドキュメント
- Passkeys.dev
- Passkeys: What the Heck and Why? (CSS Tricks記事)
- WebAuthn vs Passkeys(Passwordless.ID記事)
概要
元サイトの許諾を得て翻訳・公開いたします。
本記事では以下の用語を使います。
本記事では、rodauth-railsがRailsアプリでセットアップ済みであることが前提になっています。
新規Railsアプリでゼロから試したい場合は、以下の公式デモアプリを使うとよいでしょう。このアプリは多要素認証のやニックネームのセットアップまで完了しています(コードは本記事とごくわずかに異なっているようです)。
なお、このデモアプリの画面でアカウントを作成すると、本来は確認用のメールが送信されますが、ローカルのdevelopmentではメールが送信されないので、Railsのログ↓から確認用URLを探してブラウザで実行することで確認完了できます。あるいはRailsコンソールで直接ユーザー登録してもよいでしょう。