皆さんこんにちは、とらのあなラボのY.Fです。
先日、弊社エンジニアが開発で関わっているCreatiaで、以下のお知らせが投稿されました。
【新機能のご案内】#クリエイティア にて、『パスワードレスログイン』機能をリリースいたしました。
— クリエイティア[Creatia]@ファンクラブ開設費無料! (@creatia_cc) 2023年6月8日
パスワードの代わりに指紋や顔認証、PINコードを使って、スムーズかつ安全にクリエイティアにログインできるようになりました!
▶詳細は下記記事をご参照くださいhttps://t.co/FzsVIAl7Sp
弊社のサービスは、とらのあな通販やサークルポータル除いて、ほぼRuby on Railsを利用しています。
今回の記事では、Ruby on Rails + WebAuthnについて、調べたことなどをまとめてみたいと思います。
なお、今回提示しているコードやライブラリなどはあくまでサンプルですので、安全性を保証するものではありません。パラメータや、実装等、各自確かめていただくのが良いかと思います。
FIDO認証について
FIDO認証は従来のパスワード認証に代わる認証方法です。
上記ページの通り、公開鍵暗号の仕組みを利用した認証方式になります。
FIDO認証の仕様策定などをしている団体として、FIDOアライアンスというものもあります。
WebAuthnについて
そして、今回の表題にもなるWebAuthnについては、MDNに詳しい説明が書かれています。
ポイントとしては、
- SMSやパスワードを利用しない安全な認証
- 生体認証や、端末自体のPINコードでログインできるようになるので、利便性の向上も見込めると思います
- 公開鍵暗号の仕組みが利用される
- 各種攻撃方法への耐性
- リスト型攻撃
- フィッシング
パスワードに代わる認証方式だったり、公開鍵暗号が使われていたりなどからわかるように、FIDO認証の仕組みをWebで使えるようにしたものだという事がわかります。
実際、FIDOのWebAuthnのページにもそのようなことが書かれています。
RFCも存在します。
WebAuthn-Level3を実装したものがいわゆるPasskeyと呼ばれているものになります。
WebAuthnの実装について
実装の際は、上記MDNや以下Googleが出しているチュートリアルも参考になるかと思います。
基本的には以下を目指していく形になるかと思います。
- 認証情報の新規追加時(ユーザーの新規登録時)
- navigator.credentials.create()を利用する
- 上記関数から出力されるattestationObjectのパースや検証を行い、成功したら公開鍵情報をDBに登録する
- 認証実行(ログイン)
- navigator.credentials.get()を利用する
- 上記関数から出力されるauthenticatorDataのパースや検証を行い、DBに保存されている公開鍵を使って認証を行う
navigator.credentials.create()の場合も、navigator.credentials.get()の場合も正しいリクエストかどうか検証のために、Challengeと呼ばれるトークンをセッションなどに入れて検証します。
パラメータの詳しい情報や、パラメータに対してどのような検証、パースが必要かに関しては、以下先駆者の皆様のブログが参考になるかと思います。
RubyでのWebAuthn実装について
WebAuthn関連の情報は以下リポジトリにまとめられています。
特に、ライブラリなどに関しては、FIDO CERTIFIED™
や、 FIDO CONFORMANT
がつけられているものがあります。いずれもREADMEの最後の方に説明が書かれています。
FIDO CERTIFIED™
- FIDOアライアンスの試験に合格し、認定をもらっている
FIDO CONFORMANT
- FIDOのテストツールで合格していることが報告されているもの
Rubyのライブラリはというと、 FIDO CONFORMANT
として、以下のライブラリがあります。
利用実績等は以下のようなものがあるようです。
- USの公共サービスへのアクセスを簡単にするサービス
- GitLab
- mastodon
今回は、このライブラリを使った実装を紹介してみようと思います。
余談ですが、フロント側で使いづらい値をJSONで使いやすくするために以下のようなライブラリもあります。単に取り回しやすくしてくれるだけなので、機能的な追加があるといったようなものではありません。
認証情報の新規追加(navigator.credentials.create())
基本的にはライブラリから提供されているサンプルを参考にすれば特に困ることはないかと思います。 github.com
まずは、任意のAPIでChallengeを含む必要なデータを生成します。
ユーザーに対して公開鍵情報を追加するため、ログイン中でないと使えない機能になるかと思いますので、今回のサンプルではcurrent_userから各種情報を取得しています。
def create user = current_user create_options = WebAuthn::Credential.options_for_create( user: { id: user.webauthn_key, name: user.name }, exclude: user.webauthn_credentials.pluck(:external_key), authenticator_selection: { user_verification: 'required', require_resident_key: true, } ) if user.valid? session[:current_registration] = { challenge: create_options.challenge, user_attributes: user.attributes } respond_to do |format| format.json { render json: create_options } end else respond_to do |format| format.json { render json: { errors: user.errors.full_messages }, status: :unprocessable_entity } end end end
サンプルほぼそのままで動くかと思います。基本的には navigator.credentials.create
に渡せるようにパラメータ調整する形になります。
userやexcludeの設定内容は上記先駆者様の記事を見ていただければわかると思いますが、ここでポイントとなるのは、require_resident_key
となるかと思います。
- Resident Keyについて
- 現在はDiscoverable Credentialなどと呼ばれており、昔の名前がそのまま残っている
- これを設定することでログイン時にユーザIDの入力とかを不要にできる
- 実態としてはuserの箇所のidと同じもの
次に、上記で生成されたオブジェクトを使って navigator.credentials.create
を呼び出します。今回は普通のJavaScriptを利用します。
const response = await fetch("/api/webauthn/register", { method: "POST", headers: { "Content-Type": "application/json", } }); const responseJson = await response.json(); const credential = await webauthnJSON.create({publicKey: responseJson});
webauthnJSON.createはwebauthn-jsonが提供する関数です。実際にはnavigator.credentials.createと同じように使えます。
最後に、 上記で作成したcredentialを受け取るサーバー側の実装を追加します。
def callback webauthn_credential = WebAuthn::Credential.from_create(params) begin webauthn_credential.verify(session["current_registration"]["challenge"], user_verification: true) credential = current_user.credentials.find_or_initialize_by( external_id: Base64.strict_encode64(webauthn_credential.raw_id) ) if credential.update( nickname: params[:credential_nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count ) render json: { status: "ok" }, status: :ok else render json: "Couldn't add your Security Key", status: :unprocessable_entity end rescue WebAuthn::Error => e render json: "Verification failed: #{e.message}", status: :unprocessable_entity ensure session.delete("current_registration") end end
サンプルほぼそのままです。実際に利用する際は、各自のデータベースの構造に従って読み替え等が必要になるかと思います。
これで、データベースに公開鍵を登録する処理ができました。
WebAuthnでのログイン追加(navigator.credentials.get())
ログインに関しては、先に登録した公開鍵と、navigator.credentials.getで得られるトークンを検証する形になります。
これも、サンプルがあります。 github.com
流れ自体も、登録処理とほぼ同様になります。まずは、createと同様に必要なオブジェクト及びChallengeを生成します。
def create get_options = WebAuthn::Credential.options_for_get( user_verification: "required" ) session[:current_authentication] = { challenge: get_options.challenge, username: session_params[:username] } respond_to do |format| format.json { render json: get_options } end end
今回はDiscoverable Credentialを使いたいため、この時点ではユーザーの特定はできません。
ここで作られたオブジェクトを利用してnavigator.credentials.getを呼び出します。
const response = await fetch("<%= webauthn_sessions_path %>", { method: "POST", headers: { "Accept": "application/json" } }); const responseJson = await response.json(); const credential = await webauthnJSON.get({"publicKey": responseJson});
こちらも呼び出すメソッドが違うだけでほぼcreateと同様です。
最後に、これらを使って認証を行う処理を追加します。
def callback webauthn_credential = WebAuthn::Credential.from_get(params) user = User.find_by(webauthn_key: params["response"]["userHandle"]) raise "user #{session["current_authentication"]["username"]} never initiated sign up" unless user credential = user.credentials.find_by(external_id: Base64.strict_encode64(webauthn_credential.raw_id)) begin webauthn_credential.verify( session["current_authentication"]["challenge"], public_key: credential.public_key, sign_count: credential.sign_count, user_verification: true ) credential.update!(sign_count: webauthn_credential.sign_count) sign_in(user) render json: { status: "ok" }, status: :ok rescue WebAuthn::Error => e render json: "Verification failed: #{e.message}", status: :unprocessable_entity ensure session.delete("current_authentication") end end
こちらも、ほぼサンプルのままですが、ユーザーの取得だけ異なります。今回はDiscoverable Credentialを使うようにしたため、userHandleという形で、create時のuser.idに設定したIDが送られてきます。 こちらを利用することで、ユーザーの特定が可能になります。
実装した際に困ったこと
実際に実装したり、テストした中で困ったことについて書いてみたいと思います。
Resident Keyの扱いについて
そもそも名前がWebAuthnのLevelによって切り替わってて混乱しました。
また、ユーザーの情報もどこから取ればいいのか一見分からなくて困りました。RFC等を見たところ、userHandle = user.idということがわかりました。
ちなみに、user.idにも色々制限があり、emailなど個人情報に近いものはNGとなっているので、一意かつランダムな値が良いと思います。(RubyだとSecureRandomなどで生成される値)
iOSでの挙動について
以下記事が参考になるかと思いますが、iOSでは必ずユーザー操作を挟む必要があります。極端に言えば、ログイン画面に入った瞬間トークンを取得し、navigator.credentials.getなどを呼び出しても動くはずですが、iOSでは動きません。
実際の事例と実装例が、YubiKeyを作っているYubico社様のサイトにあります。
今回の例で行くと、
ログインボタンを押す→トークン取得→navigator.credentials.get()→トークン検証→ログイン
だと失敗する場合があり、
ログインボタンを押す→トークン取得→ダイアログなどに確認ボタンを出して押す→navigator.credentials.get()→トークン検証→ログイン
などとして、ユーザー操作を挟むことで回避可能です。
まとめ
今回の記事では、Ruby on RailsでWebAuthnを導入するにはどうすれば良さそうかについて書いてみました。
WebAuthnの仕様自体もこれからまだ拡張されていくと思いますので、よりセキュアなログイン方法を提供するために、定期的にウォッチしていきたいなと思います。
採用情報
虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp