🦔
Railsで理解するパスキー
(この記事は READYFOR Advent Calendar 2023 の6日目の記事です。)
こんにちは。バックエンドエンジニアの安本です。
社内LTで2023年1月に発表した内容ですが、まだ鮮度ありそうなのでパブリックにしました。
徐々に様々なサービスでパスキー対応が進んでいます。
普段利用するサービスがパスキー対応を始めているので、なんとなくどういったものかはイメージできているかもしれませんが、エンジニアとしてこの裏で何が起きているかを知っておく価値はあるかと思います。そこで、Railsアプリをサンプルに処理の流れを追っていきたいと思います。
Web Authentication API を理解
navigator.credentials.create(options)
- ②で新しいクレデンシャルを作るために
navigator.credentials.create(options)
を呼び出します。この呼び出しにより、ブラウザーは認証器と対話し、指紋センサーまたは画面ロックを使用してユーザーのアイデンティティを検証します。
navigator.credentials.get(options)
- ②で
navigator.credentials.get(options)
を呼び出して、指紋センサーまたは画面ロックを使用してユーザーのアイデンティティを検証します。
Railsサンプルコードから処理フローを理解
ライブラリ
ER図
新規登録
ルーティング
# bundle exec rails routes
Prefix Verb URI Pattern Controller#Action
callback_registration POST /registration/callback(.:format) registrations#callback
new_registration GET /registration/new(.:format) registrations#new
registration POST /registration(.:format) registrations#create
シーケンス
- app/models/user.rb
after_initialize do
# WebAuthn 仕様の推奨事項に従う WebAuthn ユーザー ハンドルを生成
self.webauthn_id ||= WebAuthn.generate_user_id
end
- app/controllers/registrations_controller.rb
def create
user = User.new(username: params[:registration][:username])
# navigator.credentials.create({ "publicKey": publicKeyCredentialCreationOptions })
# を呼び出すためにクライアント側コードで使用される必要な
# PublicKeyCredentialCreationOptions を構築するヘルパーメソッド。
create_options = WebAuthn::Credential.options_for_create(
user: {
name: params[:registration][:username],
id: user.webauthn_id
},
authenticator_selection: { user_verification: "required" }
)
if user.valid?
# セッションにchallengeを保持
session[:current_registration] = { challenge: create_options.challenge, user_attributes: user.attributes }
respond_to do |format|
format.json { render json: create_options }
end
irb(#<RegistrationsController:0x0000ffff441b7490>):001:0> create_options
{
"challenge": "ay9iiH9-VAhFJMLZjicLUVga7VtV56kJsY52wak-YD0",
"timeout": 120000,
"rp": {
"name": "WebAuthn Rails Demo App"
},
"user": {
"name": "takaheraw",
"id": "90nPG3htizV3g7pOrQMvO6bxX3ipQfWswvmqwRGprWGrq1da1oH6ht-itI77Vz-In2Ts0SAot0ALligmb9ayNQ",
"displayName": "takaheraw"
},
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -37
},
{
"type": "public-key",
"alg": -257
}
],
"authenticatorSelection": {
"userVerification": "required"
}
}
- app/javascript/credential.js
function create(callbackUrl, credentialOptions) {
// ユーザーのアイデンティティを端末上で検証できたら、受け取ったクレデンシャルオブジェクトをサーバーに送信して認証器を登録
WebAuthnJSON.create({ "publicKey": credentialOptions }).then(function(credential) {
callback(callbackUrl, credential);
}).catch(function(error) {
showMessage(error);
});
console.log("Creating new public key credential...");
}
- app/controllers/registrations_controller.rb
def callback
webauthn_credential = WebAuthn::Credential.from_create(params)
user = User.create!(session["current_registration"]["user_attributes"])
begin
# 作成された WebAuthn クレデンシャルが有効であることを確認
webauthn_credential.verify(session["current_registration"]["challenge"], user_verification: true)
credential = user.credentials.build(
external_id: Base64.strict_encode64(webauthn_credential.raw_id),
nickname: params[:credential_nickname],
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count
)
if credential.save
sign_in(user)
render json: { status: "ok" }, status: :ok
else
irb(#<RegistrationsController:0x0000aaaaeef7f820>):001:0> params
{
"type": "public-key",
"id": "anlI9XHadgDeOXHjfghJvV21UVjoskP-k7rkhrXX87E",
"rawId": "anlI9XHadgDeOXHjfghJvV21UVjoskP-k7rkhrXX87E",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiT2tEbU5rVGZDem9WVGtBY1RLbVppeUVXQTh6NWM3cWdLM1pRTndxdlNzdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAIGp5SPVx2nYA3jlx434ISb1dtVFY6LJD_pO65Ia11_OxpQECAyYgASFYIB-SqKnN10ZPHZIUsKwTmqidlFSmSyHVOkjVR5OUdsBbIlggpwHU3vQgVe_n9Ai0DBw4kMcw1eiAUHjfUojspQtS1Bs"
},
"clientExtensionResults": {
},
"credential_nickname": "nickname",
"controller": "registrations",
"action": "callback",
"registration": {
"type": "public-key",
"id": "anlI9XHadgDeOXHjfghJvV21UVjoskP-k7rkhrXX87E",
"rawId": "anlI9XHadgDeOXHjfghJvV21UVjoskP-k7rkhrXX87E",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiT2tEbU5rVGZDem9WVGtBY1RLbVppeUVXQTh6NWM3cWdLM1pRTndxdlNzdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAIGp5SPVx2nYA3jlx434ISb1dtVFY6LJD_pO65Ia11_OxpQECAyYgASFYIB-SqKnN10ZPHZIUsKwTmqidlFSmSyHVOkjVR5OUdsBbIlggpwHU3vQgVe_n9Ai0DBw4kMcw1eiAUHjfUojspQtS1Bs"
},
"clientExtensionResults": {
}
}
}
irb(#<RegistrationsController:0x0000aaaaeef7f820>):002:0> webauthn_credential
=> {"type"=>"public-key", "id"=>"anlI9XHadgDeOXHjfghJvV21UVjoskP-k7rkhrXX87E", "raw_id"=>"jyH\xF5q\xDAv\x00\xDE9q\xE3~\bI\xBD]\xB5QX\xE8\xB2C\xFE\x93\xBA\xE4\x86\xB5\xD7\xF3\xB1", "client_extension_outputs"=>{}, "response"=>{"client_data_json"=>"{\"type\":\"webauthn.create\",\"challenge\":\"OkDmNkTfCzoVTkAcTKmZiyEWA8z5c7qgK3ZQNwqvSsw\",\"origin\":\"http://localhost:3000\",\"crossOrigin\":false}", "attestation_object_bytes"=>"\xA3cfmtdnonegattStmt\xA0hauthDataX\xA4I\x96\r\xE5\x88\x0E\x8Cht4\x17\x0Fdv`[\x8F\xE4\xAE\xB9\xA2\x862\xC7\x99\\\xF3\xBA\x83\x1D\x97cE\x00\x00\x00\x00\xAD\xCE\x00\x025\xBC\xC6\nd\x8B\v%\xF1\xF0U\x03\x00 jyH\xF5q\xDAv\x00\xDE9q\xE3~\bI\xBD]\xB5QX\xE8\xB2C\xFE\x93\xBA\xE4\x86\xB5\xD7\xF3\xB1\xA5\x01\x02\x03& \x01!X \x1F\x92\xA8\xA9\xCD\xD7FO\x1D\x92\x14\xB0\xAC\x13\x9A\xA8\x9D\x94T\xA6K!\xD5:H\xD5G\x93\x94v\xC0[\"X \xA7\x01\xD4\xDE\xF4 U\xEF\xE7\xF4\b\xB4\f\x1C8\x90\xC70\xD5\xE8\x80Px\xDFR\x88\xEC\xA5\vR\xD4\e"}}
ログイン
ルーティング
# bundle exec rails routes
Prefix Verb URI Pattern Controller#Action
callback_session POST /session/callback(.:format) sessions#callback
new_session GET /session/new(.:format) sessions#new
session DELETE /session(.:format) sessions#destroy
POST /session(.:format) sessions#create
シーケンス
コード
- app/controllers/sessions_controller.rb
def create
user = User.find_by(username: session_params[:username])
if user
# navigator.credentials.get({ "publicKey": publicKeyCredentialRequestOptions })
# を呼び出すためにクライアント側コードで使用される必要な
# PublicKeyCredentialRequestOptions を作成するヘルパーメソッド。
get_options = WebAuthn::Credential.options_for_get(
allow: user.credentials.pluck(:external_id),
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
irb(#<SessionsController:0x0000ffff5001ebe0>):001:0> get_options
{
"challenge": "j4DyB8aOfL6_x6gKTXDYSHKof8gOw-OyJ7OGFfv4PNs",
"timeout": 120000,
"allowCredentials": [
{
"type": "public-key",
"id": "l9uhPCtDFyAab1GNZ58Yt+Sb7zcv1+UNI6jKHbYA7Vo="
}
],
"userVerification": "required"
}
- app/javascript/credential.js
function get(credentialOptions) {
// ユーザーのアイデンティティを端末上で検証できたら、受け取ったクレデンシャルオブジェクトをサーバーに送信してクレデンシャルを検証し、ユーザーを認証する
WebAuthnJSON.get({ "publicKey": credentialOptions }).then(function(credential) {
callback("/session/callback", credential);
}).catch(function(error) {
showMessage(error);
});
console.log("Getting public key credential...");
}
- app/controllers/sessions_controller.rb
def callback
webauthn_credential = WebAuthn::Credential.from_get(params)
user = User.find_by(username: session["current_authentication"]["username"])
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 クレデンシャルが有効であることを確認
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
irb(#<SessionsController:0x0000ffff54c76060>):001:0> params
{
"type": "public-key",
"id": "l9uhPCtDFyAab1GNZ58Yt-Sb7zcv1-UNI6jKHbYA7Vo",
"rawId": "l9uhPCtDFyAab1GNZ58Yt-Sb7zcv1-UNI6jKHbYA7Vo",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiajREeUI4YU9mTDZfeDZnS1RYRFlTSEtvZjhnT3ctT3lKN09HRmZ2NFBOcyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA",
"signature": "MEQCIEr1LrMlgswsFYUAWy5h3L4SJRs1E0q3Jq8EJbpJnN4tAiAQRuqzXUargtBEizAYbFwqsvr4o7QKPIbsF21r2Cwccw",
"userHandle": "Nt-7nh_lrRhdAy-MJ9NQ1i7TxMcYqZjjALpMdEu5c00RQ4pt6vMBBes0yeeYZnvLtL9TxHdgu96NhMtE2_udyA"
},
"clientExtensionResults": {
},
"controller": "sessions",
"action": "callback",
"session": {
"type": "public-key",
"id": "l9uhPCtDFyAab1GNZ58Yt-Sb7zcv1-UNI6jKHbYA7Vo",
"rawId": "l9uhPCtDFyAab1GNZ58Yt-Sb7zcv1-UNI6jKHbYA7Vo",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiajREeUI4YU9mTDZfeDZnS1RYRFlTSEtvZjhnT3ctT3lKN09HRmZ2NFBOcyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA",
"signature": "MEQCIEr1LrMlgswsFYUAWy5h3L4SJRs1E0q3Jq8EJbpJnN4tAiAQRuqzXUargtBEizAYbFwqsvr4o7QKPIbsF21r2Cwccw",
"userHandle": "Nt-7nh_lrRhdAy-MJ9NQ1i7TxMcYqZjjALpMdEu5c00RQ4pt6vMBBes0yeeYZnvLtL9TxHdgu96NhMtE2_udyA"
},
"clientExtensionResults": {
}
}
}
irb(#<SessionsController:0x0000ffff54c76060>):002:0> credential
=> {"id"=>1, "external_id"=>"l9uhPCtDFyAab1GNZ58Yt+Sb7zcv1+UNI6jKHbYA7Vo=", "public_key"=>"pQECAyYgASFYIBHsc7k-t5dJihsRDayOhHFzdOpeD5y-Y-fdWNBPgiT0IlggpNsUDUqe5D7tf_yO47bwGQcKmsRSdAOcDbrOZO3XfCo", "user_id"=>5, "created_at"=>"2023-01-03T00:29:44.000Z", "updated_at"=>"2023-01-03T00:29:44.000Z", "nickname"=>"nickname", "sign_count"=>0}
まとめ
いざパスキーのお仕事が回ってきたときに、ほんの少しでも参考になれば幸いです。
パスキーについては、こちらの記事が参考になりました。
Discussion