概要
Node.js + ExpressでAPIを作成し、それをFirebase Functionsにデプロイします。
また、APIのエンドポイントは、JWTで保護します。
JWT(JsonWebToken)とは
OAuth2準拠のアクセストークンの一種で、APIのエンドポイントに対して誰もがアクセスできないように保護します。
・基礎的な知識
・JWTの動作
・JWT公式
アーキテクチャ
下図は、作成したシステムのアーキテクチャです。
アプリケーション
- JWT発行アプリ:誰でもJWTを発行できては保護の意味がないので、サインイン機能も持たせます。
- APIサーバー:ユーザーからのリクエストを受け付けます。JWTの発行や、リクエストに含まれるJWTの認証も行います。
- APIを利用するアプリ:実装時には、Postmanを使って検証しました。
システムのフロー
① あらかじめ、Firebase Authentication(Firebase Auth)にユーザーを登録します。このサービスでは、EmailとPasswordによる登録をしました。
② JWT発行アプリで、サインインします。サインインできるのは、①でFirebase Authに登録しているユーザーのみです。サインインをすると、ユーザーのUIDとDisplaynameをFirebase Authから受け取ります。
③ JWTの発行を行います。発行を行うAPIサーバーに対して、発行に必要な情報として、UID、Displaynameと、**Expiration(JWTの有効期限)**を渡します。
④ APIサーバーに、APIリクエストを投げます。このとき、リクエストのHeadersに、Authorizationとして③で取得したJWTを含めます。
⑤ APIサーバーで、JWT認証を行います。また2段階認証として、Firebase AuthのDisplaynameとJWTに含まれるDisplaynameが一致するか確認します。これによって、万が一有効期限内にJWTが流出した場合でも、Displaynameを変更すればJWTを無効にできます。
⑥ JWTから取得したUIDを利用して、Firestoreに対してユーザーに紐づいたデータを追加したり、取得したりします。
API の骨格作成
プロジェクトの作成
Firebaseでプロジェクトを作成します。Firebaseプロジェクトの作成 を参考にしてください。
ローカルプロジェクトの初期設定は、こちら を参考にしてください。
パッケージ インストール
以下をインストールします。
npm i express jsonwebtoken cors dotenv
npm i -D @types/node @types/express @types/jsonwebtoken @types/cors
- Express
- jsonwebtoken
- cors
- dotenv
最小構成
Expressを使ったAPIの最小構成です。
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を使うことで、ダッシュボードでわかりやすく表示されます。
// console.log('hello!')
functions.logger.info('hello!');
デプロイ
以下を実行してデプロイします。デプロイには3~5分くらい時間がかかります。
npm run deploy
JWT の組み込み
以下を参考に、JWTを作成しました。
JWT 作成
アーキテクチャの③の処理に該当します。
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日)
のように指定します。
JWT 認証
アーキテクチャの④⑤の処理に該当します。
Authorization: Bearar <JWT>
リクエストのHeadersに含まれたJWTを取得して、認証します。
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 を使った認証
アーキテクチャの⑤の処理に該当します。
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認証のあとに呼び出します。
認証処理の利用
アーキテクチャの④の処理に該当します。
// 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
になるように指定します。
.env ファイル
.envファイルには、JWTで使うsecret値や、Firebaseの情報を記載します。Node.jsで.envファイルを扱うためには、dotenvパッケージを利用します。
import * as dotenv from 'dotenv';
dotenv.config();
export const SECRET = process.env.JWT_SECRET!;
Firebase の処理
初期化
サーバーサイドでfirebaseを扱う場合、firebase-adminを利用します。
import admin from 'firebase-admin';
export const firebaseApp = admin.initializeApp();
Firestore 処理
今回は、Firestoreを利用して、UIDに紐づくTODOを管理するためのAPIを作成しました。
export type TodoType = {
title: string;
description: string;
completed: boolean;
};
Todoは、タイトル、概要、完了状態をパラメーターに持ちます。
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を元に降順(新しい順)で取得しています。
成果物
JWT発行アプリ
APIサーバー
API Requestアプリケーション
まとめ
Firebaseの機能をもっと使いこなせれば、JWTを自前で作る必要はないのかもしれません。(この辺は知識がまだ浅いです)
ただ、システムを構築するときに、FirebaseやAWSなどのサービスに依存させすぎると、他のサービスへの移植が必要になったときに手間がかかるのかなーとか思う今日この頃です。