オールアバウトTech Blog

株式会社オールアバウトのエンジニアブログです。

Firebase における IP アドレス制限・カスタム認証について

f:id:allabout-techblog:20200324101558p:plain

先週に続き、オールアバウトの新卒1年目エンジニアが投稿する企画「テックブログ新卒週間2020」を開催します。本記事の執筆者 okutan です。よろしくお願いします。

目次

はじめに

業務でリアルタイム掲示板を Firebase で開発することになりました。
そのシステムの技術選定や試作品開発にあたり、Firebase のドキュメントや関連記事は充実していましたが、調査・検証に時間を要した点がいくつかありました。

そこで、本記事では Firebase を利用する際、時間がかかった調査結果の共有を目的として、

  1. Firebase Hosting における IP アドレス制限
  2. Firebase Authentication のカスタム認証

の使用例を示したいと思います。

本記事によって Firebase の採用を考えている方のお役に立てれば幸いです。
また、Firebase に興味がある方へ布教ができれば嬉しいです。

開発した リアルタイム掲示板のシステム構成図

今回開発したリアルタイム掲示板のシステム構成図です。
本記事では、この図における 1. IP アドレス制限2. カスタム認証 について共有します。

f:id:allabout-techblog:20200330092312p:plainリアルタイム掲示板のシステム構成図

1. Firebase Hosting における IP アドレス制限

Firebase Hosting 自体に IP アドレス制限の機能はありません。
そのため、Firebase Hosting に対する特定ファイルパスへのリクエストを Cloud Functions for Firebase に送信するよう設定し、受信した関数内でクライアント IP アドレスに基づいて静的ファイルを返すことで IP アドレス制限を実現できます。

この方法について、いくつかの注意点と共に示していきます。

Hosting へのリクエストを Functions に送る

まず、 IP アドレス制限を行える Cloud Functions for Firebase から静的ファイルを配信するために、Firebase Hosting の特定ファイルパスへのリクエストを Functions に送るようホスティング動作を設定します。
ホスティング動作は firebase.json1 で設定することが可能なので、その例を示します。

  • 管理画面の URL における //index.html へのリクエストを関数 adminIndexHtml に送信する firebase.json の設定例
{
    "hosting": {
        ...
        "rewrites": [ 
            {
                "source": "/@(|index.html)",
                "function": "adminIndexHtml"
            }
        ],
        ...
    },
... 

この設定についての説明をしていきます。

まず、 "hosting": で firebase プロジェクトにおける Firebase Hosting のホスティング動作を設定しています。
その中で "rewrites": ルール2を用いて、 "source: " に対するリクエストを "functions" に送るようにしています。
"source": は glob パターンマッチング3で指定されるため /@(|index.html) はファイルパス / または /index.html を表しており、これらに対するリクエストを、関数 adminIndexHtml に送るよう機能します。4

ここでの注意点は 2 つあります。

1つは、IP アドレス制限を行う関数 adminIndexHtml のリージョンは us-central1 にする必要があることです。これは Firebase Hosting と Cloud Functions の接続がこのリージョンしか対応していないためです。5
ただ、Cloud Functions for Firebase では関数単位でリージョンの選択ができるため、他の関数は別のリージョンにデプロイすることができます。6

もう一つは、

Firebase Hosting は、指定された source にファイルまたはディレクトリが存在しない場合にのみリライトルールを適用します。ルールがトリガーされると、ブラウザは HTTP リダイレクトではなく、指定された destination ファイルの実際のコンテンツを返します。

と記載があるように 、URL で指定されたファイルパスが実際に存在した場合、そのファイルが関数 adminIndexHtml から配信されません。
つまり、 IP アドレス制限を行いたい静的ファイル( index.html など)は Firebase Hosting から削除し、 Cloud Function for Firebase からのレスポンスでのみ配信する必要があります。
このことから、多くの静的ファイルに対して IP アドレス制限を適用する必要がある場合、 Firebase Hosting を使うことは現実的ではないと考えられます。

Functions におけるクライアント IP アドレス取得

まず、結論から示しますと、 Hosting から受信した Functions におけるクライアント IP アドレスを取得するプログラムは、私の場合、次のようになりました。

  • Firebase Hosting から受信した Cloud Functions for Firebase における IP アドレス取得 (Node.js)
const functions = require("firebase-functions");

exports.adminIndexHtml = functions.https.onRequest((req, res) => {
    const client_ip = req.headers["fastly-temp-xff"]
        .split(",")
        .pop()
        .trim();
...

このプログラムから分かるように、 HTTP ヘッダーの fastly-temp-xff 末尾から IP アドレスを取得しています。
この形式になった過程を説明していきます。

まず、IP アドレスは HTTP ヘッダーから取得できると考え、Hosting 経由の関数におけるヘッダーの中身を次に示すプログラムで確認してみました。

  • 関数 adminIndexHtml で HTTP ヘッダー内容を確認する
const functions = require("firebase-functions");

exports.adminIndexHtml = functions.https.onRequest((req, res) => {
    console.log(req.headers);
    res.send("ログ出力を Firebase コンソールで確認する");
});
  • 上記ログ出力から一部抜粋 ( IP アドレスは XXX.XXX.XXX.XXX, YYY.YYY.YYY.YYY で表現)
{
    ...
    "cdn-loop": "Fastly, Fastly",
    "fastly-client": "1",
    "fastly-client-ip": "XXX.XXX.XXX.XXX",
    "fastly-temp-xff": "XXX.XXX.XXX.XXX, XXX.XXX.XXX.XXX",
    ...
    "x-appengine-user-ip": "YYY.YYY.YYY.YYY",
    ...
    "x-forwarded-for": "XXX.XXX.XXX.XXX,YYY.YYY.YYY.YYY",
    ...
}

XXX.XXX.XXX.XXX が今回取得したいクライアント IP アドレスでした。
ログの出力結果から、 HTTP ヘッダーの fastly-client-ip, fastly-temp-xff, x-forwarded-for のいずれかから取得できることがわかります。

また、 Firebase Hosting の CDNFastly を用いているのでしょうか?
Fastly のドキュメントには、 fastly-client-ip はクライアント IP アドレスであると記載されています7

ただ、IP アドレスの偽装が可能なのか検証してみたところ、 x-forwarded-for の IP アドレスに加えて、 fastly-client-ip も偽装することができてしまいました。取得したい IP アドレスが含まれた HTTP ヘッダーの中で fastly-temp-xff のみ、(セキュリティの知識が全くない私には)偽装ができませんでした。

したがって、クライアント IP アドレスの取得は先に示したプログラムの様に fastly-temp-xff から取得する形式にしましたが、 fastly-temp-xff についての調査が不十分で安定して機能するかが私には現状分かっていないため、IP アドレス制限ができなければ致命的な問題が発生する機能・製品に対しては、認証機能を実装する必要があると考えます。

ちなみに、Node.js のモジュール request-ip を使用して IP アドレスを取得した場合、 X-Client-IP, X-Forwarded-For, fastly-client-ip から取得される可能性が高く8、 IP アドレスの偽装に対応できないため注意が必要だと考えられます。

それと、Firebase のドキュメントにある別の文脈でのやり方では req.connection.remoteAddress で IP アドレスしていましたが9、今回の様に Hosting 経由の関数である場合、取得したいクライアント IP アドレスではありませんでした。

Functions からクライアントに静的ファイルを配信する

IP アドレス制限をして配信したい静的ファイルを index.html として、 Cloud Functions for Firebase からクライアントに配信する方法を説明していきます。

まず前述の通り、Hosting からは index.html を配信しないよう削除する必要があります。削除しない場合、 Functions へリクエストが送信されないため、 IP アドレス制限が機能しません。

次に、削除した index.html を Functions のプロジェクトへ下記のように配置し、 IP アドレス制限を通過したリクエストに対してのみ配信するよう実装します。

f:id:allabout-techblog:20200330092308p:plain

  • 関数 adminIndexHtml で IP アドレス制限を行い index.html を配信する
const functions = require("firebase-functions");
const fs = require("fs");

exports.adminIndexHtml = functions.https.onRequest((req, res) => {
    const allowed_ips = ["XXX.XXX.XXX.XXX"];
    const client_ip = req.headers["fastly-temp-xff"]
        .split(",")
        .pop()
        .trim();
    const is_allowed = allowed_ips.indexOf(client_ip) !== -1;
    let html = "";
    let status = 0;
    if (is_allowed) {
        html = fs.readFileSync("./admin/index.html").toString();
        status = 200;
    } else {
        html = "";
        status = 403;
    }
    res.status(status).send(html);
});

以上の方法で index.html に対して IP アドレス制限をかけることが可能になります。
また、これらの実装を拡張することで、任意の静的ファイルに対して IP アドレス制限を行うことが可能になると考えられます。

2. Firebase Authentication と独自認証基盤の連携

なぜ Authentication に独自の認証基盤を連携したか?

理由は独自の認証基盤によるユーザーの認証状態を、 Cloud Functions for Firebase などを経由させることなく、 Firebase Realtime Database で検証するために連携しました。

今回開発したリアルタイム掲示板では、クライアントユーザーを自社がもつ認証基盤を用いて認証する必要がありました。

ただ、リアルタイム掲示板の DB は Firebase Realtime Database を使用し、クライアントから直接参照することでその恩恵10を得たかったため、セキュリティルール11で認証状態を検証する必要がありました。

そこで、Firebase Authentication と自社の認証基盤を連携させることで、セキュリティルールにおいて自社の認証基盤を用いた認証状態の検証を実現することができるため、連携を行いました。

連携方法

Firebase のドキュメント12を参考に、認証基盤において認証完了後に Firebase Authentication 用の JWT を発行するような API を実装し、 この API で返す JWT のクレーム iss に、認証基盤におけるユーザーIDを設定することで、 Firebase Authentication のユーザーIDと 認証基盤のユーザーIDを紐づけられます。

また、リアルタイム掲示板で表示するニックネームなども Firebase Authentication のカスタムクレームに保存ができ、セキュリティルールにも適用可能です。(ただし 1,000 Byte 以下)13

ただし、独自の認証基盤からクライアントに JWT を送信するには CORS を使用したレスポンスを認証基盤に実装する必要があるかと思います。

以上の方法で Firebase Authentication と独自認証基盤を連携させることが可能になります。

おわりに

本記事では、 Firebase における IP アドレス制限と独自認証基盤との連携について、調査結果・使用例を示しました。

IP アドレス制限は、 Firebase Hosting に IP アドレス制限の機能がないため、 Hosting に対する特定静的ファイルへのアクセスを Cloud Function for Firebase に送信し、受信した関数内でクライアント IP アドレスに基づいて静的ファイルを配信するよう設定することにより実現しました。
ただ、クライアントIPアドレスを取得する実装には不確定要素があるため注意が必要です。

独自の認証基盤との連携は、認証基盤に Firebase Authentication 用の JWT を返す実装をすることにより実現しました。 JWT のクレーム sub に認証基盤におけるユーザーIDを設定することで、Firebase Authentication におけるユーザーIDと認証基盤のユーザーIDを紐付けています。