【API】Firebase × Express で API を作成する。 JWT 保護もする。

<!-- =============================================== -->
# 概要
<!-- =============================================== -->
Node.js + ExpressでAPIを作成し、それをFirebase Functionsにデプロイします。
また、APIのエンドポイントは、JWTで保護します。

<!-- =============================================== -->
# JWT（JsonWebToken）とは
<!-- =============================================== -->
OAuth2準拠のアクセストークンの一種で、APIのエンドポイントに対して誰もがアクセスできないように保護します。

・基礎的な知識

https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be

・JWTの動作

https://qiita.com/Naoto9282/items/8427918564400968bd2b

・JWT公式

https://jwt.io/

<!-- =============================================== -->
# アーキテクチャ
<!-- =============================================== -->
下図は、作成したシステムのアーキテクチャです。

![アーキテクチャ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/059d7d29-fa29-3639-08a2-968f441e04ab.png)
※ 点線矢印はデプロイ先

### アプリケーション

- <font color="Red">JWT発行アプリ</font>：誰でもJWTを発行できては保護の意味がないので、サインイン機能も持たせます。
- <font color="Blue">APIサーバー</font>：ユーザーからのリクエストを受け付けます。JWTの発行や、リクエストに含まれるJWTの認証も行います。
- <font color="ForestGreen">APIを利用するアプリ</font>：実装時には、Postmanを使って検証しました。

### システムのフロー

① あらかじめ、Firebase Authentication（Firebase Auth）にユーザーを登録します。このサービスでは、EmailとPasswordによる登録をしました。
② <font color="Red">JWT発行アプリ</font>で、サインインします。サインインできるのは、①でFirebase Authに登録しているユーザーのみです。サインインをすると、ユーザーの**UID**と**Displayname**をFirebase Authから受け取ります。
③ JWTの発行を行います。発行を行う<font color="Blue">APIサーバー</font>に対して、発行に必要な情報として、**UID**、**Displayname**と、**Expiration（JWTの有効期限）**を渡します。
④ <font color="Blue">APIサーバー</font>に、APIリクエストを投げます。このとき、リクエストのHeadersに、Authorizationとして③で取得したJWTを含めます。
⑤ <font color="Blue">APIサーバー</font>で、JWT認証を行います。また2段階認証として、Firebase AuthのDisplaynameとJWTに含まれるDisplaynameが一致するか確認します。これによって、`万が一有効期限内にJWTが流出した場合でも、Displaynameを変更すればJWTを無効にできます。`
⑥ JWTから取得したUIDを利用して、Firestoreに対してユーザーに紐づいたデータを追加したり、取得したりします。

<!-- =============================================== -->
# API の骨格作成
<!-- =============================================== -->
### プロジェクトの作成
Firebaseでプロジェクトを作成します。[Firebaseプロジェクトの作成](https://qiita.com/nemutas/items/330cab413bccd9b2e096#firebase%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90) を参考にしてください。

ローカルプロジェクトの初期設定は、[こちら](https://qiita.com/nemutas/items/2e97e69f637e912e7f9b#hooks) を参考にしてください。

### パッケージ インストール
以下をインストールします。

```
npm i express jsonwebtoken cors dotenv
npm i -D @types/node @types/express @types/jsonwebtoken @types/cors
```

- Express

https://expressjs.com/ja/

- jsonwebtoken

https://github.com/auth0/node-jsonwebtoken

- cors

https://github.com/expressjs/cors

- dotenv

https://github.com/motdotla/dotenv

### 最小構成
Expressを使ったAPIの最小構成です。

```functions/src/index.ts
import cors from 'cors';
import express from 'express';
import * as functions from 'firebase-functions';

const app = express();
// jsonデータを扱う
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// corsの許可
app.use(cors());

// テスト用のエンドポイント
app.get('/hello', (req, res) => {
	functions.logger.info('hello!');
	res.status(200).send({ message: 'hello, api sever!' });
});

// サーバー接続
// const port = process.env.PORT || 3001;
// app.listen(port, () => {
// 	console.log('listen on port:', port);
// });

// exportすることで、/apiとしてFirebase Functionsに登録される
export const api = functions.region('asia-northeast1').https.onRequest(app);
```

#### サーバー接続の記述

Firebase Functionsを利用する場合は、**functions.https.onRequest**でサーバー接続させます。

ローカル環境で実行すると、以下のようなURLでlocalhostに接続されます。

```
npm run serve

http://localhost:5001/<project-name>/asia-northeast1/api
```

ローカルサーバーに接続している状態で、以下にアクセスすることで`hello, api sever!`が得られます。

```
http://localhost:5001/<project-name>/asia-northeast1/api/hello
```

#### ログの表示
Firebase Functionsでは、ダッシュボードでログを監視できます。console.logではなく**functions.logger**を使うことで、ダッシュボードでわかりやすく表示されます。

```.ts:
// console.log('hello!')
functions.logger.info('hello!');
```

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/4db7cd8f-0af3-b0d8-ac8e-94718f79dab3.png)

### デプロイ
以下を実行してデプロイします。デプロイには3～5分くらい時間がかかります。

```
npm run deploy
```

<!-- =============================================== -->
# JWT の組み込み
<!-- =============================================== -->
以下を参考に、JWTを作成しました。

https://nodejs.keicode.com/nodejs/express-jwtauth.php


### JWT 作成
アーキテクチャの③の処理に該当します。

```functions/src/auth.ts
type JWTPayloadType = {
	uid: string;
	name: string;
	expiresIn?: string;
};

export const createJWT: RequestHandler = (req, res) => {
	const { uid, name, expiresIn = '1m' } = req.body as JWTPayloadType;
	// console.log({ uid, name, expiresIn });
	functions.logger.info('Create JWT', { Payload: { uid, name, expiresIn } });

	if (uid && name) {
		const payload = { uid, name };
		const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn });
		res.status(200).json({ message: 'create jwt', jwt: token });
	} else {
		res.status(400).send({ error: 'Payloadが指定されていません。' });
	}
};
```
JWTの作成には、**payload**、**secret**、**options**を使います。
payloadはFirebase Authから取得したUID、Displaynameを使用します。secretは任意の固有の値を与えます。optionsは暗号化のalgorithmと有効期限（expiresIn）を設定します。

有効期限の指定方法について、例えば、`60s（60秒）`、`5m（5分）`、`1h（1時間）`、`30d（30日）`のように指定します。

https://github.com/auth0/node-jsonwebtoken

### JWT 認証
アーキテクチャの④⑤の処理に該当します。

```:Headers
Authorization: Bearar <JWT>
```
リクエストのHeadersに含まれたJWTを取得して、認証します。

```functions/src/auth.ts
export const authenticateWithJWT: RequestHandler = (req, res, next) => {
	const tokenViaBaerer = req.headers.authorization;
	if (tokenViaBaerer) {
		try {
			const token = tokenViaBaerer.split(' ')[1];
			const jwtPayload = jwt.verify(token, SECRET);
			req.jwtPayload = jwtPayload as JWTPayloadType;
			next();
		} catch (error) {
			res.status(401).send({ error: '認証に失敗しました。' });
		}
	} else {
		res.status(403).send({ error: 'tokenが指定されていません。' });
	}
};
```
認証に成功すると、返り値としてJWTを作成したときに使用したpayload（UID、Displayname）が返ってきます。それをrequestパラメーターとして設定します。
最後に、`next()`で次のミドルウェア関数を呼び出します。

### Firebase Authentication を使った認証
アーキテクチャの⑤の処理に該当します。

```functions/src/auth.ts
export const authenticateWithFirebase: RequestHandler = async (req, res, next) => {
	const payload = req.jwtPayload;
	if (payload) {
		try {
			// firebaseのauth情報と照合する
			const user = await firebaseApp.auth().getUser(payload.uid);
			if (user.displayName && user.displayName === payload.name) {
				next();
			} else {
				res.status(401).send({ error: '認証に失敗しました。' });
			}
		} catch (error) {
			res.status(401).send({ error: '認証に失敗しました。' });
		}
	} else {
		res.status(403).send({ error: 'アクセス権限がありません。' });
	}
};
```
JWTが有効期限内に漏洩した場合の対策として、Firebase Authを使用してDisplaynameで整合性を取ります。
この処理は、JWTの認証で得られたPayloadを使用するため、JWT認証のあとに呼び出します。

### 認証処理の利用
アーキテクチャの④の処理に該当します。

```functions/src/index.ts
// appに直接ルーティングする場合
app.get(
	'/jwt/check',
	[authenticateWithJWT, authenticateWithFirebase],
	(req: Request, res: Response) => {
		res.status(200).json({ message: '認証されました。' });
	}
);

// appにrouterを使ってルーティングする場合
app.use('/v1/todo', [authenticateWithJWT, authenticateWithFirebase], todoRouter);
```
今回は、本処理の前に認証処理を2つ呼びたいので、配列で指定します。
このとき順番が、`authenticateWithJWT → authenticateWithFirebase`になるように指定します。

https://expressjs.com/ja/guide/routing.html

### .env ファイル
**.env**ファイルには、JWTで使うsecret値や、Firebaseの情報を記載します。Node.jsで.envファイルを扱うためには、dotenvパッケージを利用します。

```functions/src/env.ts
import * as dotenv from 'dotenv';

dotenv.config();

export const SECRET = process.env.JWT_SECRET!;
```

<!-- =============================================== -->
# Firebase の処理
<!-- =============================================== -->
### 初期化
サーバーサイドでfirebaseを扱う場合、firebase-adminを利用します。

```functions/src/firebase.ts
import admin from 'firebase-admin';

export const firebaseApp = admin.initializeApp();
```

### Firestore 処理

今回は、Firestoreを利用して、UIDに紐づくTODOを管理するためのAPIを作成しました。

```functinos/src/types.ts
export type TodoType = {
	title: string;
	description: string;
	completed: boolean;
};
```
Todoは、タイトル、概要、完了状態をパラメーターに持ちます。

```functinos/src/firebaseFunc.ts
import admin from 'firebase-admin';
import { firebaseApp } from '../firebase';
import { TodoType } from './types';

const db = firebaseApp.firestore();
const FieldValue = admin.firestore.FieldValue;
const rootDoc = db.collection('v1').doc('todo');

export const getDBTodos = async (uid: string) => {
	// updated_atでdesc（降順）で取得する
	const userDocument = await rootDoc.collection(uid).orderBy('updated_at', 'desc').get();

	return userDocument.docs.map(doc => ({
		id: doc.id,
		...doc.data(),
		created_at: new Date(doc.data().created_at.toDate()).toLocaleString(),
		updated_at: new Date(doc.data().updated_at.toDate()).toLocaleString()
	}));
};

export const postDBTodo = async (uid: string, todo: TodoType) => {
	// addを使うとdocument idは、ユニークなidとして自動採番される
	await rootDoc.collection(uid).add({
		...todo,
		created_at: FieldValue.serverTimestamp(),
		updated_at: FieldValue.serverTimestamp()
	});
};

export const putDBTodo = async (uid: string, docId: string, todo: TodoType) => {
	await rootDoc
		.collection(uid)
		.doc(docId)
		.update({
			...todo,
			updated_at: FieldValue.serverTimestamp()
		});
};

export const deleteDBTodo = async (uid: string, docId: string) => {
	await rootDoc.collection(uid).doc(docId).delete();
};
```
データベースに登録するデータには、Todoの内容に作成日（created_at）と更新日（updated_at）を加えています。
`getDBTodos`では、updated_atを元に降順（新しい順）で取得しています。

https://firebase.google.com/docs/firestore/query-data/order-limit-data?hl=ja


<!-- =============================================== -->
# 成果物
<!-- =============================================== -->
JWT発行アプリ

https://github.com/nemutas/jwt-create-app

APIサーバー

https://github.com/nemutas/jwt-api-with-firebase

API Requestアプリケーション

https://github.com/nemutas/jwt-request-app

<!-- =============================================== -->
# まとめ
<!-- =============================================== -->
Firebaseの機能をもっと使いこなせれば、JWTを自前で作る必要はないのかもしれません。（この辺は知識がまだ浅いです）
ただ、システムを構築するときに、FirebaseやAWSなどのサービスに依存させすぎると、他のサービスへの移植が必要になったときに手間がかかるのかなーとか思う今日この頃です。