LoginSignup
18
15

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-07-21

概要

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

JWT(JsonWebToken)とは

OAuth2準拠のアクセストークンの一種で、APIのエンドポイントに対して誰もがアクセスできないように保護します。

・基礎的な知識

・JWTの動作

・JWT公式

アーキテクチャ

下図は、作成したシステムのアーキテクチャです。

アーキテクチャ.png
※ 点線矢印はデプロイ先

アプリケーション

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

システムのフロー

① あらかじめ、Firebase Authentication(Firebase Auth)にユーザーを登録します。このサービスでは、EmailとPasswordによる登録をしました。
JWT発行アプリで、サインインします。サインインできるのは、①でFirebase Authに登録しているユーザーのみです。サインインをすると、ユーザーのUIDDisplaynameをFirebase Authから受け取ります。
③ JWTの発行を行います。発行を行うAPIサーバーに対して、発行に必要な情報として、UIDDisplaynameと、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の最小構成です。

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を使うことで、ダッシュボードでわかりやすく表示されます。

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

無題.png

デプロイ

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

npm run deploy

JWT の組み込み

以下を参考に、JWTを作成しました。

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の作成には、payloadsecretoptionsを使います。
payloadはFirebase Authから取得したUID、Displaynameを使用します。secretは任意の固有の値を与えます。optionsは暗号化のalgorithmと有効期限(expiresIn)を設定します。

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

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になるように指定します。

.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を元に降順(新しい順)で取得しています。

成果物

JWT発行アプリ

APIサーバー

API Requestアプリケーション

まとめ

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

18
15
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
18
15