FirebaseAuthの認証をFlaskでやる

# はじめに

この記事では，PythonでFlaskを使ったREST API（風味）を作ります
また，Firebase Authenticationを使いユーザー認証をさせます

Flaskについてがメインなので，この記事でFirebase関連にはそんなに触れないです


Nihon University Advent Calendar 2023の16日目の記事となっています．ほかの記事もご覧いただければ幸いです

https://qiita.com/advent-calendar/2023/nihon-u


また，似たようなことをやっている記事もあったので，参考に置いておきます

https://medium.com/nyle-engineering-blog/flask%E3%81%A7rest-api%E3%82%92%E4%BD%9C%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B-fad8ae1fde5c


# 何を作る？

自分用のメモを投下できるアプリを作っていきます．名付けて「$\mathbb{Y}$ memo」です
~~注：実在するサービス，アプリとは一切関係ないオリジナルのサービスです．~~

最初にコードみたいって人はは以下のリポジトリを見てください
参考用に段階ごとにブランチを分けてみました．とはいえ途中コミットしたの忘れてて別の段階のやつがはいっちゃったりしてますが．

https://github.com/itochs/FlaskAppExample

# 環境

## client側

- React
- Vite
- Firebase
    - Authentication

Bunが速いらしいので使ってみます


https://bun.sh/docs


（他の方がBunの記事書いてるので見てってください！）

formatterはprettier，linterはeslintです．
Biomeも気になっていますが．Bun + Vite のテンプレートがprettierとeslintだったのでそのままです

## server側

- Flask
- Firebase Admin SDK

Pythonの環境はRyeを使って管理していきます．理由はRustって流行ってるじゃん？ぐらいです（適当）．

https://rye-up.com/

Linterはruffを使っていきます．理由はRustって流行って以下略

https://github.com/astral-sh/ruff

VSCodeの設定は以下の記事を参考にしました．

https://zenn.dev/enven/articles/python-ruff-with-vscode

では，次からアプリ作っていきます

# 必要そうな機能

自分用のメモを投下できるアプリにおいて必要そうな機能を考えていきます
（あんまりよくないとは思ってますが見切り発車でやるタイプなので雑です）

client側

- メモを書く
- 自分のメモを見る

server側
- メモを保存する
- ユーザーごとのメモを渡す

最低限はこんなもんということにしておきます

server側はREST APIとして作るので，そちらも考えます

## REST APIへの操作

server側に必要なのは，メモの保存とユーザーの区別でした
なのでそれらに対応するような操作を考えます

REST APIへの操作は以下のようにしてみます

| URI | GET | POST | 
|:-:|:-:|:-:|
| `/users/user_id/memos` | 特定のユーザーのメモの一覧を取得 | メモを追加 |
| `/memos` | 全てのメモの一覧を取得 |   |
| `/memos/memo_id` | 特定のメモを取得 |   | 

REST APIの設計と表は以下を参考にしました．

https://learn.microsoft.com/ja-jp/azure/architecture/best-practices/api-design


さて，データの保存にはデータベースを使うという認識が私の中にあります．
しかし面倒なので，全てJSON形式のフォルダをローカルに作成されるようにしていきます．

# とりあえずメモできるようにする

まずはclient側の基礎を作ります
できたものがこちらです


https://github.com/itochs/FlaskAppExample/tree/base

外観はこちら

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/74aabf9a-d418-01e5-22f7-0ffde16703f8.png)

最低限メモができて表示されてるのでOKです
リロードしたらメモしたものはすべて消えるので，メモをserver側に送って保存してもらいたいですね


# メモを保存

https://github.com/itochs/FlaskAppExample/tree/server-save-memo

メモの保存に対応するREST APIへの操作は`/users/user_id/memos`ですが，今は一旦ユーザーについては考えたくないので，`/memos`に対しての`POST`で保存するようにします
見切り発車の効果が出てきましたかね

## 基礎を作る

### Flaskのインストール

とりあえずFlaskをインストールして作っていきましょう
バージョンが違う方のサイトもあるので注意しましょう

https://flask.palletsprojects.com/en/3.0.x/installation/

### Flaskの使い方

Flaskの基本的な使い方は以下のquickstartを見るのが早いと思います

https://flask.palletsprojects.com/en/3.0.x/quickstart/#a-minimal-application

自分のイメージは

- Flaskクラスのインスタンスがサーバー本体
- そのインスタンスのデコレータである`route`の引数にURLを入れる
    - デコレートする（？）関数が，URLにアクセスしたときの処理

という感じです

サーバーの起動は`--app`オプションでFlaskクラスのインスタンスを作ったPythonファイルを指定します
`flask run --app ファイルパス`とコマンドを打てば起動します
ファイル名を`app.py`とすれば`--app`オプションはいらなくなります[^1]

開発環境では`--debug`オプションを付けておくと便利です．変更を加えると勝手にリロードされて変更が読み込まれます．
というか逆につけないと変更が適用されず，いちいち起動しなおさないといけなくなります（1敗）

[^1]: 見つけてくれた`app.py`の場所は試した感じ，作業ディレクトリ直下，`src`という名前のディレクトリ内だけでした．`src/server`みたいな2階層下だと認識してくれませんでした．どっかにちゃんと書いてたりするんですかね．

### `/memos`にPOSTする準備

メモを保存するための準備です．

#### POSTデータの形式

JSON形式で，キーは`memo`，値は文字列の配列とします
拡張しやすいようにこうしておきます

#### JSONファイルを作っておく
`memos.json`という名前にして，`db`ディレクトリの中に入れました．
このファイルにメモを保存します．

#### メモを保存する関数の作成
`memos.json`を読み込んでメモを追加して書き込む，という手段でメモを保存します
参考

https://qiita.com/eg_i_eg/items/aff02f6057b476cb15fa


また，メモの区別としてuuidを生成してidとして入れます


https://docs.python.org/ja/3/library/uuid.html

ファイルパスは他の場所でも使いそうだったので定数をまとめる場所を作りました
`util`という場所に`constant.py`というファイル作ってそこに定義しておきました

```python: util/constant.py
MEMO_FILE = "./src/db/memos.json"
```


それと，`app.py`内に書くと煩雑になりそうな予感がしたので，メモに関する処理をするディレクトリを作ってそこに入れてます（`src/memos/save_memo.py`）

`memos.json`に何も書かれてないのに読み込もうとしたら`json.decoder.JSONDecodeError`が出たので，そこで空の配列を入れるようにしておきました


書き込みが無事終了したら生成したuuidを，書き込まなかったらNoneを返すようにしています



```python: save_memo.py
import json
import uuid

from util import constant


def save_memo(memo: list[str]) -> bool:
    if not memo or len(memo) == 0:
        return None

    memos = None
    id = str(uuid.uuid4())
    with open(constant.MEMO_FILE, "r") as f:
        try:
            memos = json.load(f)
        except json.decoder.JSONDecodeError:
            memos = []
        memos.append({"memo": memo, "id": id})
    with open(constant.MEMO_FILE, "w") as f:
        json.dump(memos, f, indent=2, ensure_ascii=False)

    return id
```


#### POSTされたデータの取得

flaskに`request`というオブジェクトがあり，そこからPOSTされたデータが取得できます．
JSON形式なので，`request.get_json()`とするとデータが取得可能です．
`flask`からimportしましょう

あとは取得したメモを`save_memo`に突っ込んで，保存の成否でレスポンスを変えれば出来上がりです．

```python: app.py
from flask import Flask, request
from memos.save_memo import save_memo

app = Flask(__name__)


@app.route("/memos", methods=["POST"])
def index():
    req: dict = request.get_json()
    memo = req.get("memo")
    id = save_memo(memo)
    if id:
        return {"id": id}, 200
    else:
        return {"message": "memo have no content"}, 400

```

### `/memos`にPOSTしてみる

`curl`を使えばPOSTできるので試してみましょう．メモの保存ができてると思います

```sh: post_memo.sh
curl -X POST -H "Content-Type: application/json" -d '{"memo":"'$1'"}' http://127.0.0.1:5000/memos
```

## client側からPOST

POSTしたらメモが保存されることがわかったので，client側から操作してみましょう．
fetchでURL書いてPOSTを指定すればいいだけと思いきや，`http://127.0.0.1:5000`だと`No 'Access-Control-Allow-Origin' header`のCORSエラーが発生します．

回避方法はviteのconfigでproxyの設定すればいいらしいんですけどあんまりわかっていません．
ドキュメント読んでください．

https://ja.vitejs.dev/config/server-options.html#server-proxy


とりあえず以下のようにして，`/api/memos`に対してfetchすればなんとかなります

```javascript: vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": {
        target: "http://127.0.0.1:5000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});
```

フォームを送信したときに実行する関数でfetchさせて動作確認しましょう．
```javascript
// ...前略

  function onMemoSubmit(e) {
    e.preventDefault();
    if (!postMemoRef.current) {
      return;
    }

    const memo_content = postMemoRef.current.value.trim();
    postMemoRef.current.value = "";
    if (memo_content === "") {
      return;
    }

    const parsed_memo_content = memo_content
      .split("\n")
      .filter((line) => line !== "");
    setMemos([...memos, parsed_memo_content]);

    fetch("/api/memos", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ memo: parsed_memo_content }),
    });
  }
  
// ...後略
```

以下のような感じになってたらうれしいです
```json: memos.json
[
  {
    "memo": "test",
    "id": "233f8524-9a13-4a3c-9043-a6a4ff69c863"
  },
  {
    "memo": [
      "すももももももももっももももも"
    ],
    "id": "8060f730-3ad4-4ea9-878d-9502e4f9ac6b"
  }
]
```

# メモを取得

続いてメモを取得していきます．

## server側

まずは，すべてのメモを取得，およびある一つのメモの取得するための処理を作っていきます．

すべてのメモの取得はJSON読み込めばいいですし，個別のメモもidを参照すれば特定できます．

```python: get_memo.py
import json

from util import constant


def get_all_memos():
    memos = []
    with open(constant.MEMO_FILE, "r") as f:
        try:
            memos = json.load(f)
        except json.decoder.JSONDecodeError:
            memos = []
    return memos


def get_memo(memo_id: str):
    memos = get_all_memos()
    memo = next(filter(lambda m: m["id"] == memo_id, memos), None)
    return memo
```

### ルートの設定

すべてのメモの取得は`/memos`に対して`GET`，個別のメモの取得は`/memos/memo_id`に対して`GET`でした
なのでこれにそって作っていきます

レスポンスを返す際は，ヘッダーの情報をflaskがいい感じに入れてくれるようにするために`Response`オブジェクトにして返してあげるのがいいんじゃないかなと思います．（[ドキュメント](https://flask.palletsprojects.com/en/3.0.x/quickstart/#about-responses)）

https://flask.palletsprojects.com/en/3.0.x/api/#flask.Response



今回使うのは，flaskの[`jsonify`関数](https://flask.palletsprojects.com/en/3.0.x/api/#flask.json.jsonify)です．JSONでの応答してくれます．`make_response`でもいいのですが，JSON返してる感があるのでこっち使います

とりあえず実装したものが以下のものです


```python: app.py
from flask import Flask, jsonify, request
from memos.get_memo import get_all_memos as get_all_memos_from_db
from memos.get_memo import get_memo as get_memo_from_db


@app.route("/memos", methods=["GET"])
def get_all_memos():
    all_memo = get_all_memos_from_db()
    return jsonify(all_memo)


@app.route("/memos/<id>", methods=["GET"])
def get_memo(id: str):
    memo = get_memo_from_db()
    if memo is None:
        return {"message": "memo not found"}, 404

    return jsonify(memo)
```

ここで`"/memos/<uuid:id>"`についてです．

> You can add variable sections to a URL by marking sections with <variable_name>. Your function then receives the <variable_name> as a keyword argument. Optionally, you can use a converter to specify the type of the argument like <converter:variable_name>.

> https://flask.palletsprojects.com/en/3.0.x/quickstart/#variable-rules

というわけでURLをいい感じに値を入れられます．その際に型も指定できます．
指定できる型は`string` `int` `float` `path` `uuid`のようです．

https://flask.palletsprojects.com/en/3.0.x/quickstart/#variable-rules

メモの区別はuuidなのでuuidを指定~~します~~したいところですが，JSONで保存してるので型はstrです．

これでメモの取得ができるので，`http://127.0.0.1:5000/memos`とかに接続して試してみてください．


## client側

https://github.com/itochs/FlaskAppExample/tree/client-save-memo

client側で注意すること2つです．
- 取得したメモはobjectの配列になっていること
- メモを作るとserver側からidが返ってくること

server側からidが返ってくるやつは，そこでもう一度すべてのメモを取得するっていうのでも一応できます．

後は気合で何とかしてください

# ユーザー認証

https://github.com/itochs/FlaskAppExample/tree/server-id-token-verify

さて，ユーザーごとに区別してメモを保存していきましょう

最近使ってるっていう理由で，firebaseで認証します

https://firebase.google.com/docs/web/setup?hl=ja#add-sdk-and-initialize


google認証を使います

https://firebase.google.com/docs/auth/web/google-signin?hl=ja

ちなみに
> Firebase サービスの API キーは非公開ではない
> https://firebase.google.com/support/guides/security-checklist?hl=ja#understand_api_keys

らしいですね

## ユーザーの区別

> Firebase クライアント アプリがカスタム バックエンド サーバーと通信する場合、そのサーバーに現在ログインしているユーザーを特定する必要が生じる場合があります。これを安全に行うために、正常なログイン後、ユーザーの ID トークンを HTTPS を使ってサーバーに送信します。次に、サーバー上で ID トークンの完全性と信頼度を確認し、ID トークンの uid を取得します。サーバーで現在ログインしているユーザーを安全に特定するために、この方法で送信された uid を使用できます。

https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja

というわけで，firebase authではIDトークンを検証てログインしている人を特定します


### IDトークン検証のためのデコレータ作成

client側でfetchするときにAuthorization headerにIDトークンを載せてserver側に送信し，server側でそのIDトークンの人を検証し特定します．

この辺の処理をスマートにやっている記事がありますので以下を参照してください．こちらはJWT認証なので，その認証している部分をfirebaseの認証に切り替えます
`firebase-admin`をインストールする必要があります


https://zenn.dev/oreilly_ota/articles/1e1cad2451391d

デコレータやfunctiollsのwrapsに関しては上の記事に加え，以下の記事を参考にしました
- https://qiita.com/kRysTasis/items/6d30c6cc73c7ec9d603f
- https://zenn.dev/ryo_kawamata/articles/learn_decorator_in_python
- https://qiita.com/kangetsu121/items/039b1c236f735a14c472


そのデコレータを作って適用させてあげると，第1引数にユーザーIDが入るようになります．
最終的に出来上がったものが以下のものです．

<details>

<summary>verify.py</summary>

```python
from functools import wraps

from firebase_admin import auth
from flask import request


class AuthError(Exception):
    pass


def get_token_auth_header():
    auth = request.headers.get("Authorization", None)
    if auth is None:
        raise AuthError("Authorization header is expected")
    auth_parts = auth.split()
    if auth_parts[0].lower() != "bearer":
        raise AuthError("Authorization header must start with Bearer")
    elif len(auth_parts) == 1:
        raise AuthError("Token not found")
    elif len(auth_parts) > 2:
        raise AuthError("Authorization header must be bearer token")

    token = auth_parts[1]
    return token


def verify_token(token):
    try:
        decoded_token = auth.verify_id_token(token)
        return decoded_token
    except Exception:
        raise AuthError("Invalid token")


def require_auth(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        try:
            token = get_token_auth_header()
            decoded_token = verify_token(token)
        except AuthError as e:
            return {"message": str(e)}, 401
        except Exception as e:
            return {"message": f"Internal server error. {str(e)}"}, 500

        uid = decoded_token.get("uid")

        return f(uid, *args, **kwargs)

    return wrapper
```

</details>


### firebaseの認証
firebaseで認証するとき，Firebase Admin SDKを初期化する必要があります．
この記事ではアプリをデプロイしないので，Firebase Admin SDKを初期化するにはFirebase サービス アカウントの秘密鍵のパスを指定する必要があります．詳細は以下を見てください

https://firebase.google.com/docs/admin/setup?hl=ja#initialize_the_sdk_in_non-google_environments

秘密鍵を作成したら`GOOGLE_APPLICATION_CREDENTIALS`という環境変数にそのパスを指定してあげます
そしてちゃんと現在のシェルで環境変数に値が入っているか確認しましょう．ない場合は`export`で値を入れるなり，`source`で読み込むなりしてください

そして，`app.py`で初期化してあげれば，IDトークンの検証の下準備が完了です．


```python: app.py
from firebase_admin import initialize_app
from flask import Flask, jsonify, request
from memos.get_memo import get_all_memos as get_all_memos_from_db
from memos.get_memo import get_memo as get_memo_from_db
from memos.save_memo import save_memo
from util.verify import require_auth

app = Flask(__name__)
firebase_app = initialize_app()

# 略
```

## ユーザーごとのメモを取得

先ほど頑張って`user_id`を取得できるようになりました．
なので今度はユーザーごとのメモを取得を取得しましょう．
`/users/user_id/memos`に`GET`するとメモの一覧が取れるようにします．
~~`/memos`にユーザーの検証して`GET`すれば良くね？~~

まずはメモ自体に`user_id`の情報を入れます．メモを保存するときに入れましょう．
つまり`/memos`の`POST`に認証機能を付けます

```python: app.py
from util.verify import require_auth

app = Flask(__name__)
firebase_app = initialize_app()


@app.route("/memos", methods=["POST"])
@require_auth
def index(user_id: str):
    req: dict = request.get_json()
    memo = req.get("memo")
    id = save_memo(memo, user_id)
    if id:
        return {"id": id}, 200
    else:
        return {"message": "memo have no content"}, 400
```

```python: save_memo.py
import json
import uuid

from util import constant


def save_memo(memo: list[str], user_id: str) -> bool:
    if not memo or len(memo) == 0:
        return None

    memos = None
    id = str(uuid.uuid4())
    with open(constant.MEMO_FILE, "r") as f:
        try:
            memos = json.load(f)
        except json.decoder.JSONDecodeError:
            memos = []
        memos.append({"memo": memo, "id": id, "user_id": user_id})
    with open(constant.MEMO_FILE, "w") as f:
        json.dump(memos, f, indent=2, ensure_ascii=False)

    return id
```



あとはメモを取得する関数で`user_id`でのフィルタリングをしてルートを作りましょう．
あと忘れてましたが，メモの保存は`/users/<user_id>/memos`の`POST`でやるのでそこも修正します．

```python: get_memo.py
import json

from util import constant


def get_all_memos(user_id: str = None):
    memos = []
    with open(constant.MEMO_FILE, "r") as f:
        try:
            memos = json.load(f)
        except json.decoder.JSONDecodeError:
            memos = []

    if user_id is None:
        return memos

    user_memos = list(filter(lambda m: m["user_id"] == user_id, memos))
    return user_memos


def get_memo(memo_id: str, user_id: str = None):
    memos = get_all_memos(user_id)
    memo = next(filter(lambda m: m["id"] == memo_id, memos), None)
    return memo
```

```python: app.py
@app.route("/users/<user_id>/memos", methods=["GET"])
@require_auth
def get_user_memos(uid, user_id: str):
    if uid != user_id:
        return {"message": "Unauthorized"}, 401

    memos = get_all_memos_from_db(uid)

    return jsonify(memos)
```

## client側でデータ取得


https://github.com/itochs/FlaskAppExample/tree/client-firebase


client側では，サインインしていない状態だとすべてのメモを見れて，サインインしていると自分のだけ見れるようにします．
~~アプリとしては意味わからんですけど~~

まあなんかいい感じにサインインさせて`user`が切り替わるごとにfetchすればいいと思います

サインインしてない状態

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/dd6127ca-1898-0f4d-7c91-0d54f285391c.png)

サインインした状態

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/8159be97-0608-bf8f-a148-7f1832271236.png)


というわけで出来上がったものがこちらです．Chakra UI使いました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/be8b5537-39fe-dab5-fc42-315f3c36ddd6.png)


# 終わりに

この記事では，PythonでFlaskを使ったREST API（風味）を作りました
また，Firebase Authenticationを使いユーザー認証をデコレータを使っていい感じに認証させることができました

Flaskについてがメインっていったけどちょっと薄味になったかもしれないです．
ドタバタで作ったので変なこと言ってたりしたらごめんなさい

それはそれとして，BunとかRyeとか触れたので楽しかったです．

https://github.com/itochs/FlaskAppExample