お断り
本記事にもとづいて、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を使う際に必要となる模様)。
バックアップの流れは以下の通りである。
- Firebase CLIのAPIを通して、JSON形式でFirebaseAuthをエクスポートする。
- Cloud KMSのAPIを通して、FirebaseAuthのJSONファイルを暗号化する。
- GCSのAPIを通して、暗号化されたファイルをバケットに保存する。
※your-xxx
の計5か所は、FirebaseおよびCloud KMSから実際の値を確認して置換すること。
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を使う際に必要となる模様)。
リストアの流れは以下の通りである。
- GCSのAPIを通して、暗号化されたファイルを読み込む。
- Cloud KMSのAPIを通して、暗号化された情報を復号する。
- 復号された情報からオブジェクト型配列としてユーザ情報を取得する。
- Firebase Admin SDKのimportUsersのAPIに合わせて、ユーザ情報のオブジェクトの形式(プロパティ構成)を変換する。
- Firebase Admin SDKのAPIを通して、既存のユーザ情報を削除する。
- 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-secret
とbase64SaltSeparator
は、FirebaseおよびCloud KMSから実際の値を確認して置換すること。
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社には、もっと簡単に実行できる仕組みを用意してもらいたいですね。。。