特定のapikeyを持つ通信のみFirebase Hostingへのアクセスを許可するようにする

# 概要

Cloud Functions for Firebase を用いることで、ヘッダの値をもとにFirebase hostingへのアクセスを制限してみます。

# 前提

動作環境は、Microsoft Windows 10 Homeです。
Node.jsはインストールしてある前提です。(v12.14.1)

# 準備

## Firebase CLIインストール
`npm install -g firebase-tools` でFirebase CLIをインストールします。

## Firebase hostingプロジェクトを作成
`firebase login` でローカルマシンをFirebaseに接続させます。

`firebase projects:list` でCLI が正しくインストールされていて、アカウントにアクセスしていることをテストします。

```
C:\Users\nanna>firebase projects:list
√ Preparing the list of your Firebase projects
┌──────────────────────┬───────────────────┬────────────────┬──────────────────────┐
│ Project Display Name │ Project ID        │ Project Number │ Resource Location ID │
├──────────────────────┼───────────────────┼────────────────┼──────────────────────┤
│ angular-try          │ angular-try-8c667 │ 422372883147   │ us-central           │
└──────────────────────┴───────────────────┴────────────────┴──────────────────────┘

1 project(s) total.
```

`firebase init` でFirebaseプロジェクトを追加します。

```
C:\Users\nanna\firebase-try>firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  C:\Users\nanna\firebase-try

? Are you ready to proceed? Yes
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. Hosting: Configure and deploy Firebase Hosting sites

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Please select an option: Create a new project
i  If you want to create a project in a Google Cloud organization or folder, please use "firebase projects:create" instead, and return to this command when you've created the project.
? Please specify a unique project id (warning: cannot be modified afterward) [6-30 characters]:
 apikey-control
? What would you like to call your project? (defaults to your project ID)
√ Creating Google Cloud Platform project
√ Adding Firebase resources to Google Cloud Platform project

=== Your Firebase project is ready! ===

Project information:
   - Project ID: apikey-control
   - Project Name: apikey-control

Firebase console is available at
https://console.firebase.google.com/project/apikey-control/overview
i  Using project apikey-control (apikey-control)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? public
? Configure as a single-page app (rewrite all urls to /index.html)? No
+  Wrote public/404.html
+  Wrote public/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

+  Firebase initialization complete!
```

コマンド実施すると、[Firebaseコンソール](https://console.firebase.google.com/)にプロジェクトが追加されます。
また、以下のようにローカルにファイルが生成されます。

```
C:\USERS\NANNA\FIREBASE-TRY
│  .firebaserc
│  .gitignore
│  firebase.json
│
└─public
        404.html
        index.html
```

## Cloud Functions を初期化

`firebase init functions` でCloud Functions を初期化します。

```
C:\Users\nanna\firebase-try>firebase init functions

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  C:\Users\nanna\firebase-try

Before we get started, keep in mind:

  * You are initializing in an existing Firebase project directory

? Are you ready to proceed? Yes

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

i  .firebaserc already has a default project, using apikey-control.

=== Functions Setup

A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
+  Wrote functions/package.json
+  Wrote functions/index.js
+  Wrote functions/.gitignore
? Do you want to install dependencies with npm now? Yes

> protobufjs@6.9.0 postinstall C:\Users\nanna\firebase-try\functions\node_modules\protobufjs
> node scripts/postinstall

npm notice created a lockfile as package-lock.json. You should commit this file.
added 258 packages from 206 contributors and audited 258 packages in 10.445s

30 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities


i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

+  Firebase initialization complete!
```

成功すると、新たに`functions`ディレクトリが生成されます。

```
C:.
│  .firebaserc
│  .gitignore
│  firebase.json
│
├─functions
│      .gitignore
│      index.js
│      package-lock.json
│      package.json
│
└─public
        404.html
        index.html
```

## 取得するコンテンツを配置

今回、コンテンツ取得を目的とした通信が正当なものではない場合、ステータスコード400で`400.html`を返すようにします。
また、コンテンツとして、`file-A.html`と`file-B.html`を配置しておきます。

これらをCloud Functionsから見れるように、以下のように配置します。(クラウド上にデプロイするときには、Firebase Hostingの資材とCloud Functionsの資材は別々のところに行く。`functions`配下(Cloud Functions)と`public`配下(Firebase Hosting)は別々の場所にデプロイされるイメージ)

```
C:.
│  .firebaserc
│  .gitignore
│  firebase.json
│      
├─functions
│  │  .gitignore
│  │  index.js
│  │  package-lock.json
│  │  package.json
│  │  
│  └─public
│          400.html
│          404.html
│          file-A.html
│          file-B.html
│          
└─public
        404.html
        index.html
```

## 使わない資材を削除

今回は、**Firebase Hostingは実質ドメイン取得だけのために使う**構成にしました。
レスポンスは、Cloud Functions側で`functions/public`配下の資材をもとに作成するので、Firebase Hosting側の`public`配下の資材は使わないので削除します。
その結果、下記のような構成になります。

```
C:.
│  .firebaserc
│  .gitignore
│  firebase.json
│      
├─functions
│  │  .gitignore
│  │  index.js
│  │  package-lock.json
│  │  package.json
│  │  
│  └─public
│          400.html
│          404.html
│          file-A.html
│          file-B.html
│          
└─public
```

# 動作するように編集

## functions/index.js

`functions/index.js`を以下のように編集し、コンテンツ取得リクエストのパスと`apikey`ヘッダが想定と符合するものであればステータスコード200でコンテンツファイルを返すようにします。
また、この機能を`filter`と名付けています。

```javascript
const functions = require('firebase-functions');
const fs = require('fs');
const path = require('path');

// // Create and Deploy Your First Cloud Functions
// // https://firebase.google.com/docs/functions/write-firebase-functions
//
exports.filter = functions.https.onRequest((request, response) => {
    const requestPath = request.path;
    const matchFile = data.find(d => d.path === requestPath);
    let statusCode;
    let fileName;

    if (typeof matchFile === "undefined") {
        statusCode = 404;
        fileName = "404";
    } else {
        if (matchFile.apikey.some(k => k === request.headers.apikey)) {
            statusCode = 200;
            fileName = requestPath;
        } else {
            statusCode = 400;
            fileName = "400";
        }
    }

    let fileContent;
    fileContent = fs.readFileSync(path.resolve('public') + '/' + fileName + '.html');
    const html = fileContent.toString();

    response.status(statusCode).send(html);
});

const data = [
    {
        path: "/file-A",
        apikey: [
            "apikey1",
            "apikey2",
            "apikey3"
        ]
    },
    {
        path: "/file-B",
        apikey: [
            "apikey3"
        ]
    }
]
```

## firebase.json

`firebase.json`を以下のように編集し、該当のドメイン配下に来た通信には、`filter`を通すようにします。

```json
{
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "filter"
      }
    ]
  }
}
```

# 結果

下記のページにデプロイしています。
https://apikey-control.web.app/

`https://apikey-control.web.app/file-A` への通信のヘッダに`apikey: apikey1`or`apikey: apikey2`or`apikey: apikey3`を付ければ、晴れてfile-Aが見れます。
`https://apikey-control.web.app/file-B` へは、`apikey: apikey3`のみ許可しています。

[Talend API Tester](https://chrome.google.com/webstore/detail/talend-api-tester-free-ed/aejoelaoggembcahagimdiliamlcdmfm?hl=ja)や[Burp Suite](https://portswigger.net/burp/communitydownload)などを使って、apikeyを付与しながら確かめることができると思います。
Talend API Testerが一番手軽かもしれません。

# その他

## firebase.json のrewritesについて

`firebase.json`の下記の部分で、すべてのリクエストを`filter`にかけることができると思っていたのですが、ルート直下?(今回だと、`https://apikey-control.web.app/`)のアクセスについて、**`public/index.html`がある場合は**、まず`index.html`にリダイレクトに行くような動作になるようでした。
https://stackoverflow.com/questions/44871075/redirect-firebase-hosting-root-to-a-cloud-function-is-not-working

```json
    "rewrites": [
      {
        "source": "**",
        "function": "filter"
      }
    ]
```

## firebase serve の挙動が不可解な時がある

firebase serveを実施することで、ローカルにて挙動を確認できるのですが、私のローカルで`https://apikey-control.web.app/`にアクセスすると、`https://apikey-control.web.app/404`にリダイレクトされるという挙動になりました。
原因は不明なのですが、deploy後のクラウド環境では再現しなかったです。

# 参考

https://github.com/nannany/firebase-cloud-functions
https://firebase.google.com/docs/hosting/functions
https://qiita.com/otakky/items/1363e7b4c706dc9cd096