虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

RailsプロダクトへのWebAuthn導入に向けての取り組み

皆さんこんにちは、とらのあなラボのY.Fです。

先日、弊社エンジニアが開発で関わっているCreatiaで、以下のお知らせが投稿されました。

弊社のサービスは、とらのあな通販やサークルポータル除いて、ほぼRuby on Railsを利用しています。

speakerdeck.com

今回の記事では、Ruby on Rails + WebAuthnについて、調べたことなどをまとめてみたいと思います。

なお、今回提示しているコードやライブラリなどはあくまでサンプルですので、安全性を保証するものではありません。パラメータや、実装等、各自確かめていただくのが良いかと思います。

FIDO認証について

FIDO認証は従来のパスワード認証に代わる認証方法です。

fidoalliance.org

上記ページの通り、公開鍵暗号の仕組みを利用した認証方式になります。

FIDO認証の仕様策定などをしている団体として、FIDOアライアンスというものもあります。

fidoalliance.org

WebAuthnについて

そして、今回の表題にもなるWebAuthnについては、MDNに詳しい説明が書かれています。

developer.mozilla.org

ポイントとしては、

  • SMSやパスワードを利用しない安全な認証
    • 生体認証や、端末自体のPINコードでログインできるようになるので、利便性の向上も見込めると思います
  • 公開鍵暗号の仕組みが利用される
  • 各種攻撃方法への耐性
    • リスト型攻撃
    • フィッシング

パスワードに代わる認証方式だったり、公開鍵暗号が使われていたりなどからわかるように、FIDO認証の仕組みをWebで使えるようにしたものだという事がわかります。

実際、FIDOのWebAuthnのページにもそのようなことが書かれています。

fidoalliance.org

RFCも存在します。

www.w3.org

www.w3.org

WebAuthn-Level3を実装したものがいわゆるPasskeyと呼ばれているものになります。

WebAuthnの実装について

実装の際は、上記MDNや以下Googleが出しているチュートリアルも参考になるかと思います。

developers.google.com

基本的には以下を目指していく形になるかと思います。

  • 認証情報の新規追加時(ユーザーの新規登録時)
    • navigator.credentials.create()を利用する
    • 上記関数から出力されるattestationObjectのパースや検証を行い、成功したら公開鍵情報をDBに登録する
  • 認証実行(ログイン)
    • navigator.credentials.get()を利用する
    • 上記関数から出力されるauthenticatorDataのパースや検証を行い、DBに保存されている公開鍵を使って認証を行う

navigator.credentials.create()の場合も、navigator.credentials.get()の場合も正しいリクエストかどうか検証のために、Challengeと呼ばれるトークンをセッションなどに入れて検証します。

パラメータの詳しい情報や、パラメータに対してどのような検証、パースが必要かに関しては、以下先駆者の皆様のブログが参考になるかと思います。

engineering.mercari.com

techblog.yahoo.co.jp

RubyでのWebAuthn実装について

WebAuthn関連の情報は以下リポジトリにまとめられています。

github.com

特に、ライブラリなどに関しては、FIDO CERTIFIED™ や、 FIDO CONFORMANT がつけられているものがあります。いずれもREADMEの最後の方に説明が書かれています。

  • FIDO CERTIFIED™
    • FIDOアライアンスの試験に合格し、認定をもらっている
  • FIDO CONFORMANT
    • FIDOのテストツールで合格していることが報告されているもの

Rubyのライブラリはというと、 FIDO CONFORMANT として、以下のライブラリがあります。

github.com

利用実績等は以下のようなものがあるようです。

今回は、このライブラリを使った実装を紹介してみようと思います。

余談ですが、フロント側で使いづらい値をJSONで使いやすくするために以下のようなライブラリもあります。単に取り回しやすくしてくれるだけなので、機能的な追加があるといったようなものではありません。

github.com

認証情報の新規追加(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ということがわかりました。

www.w3.org

ちなみに、user.idにも色々制限があり、emailなど個人情報に近いものはNGとなっているので、一意かつランダムな値が良いと思います。(RubyだとSecureRandomなどで生成される値)

www.w3.org

iOSでの挙動について

以下記事が参考になるかと思いますが、iOSでは必ずユーザー操作を挟む必要があります。極端に言えば、ログイン画面に入った瞬間トークンを取得し、navigator.credentials.getなどを呼び出しても動くはずですが、iOSでは動きません。

実際の事例と実装例が、YubiKeyを作っているYubico社様のサイトにあります。

developers.yubico.com

今回の例で行くと、

ログインボタンを押す→トークン取得→navigator.credentials.get()→トークン検証→ログイン

だと失敗する場合があり、

ログインボタンを押す→トークン取得→ダイアログなどに確認ボタンを出して押す→navigator.credentials.get()→トークン検証→ログイン

などとして、ユーザー操作を挟むことで回避可能です。

まとめ

今回の記事では、Ruby on RailsでWebAuthnを導入するにはどうすれば良さそうかについて書いてみました。

WebAuthnの仕様自体もこれからまだ拡張されていくと思いますので、よりセキュアなログイン方法を提供するために、定期的にウォッチしていきたいなと思います。

採用情報

虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp