FIDO2に則ったパブリックキー認証の実装

通常の開発同様にFIDO2に則った実装でブラウザ・フロントエンド・バックエンド・DBと扱われるレイヤーはそれぞれあるが、普段使いのブラウザとOSを意識して実装する必要がある。ブラウザが対応してなければ使えないからだ。

Chromeは今のバージョンが100超えているし、Safariは5月時点で16超えているので気にすることはない。が、問題は秘密鍵の共有にある。

なので僕らアプリケーション開発者ができることってそんななくて、Google先生Apple大明神が対応してくれるのを待つしかない。とあるバージョンからは対応してくれていているんだが、図の通り秘密鍵の共有はしてくれない。

初回ログインフロー

緑がクライアント、黄色がFIDO2対応しているWebアプリケーションだが、イメージとしてはみんなお馴染みSSHとそんなやってることはかわらない。秘密鍵はこちらがブラウザで管理し、アプリケーション側は公開鍵をDBに保存する。

見ての通り端末依存なので、SSHの秘密鍵がないとサーバにログインできないように秘密鍵・公開鍵のペアがないと認証は通らない。

これがFIDO2の課題なわけなんだけど、パスキーがある程度は解決してくれる。

ある程度といったのは、各端末のChrome同士、Safari同士なら解決できるが、Chromeで作った秘密鍵をSafariが共有することはできない。ただ秘密鍵なのでもれたら終わりだし、正直な話共有はあまりしたくないとも思う。

実装

FIDO2規格の秘密鍵->公開鍵の認証を実装する。Webアプリケーション側でこの実装ができるとiCloudで秘密鍵共有に繋げることができる。

WWDCでAppleの人が説明してくれていた。

要件はこんな感じ

この場合の認証機はFace IDやTouch IDといった生体認証のこと。FIDOの最初の方ではなんかUSBで繋ぐ指紋認証機的なのを想定していたが、今パスワード使うのにそんなの持ち歩かないので、Face IDとTouch IDだと思って差し支えないと思う。

実装APIは四つ。新規登録・ログインそれぞれのアクションで二つのAPIを叩く。それぞれの処理が終わったら表示させたい情報を返すAPIが叩けるようになる。あとはフロントエンドからアクセスするトークンなどを発行して、バックエンドと通信してもらえればいい。

今回は初回ログインの処理を書いた。controllerに全書きされているが業務で使う場合Service ObjectやRailsのModelに小さく切り出すとかする。

  # POST   /session(.:format)
  def create
    user = User.find_by(name: name_param)

    if user&.authenticate(params[:password])
      sign_in(user)
      redirect_to(new_session_path)
      return
    end

    if user
      get_options = WebAuthn::Credential.options_for_get(
        allow: user.credentials.pluck(:external_id),
        user_verification: 'required'
      )

      save_authentication('challenge' => get_options.challenge, 'name' => session_params[:name])

      hash = {
        original_url: new_session_path,
        callback_url: callback_session_path(format: :json),
        get_options:
      }

      respond_to do |format|
        logger.debug { "respond with: #{hash}" }
        format.json { render(json: hash) }
      end
    else
      respond_to do |format|
        logger.debug { "name #{name_param} does not exist" }
        format.json { render(json: { errors: ['name does not exist'] }, status: :unprocessable_entity) }
      end
    end
  end

ここでは送られてきたパスワードを保存し、ログイン用のチャレンジコードを発行する。フロントエンド側に生成したCallback APIのURLと一緒にチャレンジコードを返す。するとブラウザがチャレンジコードを元に署名した秘密鍵を作成し、Callback APIにポストしてくれる。

Callback APIは以下。

 # POST   /session/callback(.:format)
  def callback
    logger.debug { 'in session#callback' }
    webauthn_credential = WebAuthn::Credential.from_get(params)

    user = User.find_by(name: saved_name)

    raise("user #{saved_name} never initiated sign up") unless user

    credential = user.credentials.find_by(external_id: external_id(webauthn_credential))

    begin
      webauthn_credential.verify(
        saved_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

わかりやすいようにcallbackとなっているが、本当は別なcontrollerにしてcreateアクションを増設するのが正しいだろう。あとはwebauthnのgemが公開鍵を検証し秘密鍵とexternal_idを保存してあげる。その後は手動でセッションを開始する流れになる。

当然Deviceなんてものは使わないので、いつものメソッドが使いたいなら継承するクラスに書いておくといいだろう。

  before_action :authenticate_user

  def sign_in(user)
    session[:user_id] = user.id
  end

  def sign_out
    session[:user_id] = nil
  end

  def authenticate_user
    redirect_to(new_session_path) unless current_user
  end

  def current_user
    @current_user ||=
      (User.find_by(id: session[:user_id]) if session[:user_id])
  end

困りポイント

やっぱり共有できないのが引っかかる。あくまでブラウザ、デバイス依存だからだ。パスワードもそうやんけというのはあるが、仕組み上秘密鍵がないとログインできない。

解決策としてはサービス側でユーザが複数の公開鍵を管理できるようにしてあげるかユーザが頑張るかかなと思う。

それかAppleはiCloudを全てのデバイスで使えるので、Appleに魂を売れば解決する。

Apple系は便利だけど最近高いから困る。

まとめ

MacでChrome使いたい。