この記事は、LITALICO Engneers Advent Calender 2023 シリーズ1の7日目の記事です。前日は、@k_orita の「一年目の新人こそ、チーム1厳しいレビュワーになろう!」でした。
はじめに
LITALICO プロダクトエンジニアリング(PE)部の片桐英人(かたぎり えいと)です。基盤グループに所属しています。2022年11月に入社しました。岐阜県岐阜市からリモートワークしています。
ここ最近、伊藤園から発売されていた「ガッサータ」を愛飲しています。コーヒー味の炭酸飲料です。既に生産中止になっているようで、Amazon などで割りと安く手に入ることは良いのですが、持続性がないので、エスプレッソを炭酸水で割るエスプレッソソーダに試そうかと悩んでいます。
最近、Nintendo Online 1、Yahoo! Japan 2、GitHub 3 などでパスキー認証に対応するようになり、パスキー認証が普及してきたように思われます。
PE部で開発・運用しているLITALICO 仕事ナビ、LITALICO 発達ナビ、LITALICO キャリアは、Ruby On Rails(Rails)で作成されています。
パスキー認証は、従来のパスワード認証よりも簡単で安全なため 4 5、近い将来、これらのアプリケーションでも、パスキー認証を利用できるようにしたので、簡単な Rails のサンプルアプリケーション上にパスキー認証の実装を試してみました。
この記事の内容は、PE部で、隔週で開催している勉強会(通称、PE Tech Evening)にて発表したものです。
サンプルアプリケーションについて
サンプルアプリケーションは、GitHub eito-katagiri-LITALICO/webauthn-ruby-sample の advent_calendar_2023 ブランチで公開しています。
動作されるには、Ruby 3.2 と Node.js 20 が必要です。以下のようにして動作させることができます。
$ git clone https://github.com/eito-katagiri-LITALICO/webauthn-ruby-sample.git
$ cd webauthn-ruby-sample
$ git checkout advent_calendar_2023
$ bundle install
$ bin/setup
$ bin/rails javascript:build css:build
$ bin/rails server
9b22d63 のコミットまでで、rails new
をして、devise を使ってパスワード認証をするようにしています。その後のコミットで、パスキー認証を実装しています。
WebAuthn(Web Authentication API)について
WebAuthn でのブラウザ(クライアント)とアプリケーション(Relying Party)間での認証に必要な資格情報を登録
や資格情報を使っての認証の流れを把握しておくと実装がわかりやすくなるので簡単に説明します。
Mozilla Developer Network(MDN)の「Web認証API」ページの説明がわかりやすいので、そのページの図を利用させてもらいます。
登録
資格情報の登録はブラウザ上の JavaScript アプリケーションがサーバーにして、登録に必要な情報を要求することから始まります。そして、以下の流れで登録を行います。
- サーバーは、チャレンジ(リクエスト時に生成される16バイト以上のランダムな文字列)やユーザー情報などを送信します。
- JavaScript アプリケーションは、受け取った情報を認証機に渡して、資格情報の作成を要求します。認証機は、機器に内蔵されている指紋や顔認証認証であったり、YubiKey といった外部の機器になります。
- 認証機は、生体認証などでユーザー認証を経て、資格情報を作成します。
- 認証機は、生成した資格情報などを JavaScript アプリケーションに送信します。
- JavaScript アプリケーションは、受け取った資格情報をサーバーに送信します。
- サーバーは、受け取った情報を検証します。改竄されていないと判断すると情報をデータベースなどに保存します。そして、JavaScript アプリケーションに検証結果を送信します。
認証
認証は、登録と同じようにブラウザ上の JavaScript アプリケーションがサーバーにして、認証に必要な情報を要求することから始まります。そして、以下の流れで認証を行います。
- サーバーは、チャレンジやユーザー情報などを送信します。
- JavaScript アプリケーションは、受け取った情報を認証機に渡して、その情報の中のチャレンジへの署名の作成を要求します。
- 認証機は、生体認証などでユーザー認証を経て、チャレンジに署名をします。
- 認証機は、署名などを JavaScript アプリケーションに送信します。
- JavaScript アプリケーションは、受け取った署名などの情報をサーバーに送信します。
- サーバーは、受け取った署名を検証します。検証結果を JavaScript アプリケーションに送信します。この時、署名が正しいと判断される場合には、Rails アプリケーションなどでは、ログイン処理を行います。
使用ライブラリ
Rails アプリケーションで、パスキー認証を実装するにあたり、以下のライブラリを使用しました。
webauthn-ruby
webauthn-ruby は、WebAuthn の通信で、サーバー(Relying Party)側の処理を行う RubyGem です。rubygems.org でのダウンロード数をみても、これを使っておけば、良いと思います。
デモのアプリケーション(webauthn-rails-demo-app)が提供されています。今回も、実装する際にかなり参考にしました。
webauthn-json
webauthn-json は、Web Authentication API(ウェブ認証 API) を簡単に扱うための npm です。サーバーとの通信で扱うJSONのエンコード・デコード処理をするための関数も提供します。
npmjs.org で検索すると同様の機能を提供する npm がいくつかあるようです。今回、上記のwebauthn-ruby のでもアプリケーションで、 webauthn-json が利用されていたので、使用しました。使用して、特に不満はありませんでした。他の npm は、試していません。
パスキー認証の実装
今回は、パスワード認証が実装されているアプリケーションにパスキー認証を追加するという前提で実装してみました。
これから説明するように最初にモデルを作成して、パスキーの登録、そして、パスキーでの認証を実装しました。
モデル
認証資格情報を格納するための Credential
というモデルを作成しました。そして、ユーザーのモデル(User
)が has_many :credentials
という関係を作成しました。サンプルアプリケーションでは、e8ff0b5 になります。
Credenial
モデルのためのテーブルは以下のように作成しました。
create_table :credentials do |t|
t.references :user, null: false, foreign_key: true
t.string :external_id, null: false
t.string :public_key, null: false
t.string :nickname, null: false
t.bigint :sign_count, null: false, default: 0
t.timestamps
end
以下がテーブルの各項目の説明です。
-
external_id
は、認証機が作成する資格情報の ID です。 -
public_key
は、認証機が生成した非対称暗号の公開鍵です。認証時に署名の検証に使用します。 -
nickname
は、登録するパスキーを識別するための名前です。 -
sign_count
は、認証時に認証器から送信されてくる認証回数です。
登録
登録処理を行うための RegistrationsController
を作成しました。そこで、登録のためのページを作成する #new
、登録のためのチャレンジなどを作成する #create
、そして、資格情報を検証と保存を行う #callback
の3つのアクションを作成しました。サンプルアプリケーションでは、それぞれ、c707356、a7495bd、2a0cd9d になります。
登録の情報生成(サーバー)
登録のためのチャレンジなどの情報の生成を RegistrationsController#create
で、以下のように行なっています。
options = relying_party.options_for_registration(
user: {
name: current_user.email,
id: current_user.webauthn_id
},
attestation: "none",
authenticator_selection: {
authenticator_attachment: "platform",
resident_key: "discouraged",
user_verification: "required"
}
)
options_for_registration
の引数について、以下に少し補足します。
-
current_user.webauthn_id
は、WebAuthn.generate_user_id
で生成された文字を使用しています。WebAuthn.generate_user_id
は、64バイトのランダムな文字列(SecureRandom.random_bytes
)を生成しています。予測不能な値である必要があるため、current_user.id
ではなく、別途、生成しています。 -
attestation
は、iOS 上の Safari で生体認証を使用するには、現在のところ、none
を使用する必要があるようです。まだ、調査中です。 -
authenticator_selection
で、user_verification
はrequired
にして、資格情報生成時にデバイスでユーザー認証を行うようにしています。
資格情報の生成・送信(クライアント)
クライアントである JavaScript アプリケーションでのは、資格情報の生成を認証機に要求とサーバーへ送信処理は、a7495bd と 2a0cd9d で作成しました。
まず、サーバーへ情報の要求を以下のように行なっています。
const registration = await fetch('/registrations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({}),
});
const json = await registration.json();
getCsrfToken
は、ページ内の meta
タグから、Rails が発行する CSRF トークンを取得しています。
そして、認証機への資格情報生成の要求を以下のように行なっています。
const options = parseCreationOptionsFromJSON({ publicKey: json });
const response = await create(options);
create
で、認証機に資格情報の生成を要求しています。
最後に資格情報のサーバーへの送信は以下のように行なっています。
const params = new URLSearchParams({ nickname: nickname });
await fetch(`/registrations/callback?${params}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify(response),
});
資格情報の検証・登録(サーバー)
資格情報の検証を RegistrationsController#call
で、以下のように行なっています。
webauthn_credential = relying_party.verify_registration(
params,
session.dig("creation_registration", "challenge"),
user_verification: true
)
session.dig("creation_registration", "challenge")
は、RegistrationsController#create
で生成したチャレンジをセッションに保管しているので、それを取得して検証に使用しています。
そして、登録は以下のように行なっています。
current_user.credentials.create!(
external_id: Base64.strict_encode64(webauthn_credential.raw_id),
nickname: params[:nickname],
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count
)
認証
認証を行うための SessionsController
を作成しました。そこで、ユーザー名を入力するためのページを作成する #new
、認証のためのチャレンジなどを作成する #create
、そして、署名の検証と認証を行う #callback
の3つのアクションを作成しました。サンプルアプリケーションでは、それぞれ、aa2e8bb、49a7a33、e1ac0fa になります。
認証の情報生成(サーバー)
認証のためのチャレンジなどの情報の生成を SessionsController#create
で、以下のように行なっています。
get_options = relying_party.options_for_authentication(
allow: user.credentials.pluck(:external_id),
user_verification: "required"
)
allow
で、認証のために利用する資格情報を指定して、user_verification
は required
とすることで、ユーザー認証を要求しています。
署名の生成・送信(クライアント)
クライアントである JavaScript アプリケーションでの、署名の生成を認証機に要求とサーバーへ送信処理は、49a7a33 とe1ac0fa で作成しました。
まず、サーバーへ情報の要求を以下のように行なっています。
const session = await fetch('/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({ email }),
});
const json = await session.json();
そして、認証機へのチャレンジへの署名の要求を以下のように行なっています。
const options = parseRequestOptionsFromJSON({ publicKey: json });
const response = await get(options);
get
で、認証機にチャレンジへの署名を要求しています。
最後に資格情報のサーバーへの送信は以下のように行なっています。
await fetch(`/sessions/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify(response),
});
window.location.href = '/';
成功のレスポンスが返信されるとログインできていることになります。
認証情報(署名)の検証・登録(サーバー)
認証情報の検証を SessionsController#call
で、以下のように行なっています。
verified, credential = relying_party.verify_authentication(
params,
session.dig("current_authentication", "challenge"),
user_verification: true
) do |webauthn_credential|
user.credentials.find_by(external_id: Base64.strict_encode64(webauthn_credential.raw_id))
end
credential.update!(sign_count: verified.sign_count)
登録時と同様に SessionsController#create
で作成したチャレンジがセッションに保存されているので、取得して、検証に利用しています。ブロックは、検証が成功した際に呼び出されるので、必要な処理を記述します。ここでは、後ほど、sign_count
を更新するために保存している資格情報を取得しています。
デモ
今回作成したサンプルアプリケーションで、iPhone シミュレーターを使って、認証する様子です。
さいごに
サーバー側は、ほとんど、webauthn-ruby が提供するデモアプリケーションをほとんどコピーしており、実際、実装する際には、モデルやコントローラの名前はもう少し工夫する必要があると思います。テストを作成する必要があります。webauthn-ruby では、テストに利用できそうな WebAuthn::FakeAuthenticator
や WebAuthn::FakeClient
というクラスが提供されているので、これらを使用して、テストを作成してみたいと思います。
明日の8日目は、@taka-fujita の「(仮)git用語集」です。とても、気合が入っているようなので、お楽しみに!