🦔

Railsで理解するパスキー

2023/12/06に公開

(この記事は READYFOR Advent Calendar 2023 の6日目の記事です。)

こんにちは。バックエンドエンジニアの安本です。

社内LTで2023年1月に発表した内容ですが、まだ鮮度ありそうなのでパブリックにしました。
徐々に様々なサービスでパスキー対応が進んでいます。

https://passkeys.directory

普段利用するサービスがパスキー対応を始めているので、なんとなくどういったものかはイメージできているかもしれませんが、エンジニアとしてこの裏で何が起きているかを知っておく価値はあるかと思います。そこで、Railsアプリをサンプルに処理の流れを追っていきたいと思います。

Web Authentication API を理解

  • ②で新しいクレデンシャルを作るためにnavigator.credentials.create(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}

まとめ

いざパスキーのお仕事が回ってきたときに、ほんの少しでも参考になれば幸いです。
パスキーについては、こちらの記事が参考になりました。
https://blog.agektmr.com/2022/12/passkey.html
https://techbookfest.org/product/eiaE1tk3bEcu7iPfZx9ysU

Discussion