既存アプリケーションの認証基盤を Firebase Authentication にお引越し

そろそろ自作WebアプリケーションTwitterログインに対応したいなと思い続けてはや1年。重い腰を上げてやるぞ~となったのでやってゆきます。

このWebアプリケーションの構成は概ね以下の通り。これをどのように移行していくかを考えます。

  • Ruby on Rails製のAPIサーバ(以下リソースサーバと呼ぶ)とVue.js製フロントエンドで構成されている
  • 認証まわりはdevise gemを利用しており、リソースサーバと認証サーバは同居している
  • リソースサーバ宛のAPIリクエストの認証はセッションクッキーに基づき行っている

どうして置き換えるの?

上記構成だと OmniAuth gemをつかうことで目的は果たせそうですが、以下の理由で置き換えることにしました。

  • 今後ほかのWebアプリケーションを作りたくなったときに備えて認証機能をアプリケーションから切り離しておき、使いまわせるようにしたかった
  • 新しいログイン方法を採用したいときにそれを開発したくない
    • メールリンク / パスワード / 外部ID / 2要素認証 etc...
    • 認証周辺の処理は特にセキュリティへの配慮が必要なので自前で開発したくない。みんなが使っている安全なシステムに任せたい。
    • Firebase Authentication から Identity Platform に移行すれば二要素認証もサポートできそう?(Firebase Authenticationと Identity Platformは内部的には同一っぽいので、Firebase Authenticationで追加したユーザーは Identity Platformでも参照できる)

今回は、「Twitter認証をサポートしていること」かつ「ドキュメントが分かりやすく読みやすい」ということで外部認証基盤としてFirebase Authenticationを選択しました。

firebase.google.com

基本的には上記ドキュメントを参照しながら、必要に応じて Firebase JavaScript SDKのドキュメント を見て進めました。

認証情報の受け取りとリソースサーバの認証

認証情報の要求とリソースサーバでの認証情報の利用は、概ねこんな感じで行えそうです。

f:id:piyorinpa:20211222224023p:plain

  1. クライアント側(今回はWebフロントエンド)からFirebase Authenticationが提供する認証サーバにログインリクエストを行う
  2. 認証サーバから認証の成否+認証が成功していればユーザー情報が返却される
  3. ユーザー情報のうち、IDトークン(署名付きJWT)をリソースサーバに送信する
  4. リソースサーバ側で公開鍵を用いてIDトークンを検証
  5. IDトークンの検証の結果、認証サーバから払い出されたものであることを確認できたら、トークンのペイロードに含まれるユーザーIDからユーザーを特定してリソースを返却する

これを順番に行ってみます。

準備

まずは 公式ドキュメント を参考にしながら、WebフロントエンドアプリケーションにFirebase SDKをインストールします。

npm install --save firebase

アプリケーションを初期化する際に、Firebase SDKを初期化する必要があります。APIキーなどはFirebaseのWebコンソールなどで確認しましょう。

import { initializeApp } from 'firebase/app';

initializeApp({apiKey: 'xxxxxx', authDomain: 'hoge.fireabseapp.example'});

サインアップ(手順1, 2)

f:id:piyorinpa:20211222230720p:plain

Firebase Authentication は以下のフローによって認証を行う「メールリンク認証」をサポートしています。

  • ログイン画面にメールアドレスを入力する
  • 入力したメールアドレス宛に確認メールが送られる
  • メール内に含まれるURLにアクセスすることで認証が完了する

アカウントを従来の認証基盤からFirebase Authenticationに移行する際に、パスワードの取り扱いがネックになります。多くのウェブサービスではユーザー情報が漏えいした場合のリスクを低減するために、パスワードを不可逆な文字列に変換する関数を通して保存・検証しているかと思います。 このため、移行先と移行元で変換関数のアルゴリズムや各種パラメータを一致させなければパスワード情報の移行ができません。

過去記事にも示した通り、パスワード情報のインポートも頑張ればできます。)

メールリンク認証でひとまずログインしてもらって、後から新たにパスワードを設定してもらうことで面倒なパスワード情報の移行を回避できそうです。

firebase.google.com

予めFirebase AuthenticationのWebコンソールで「メール / パスワード」プロバイダを追加しておく必要があります。また、メール / パスワードプロバイダの「メールリンク(パスワードなしでログイン)」設定をONにしておく必要があります。

f:id:piyorinpa:20211222224835p:plain
Firebase AuthenticationのWebコンソールの設定画面

リファレンス実装はこんなかんじ。

// sign_up.html

import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

const auth = getAuth();
const email  = 'account@example.com'

function sendVerificationMail() {
  try {
    const actionCodeSettings = {
      // メールに含まれるリンク先
      // このリンクに認証に必要なパラメータが付与されてメール本文に掲載される
      url: 'https://example.com/auth/confirm.html',
      handleCodeInApp: true
    };
    // メールの送信(この時点でアカウントが認証サーバ上に存在しなければ作られる)
    await sendSignInLinkToEmail(auth, email, actionCodeSettings);
    window.localStorage.setItem('emailForSignIn', email);
  } catch(e) {
    console.error(e);
  }
}

メールの送信はFirebase Authenticationが行ってくれます。あとは actionCodeSettings.url に指定したメール内リンクの遷移先の処理を書いていきます。

// confirm.html

import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

const auth = getAuth();

function verify() {
  try {
    // メールリンク認証に必要なパラメータが存在する場合は処理を続行
    if (isSignInWithEmailLink(auth, window.location.href)) {
      // localStorageに保存しておいたメールアドレスを取り出す
      // [NOTE] メール送信を行ったデバイスと異なるデバイスでアクセスされた場合は
      // localStorageにメールアドレスが保存されていないので
      // 何らかの手段でメールアドレスを聞く必要がある
      const email = localStorage.getItem('emailForSignIn');

      // パラメータを検証してよさそうならユーザー情報を取得する
      const userCredential = await signInWithEmailLink(auth, email, window.location.href);

      // [WIP] この辺にリソースサーバ側のユーザーリソースを作成する処理を作る
  } catch(e) {
    console.error(e);
  }
}

window.onload = () => verify();

これだけでアカウント登録や認証ができてしまうなんて簡単ですね。登録されたアカウント情報はFirebase AuthenticationのWebコンソールでも確認できます。

あとは、得られた認証情報に含まれるuid(ユーザーを一意に特定するためのトークン)を用いてリソースサーバ側にユーザーリソースを作ればよさそうです。

お気づきかもしれませんが、メールリンク認証の場合はサインインとサインアップの処理はほぼ同じですね。ログイン時も同様に sendSignInLinkToEmail()signInWithEmailLink() を組み合わせることで認証情報を得られます。

パスワードの設定

メールリンク認証ではなく、メールアドレス / パスワード認証としたい場合は、サインアップ処理(メールリンク認証によるログイン)の直後に設定してもらうのがよいでしょう。

(サインアップ処理のあとにページ遷移をした場合、認証状態を永続化 していない場合はログイン状態がなくなってしまうことに注意。なので、リファレンス実装を行うときはサインアップと同一ページでパスワードを変更してみるといいと思います。)

import { updatePassword } from "firebase/auth";

async setPassword() {
  const password = 'passw0rd';

  await updatePassword(auth.currentUser, password);
}

f:id:piyorinpa:20211222233630p:plain

多くの認証基盤におけるメールアドレス / パスワード認証の場合、サインアップ時にメールを送信してメールアドレスの所有者確認をするかと思いますが、 上記手順で行うことで、Firebase Authenticationでもそれを再現できます。

メールの所有者確認ができているかどうかは、 auth.currentUser.emailVerified の真偽値で確認できるので、「メールの所有者確認ができていないユーザーには操作を制限する」という 実装をアプリケーション側で行うことも可能です。後述するIDトークンのペイロードからも確認できます。

(たとえば、Firebase Authenticationのドキュメント「パスワード認証」の項のとおりにメールアドレス / パスワードを登録したときは、確認メールの送信などによってメールアドレスの所有者確認をしないので、 auth.currentUser.emailVerifiedfalse になります)

パスワードを設定したあとのパスワード認証によるログインについては、公式ドキュメントの通り実装をすればよさそうです。

firebase.google.com

ログイン状態の永続化

ログイン処理の前に以下のように記述することで、ページ遷移してもログイン状態を維持できるようになります。 (ここでいうページ遷移とは、シングルページアプリケーションのようにクライアント側の制御でページを遷移させるものではなく、ハイパーリンクをクリックしてサーバからHTMLを取得してレンダリングされる状態のこと)

import { getAuth, browserSessionPersistence} from "firebase/auth";

const auth = getAuth();

await setPersistence(auth, browserSessionPersistence);

このとき、ログイン後にはクライアント側のIndexedDBにユーザー情報が保存されるようです。

詳しくはドキュメントを参照するとよさそうです。

firebase.google.com

IDトークンの検証(手順3, 4, 5)

認証サーバから得られた認証情報をリソースサーバで検証することで、ユーザーを特定したりする必要があります。「サインアップ」の項のメールリンクの検証部分に以下のようなソースコードを追加したとしましょう。

  // confirm.html

  import { getAuth, sendSignInLinkToEmail } from "firebase/auth";

  const auth = getAuth();

  function verify() {
    try {
      // メールリンク認証に必要なパラメータが存在する場合は処理を続行
      if (isSignInWithEmailLink(auth, window.location.href)) {
        // localStorageに保存しておいたメールアドレスを取り出す
        // メール送信を行ったデバイスと異なるデバイスでアクセスされた場合はlocalStorageにメールアドレスが保存されていないので
        // 何らかの手段でメールアドレスを聞く必要がある
        const email = localStorage.getItem('emailForSignIn');

        // パラメータを検証してよさそうならユーザー情報を取得する
        const userCredential = await signInWithEmailLink(auth, email, window.location.href);

-       // [WIP] この辺にリソースサーバ側のユーザーリソースを作成する処理を作る
+       // サインアップ処理
+       await fetch('https://api.example.com/auth/sign_up', {
+         method: 'POST',
+         headers: {
+           // (auth.currentUser.accessToken でもOK)
+           `Authorization: ${userCredential.accessToken}`  
+         }
+       })
      }
    } catch(e) {
      console.error(e);
    }
  }
   
  window.onload = () => verify();

上記Authorization Headerに設定した userCredential.accessToken は署名付きJWTで、ペイロードにuidが含まれています。 リソースサーバはRuby on Railsを用いて開発されていますが、FirebaseのRuby SDKは提供されていないため、自前で署名の検証やJWTのデコードを行う必要があります。 これをFirebase Authenticationのドキュメントに示される情報を参照しながら実装します。

firebase.google.com

Ruby on Rails の場合、たとえば以下のようにすることで検証できます(ruby-jwt gem をインストールする必要があります)。署名検証に成功すれば、JWTは確かにFirebase Authenticationから送信されていて、かつ改ざんされていないことが分かるので、 ペイロードに含まれる情報を用いてログインユーザーを決定できます。

module FirebaseIdTokenVerification
  extend ActiveSupport::Concern

  def verification_firebase_id_token
    # 公開鍵の取得
    certs = fetch_firebase_verification_cert

    # JWTのうちヘッダ部を取得する
    header = JSON.parse(Base64.decode64(headers[:token].split('.').first))

    # fetch_firebase_verification_cert で得られる公開鍵の中から必要なものを取り出す
    cert = OpenSSL::X509::Certificate.new(certs[header['kid']]).public_key

    # RS256 で署名検証
    decoded = JWT.decode(params[:token], cert, true, { algorithm: 'RS256' })

    # uidを含む検証済みペイロードが得られる
    @firebase_verified_payload = decoded[0]
  end

  
  def fetch_firebase_verification_cert
    body = Net::HTTP.get(URI.parse('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com')) 
    JSON.parse(body)
  end
end

fetch_firebase_verification_cert は毎回リクエストするのではなく、Redisなどの揮発性ストレージに入れておいてもよいかもしれません。

さらに以下のようにAPIを定義することで、uidをキーに持つユーザーリソースを作成できます。

class AccountsController < ApplicationController
  include FirebaseIdTokenVerification

  before_action :verification_firebase_id_token

  # /auth/sign_up
  def create
    uid = @firebase_verified_payload['sub']
    user = User.create!(firebase_uid: uid)
  
    head :created
  end
end

このようにIDトークンを検証することで、ログインセッションを表現できます。

既存ユーザーのインポート

まだ試していませんが、筆者の過去記事の通り Firebase CLIを用いてCSVインポートができるので、データベースから適切な情報をエクスポートしてFirebase CLIを使ってアップロードすればよさそうです。

garakuta-toolbox.hatenablog.com

Firebase CLIのドキュメントはこちら。

firebase.google.com

まとめ

いろいろ試してみて、既に認証基盤を持った既存アプリケーションでも割と移行できそうであることが確認できました。 パスワードリセット機能やアカウント更新機能など、一通りそろっているので引き続き問題なく移行できそうです。

実装を進めて、Twitter認証できるように改修するぞ~というかんじです。ではでは~