LoginSignup
1
0

More than 1 year has passed since last update.

Firebase Authenticationのバックアップとリストアをローカル環境で実行できるようにする

Last updated at Posted at 2023-04-06

お断り

本記事にもとづいて、FirebaseAuthのバックアップ・リストアを試みたことで生じた問題について、私は一切の責任を取ることができません!!!
各処理で何が行われているかを十分に理解した上で、個々人の責任において実行いただきますよう、何卒よろしくお願いいたします。

やりたかったこと

GCPのCloud Shellなどから、Firebase CLIを通してFirebaseAuthのインポート/エクスポートできることは知っているが、ローカル環境で完結できた方が楽。ローカル環境でバックアップもリストアもできるようにしたい。

技術要素

Firebase CLI:Firebaseのプロジェクト管理系の機能を呼び出せる。
Firebase Admin SDK:Firebaseの管理者権限が必要な各種機能を呼び出せる。
Google Cloud Key Management Service(Cloud KMS):データの暗号化と復号ができる。
Google Cloud Storage(GCS):データの保存ができる。

Goolge Cloudにおける事前準備

こちらの記事を大いに参考にさせていただいた。
・Cloud KMSにおけるキーとキーリングの作成
・GCSにおけるバケットの作成

gcloudコマンドをローカル環境にインストールするなど、その他諸々の事前準備については、公式ページ等を参考にしてください。

バックアップ

下記プログラムの実行にあたり、公式ページを参考にして、アプリケーションのデフォルト認証情報を事前に設定すること(ローカル環境でCloud KMSのAPIを使う際に必要となる模様)。

バックアップの流れは以下の通りである。

  1. Firebase CLIのAPIを通して、JSON形式でFirebaseAuthをエクスポートする。
  2. Cloud KMSのAPIを通して、FirebaseAuthのJSONファイルを暗号化する。
  3. GCSのAPIを通して、暗号化されたファイルをバケットに保存する。

your-xxxの計5か所は、FirebaseおよびCloud KMSから実際の値を確認して置換すること。

authBK.ts
import * as kms from '@google-cloud/kms';
import * as gcs from '@google-cloud/storage';
import * as fs from 'fs';
import { format } from 'date-fns';
const firebaseTools = require('firebase-tools');

// 省略:Firebaseインスタンスの生成と初期化

const now = new Date();
const timestamp =
  now.getFullYear() +
  ('0' + (now.getMonth() + 1)).slice(-2) +
  ('0' + now.getDate()).slice(-2) +
  '-' +
  ('0' + now.getHours()).slice(-2) +
  ('0' + now.getMinutes()).slice(-2) +
  ('0' + now.getSeconds()).slice(-2);
const plaintextFileName = `authBK.json`;

// Authenticationユーザのエクスポート
const tmpPlaintextFileName = `/tmp/${plaintextFileName}`;
await firebaseTools.auth.export(tmpPlaintextFileName);
const plaintext = fs.readFileSync(tmpPlaintextFileName);

// 暗号化
const kmsClient = new kms.KeyManagementServiceClient();
const keyName = kmsClient.cryptoKeyPath(
  'your-projectId',
  'your-locationId',
  'your-keyring',
  'your-key'
);
const [encryptResponse] = await kmsClient.encrypt({ name: keyName, plaintext: plaintext });
const tmpCiphertextFileName = `/tmp/${plaintextFileName}.encrypted`;
fs.writeFileSync(tmpCiphertextFileName, encryptResponse!.ciphertext as string);

// GCS に保存
const gcsClient = new gcs.Storage();
const bucketName = 'your-authBK-bucket';
const bucket = gcsClient.bucket(bucketName);
const dateFormat = format(now, "yyyyMMdd'_'HHmmss");
const backupPath = `${dateFormat}`;
await bucket.upload(tmpCiphertextFileName, { destination: backupPath });

// 一時ファイルの削除
fs.unlinkSync(tmpPlaintextFileName);
fs.unlinkSync(tmpCiphertextFileName);

リストア

下記プログラムの実行にあたり、公式ページを参考にして、アプリケーションのデフォルト認証情報を事前に設定すること(ローカル環境でCloud KMSのAPIを使う際に必要となる模様)。

リストアの流れは以下の通りである。

  1. GCSのAPIを通して、暗号化されたファイルを読み込む。
  2. Cloud KMSのAPIを通して、暗号化された情報を復号する。
  3. 復号された情報からオブジェクト型配列としてユーザ情報を取得する。
  4. Firebase Admin SDKのimportUsersのAPIに合わせて、ユーザ情報のオブジェクトの形式(プロパティ構成)を変換する。
  5. Firebase Admin SDKのAPIを通して、既存のユーザ情報を削除する。
  6. Firebase Admin SDKのAPIを通して、ユーザ情報をインポートする。

Firebase CLIのnpm公式サイトにおいて、「The Firebase CLI can also be used programmatically as a standard Node module.」とある。こちらを参考に、firebase-tools.auth.import()を実行してみたが、「firebase-tools.auth.import is not a function」となった。firebase-tools.auth.export()は実行できたのだが。。。

公式ページにて、Authenticationのインポート方法を発見した。ただ、firebase-tools.auth.export()で出力されるユーザ情報と、admin.importUsers()の引数となるユーザ情報では、レコードの情報量は概ね一致しているものの、オブジェクトの形式(プロパティ構成)が異なるらしかった。IDを例に挙げると、エクスポート時はlocalId、インポート時はuidとなっていた。

※前述の公式ページにあるように、インポート時の挙動は以下の通りとなる。
・既存のFirebaseAuthにのみ存在するユーザ:そのまま残る
・両方に存在するユーザ:BKのレコードに上書きされる
・BKにのみ存在するユーザ:レコードが追加される

これでは完全なリストアとは呼べないため、既存のFirebaseAuthを削除した後、BKをインポートする方針とした。

your-xxxの計6か所およびbase64-secretbase64SaltSeparatorは、FirebaseおよびCloud KMSから実際の値を確認して置換すること。

authRestore.ts
import * as kms from '@google-cloud/kms';
import * as gcs from '@google-cloud/storage';
import * as admin from 'firebase-admin';

// 省略:Firebaseインスタンスの生成と初期化

interface CLIExportedFormat {
  localId: string;
  email: string;
  emailVerified: boolean;
  passwordHash: string;
  salt: string;
  displayName?: string;
  lastSignedInAt?: string;
  createdAt: string;
  disabled: boolean;
  customAttributes?: string;
  providerUserInfo?: object[];
}

const gcsClient = new gcs.Storage();
const bucketName = 'your-authBK-bucket';
const bucket = gcsClient.bucket(bucketName);
const backupPath = 'your-backupPath';
const chunks: Buffer[] = [];

// ファイルの読み込み
bucket
  .file(backupPath)
  .createReadStream()
  .on('data', (chunk) => chunks.push(Buffer.from(chunk)))
  .on('finish', async (): Promise<void> => {
    // 復号
    const ciphertext = Buffer.concat(chunks);
    const kmsClient = new kms.KeyManagementServiceClient();
    const keyName = kmsClient.cryptoKeyPath(
      'your-projectId',
      'your-locationId',
      'your-keyring',
      'your-key'
    );
    const [decryptResponse] = await kmsClient.decrypt({
      name: keyName,
      ciphertext: ciphertext,
    });
    const usersInCLIExportedFormat: CLIExportedFormat[] = JSON.parse(
      decryptResponse!.plaintext!.toString()
    ).users;
    const usersInImportUsersArgFormat = convertToImportUsersArgFormat(usersInCLIExportedFormat);

    // 既存のAuthenticationユーザを削除
    const uids = (await admin.auth().listUsers()).users.map((user) => user.uid);
    await admin.auth().deleteUsers(uids);

    // Authenticationユーザをインポート
    await admin.auth().importUsers(usersInImportUsersArgFormat, {
      hash: {
        algorithm: 'SCRYPT',
        key: Buffer.from('base64-secret', 'base64'),
        saltSeparator: Buffer.from(`base64SaltSeparator`, 'base64'),
        rounds: 8,
        memoryCost: 14,
      },
    });
  });

/**
 * Authenticationユーザ情報(object)のフォーマットを変換
 */
function convertToImportUsersArgFormat(
  usersInCLIExportedFormat: CLIExportedFormat[]
): admin.auth.UserImportRecord[] {
  const usersInImportUsersArgFormat: admin.auth.UserImportRecord[] = usersInCLIExportedFormat.map(
    (userInCLIExportedFormat: CLIExportedFormat) => {
      const userInImportUsersArgFormat: admin.auth.UserImportRecord = {
        uid: userInCLIExportedFormat.localId,
        email: userInCLIExportedFormat.email,
        emailVerified: userInCLIExportedFormat.emailVerified,
        passwordHash: Buffer.from(userInCLIExportedFormat.passwordHash, 'base64'),
        passwordSalt: Buffer.from(userInCLIExportedFormat.salt, 'base64'),
        disabled: userInCLIExportedFormat.disabled,
      };
      // displayNameがある場合は設定
      if (typeof userInCLIExportedFormat.displayName !== 'undefined') {
        userInImportUsersArgFormat.displayName = userInCLIExportedFormat.displayName;
      }
      // lastSignedInAtがある場合はmetadataのlastSignInTimeに設定
      // ※エポックミリ秒の文字列をUTCの文字列に変換して設定する必要がある
      if (typeof userInCLIExportedFormat.lastSignedInAt !== 'undefined') {
        userInImportUsersArgFormat.metadata = {
          creationTime: new Date(Number(userInCLIExportedFormat.createdAt)).toUTCString(),
          lastSignInTime: new Date(Number(userInCLIExportedFormat.lastSignedInAt)).toUTCString(),
        };
      } else {
        userInImportUsersArgFormat.metadata = {
          creationTime: new Date(Number(userInCLIExportedFormat.createdAt)).toUTCString(),
        };
      }
      // customAttributesがある場合はcustomClaimsに設定
      // ※objectに変換して設定する必要がある
      if (typeof userInCLIExportedFormat.customAttributes !== 'undefined') {
        userInImportUsersArgFormat.customClaims = JSON.parse(
          userInCLIExportedFormat.customAttributes
        );
      }
      return userInImportUsersArgFormat;
    }
  );
  return usersInImportUsersArgFormat;
}

苦労したこと

・readStreamの取り扱い:非同期処理がよくわからず。。。
・importUsers()の引数の型調査:ファイルをほじくり倒しました。少しでも英語が読めてよかった。。。

結び

FirebaseAuthをバックアップ&リストアできるようになりました。
Google社には、もっと簡単に実行できる仕組みを用意してもらいたいですね。。。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0