【FastAPI+Firebase】Bearer認証を行うAPIサーバの構築

FastAPIは便利ですね。APIサーバを簡単に構築できるフレームワークとして個人的に愛用しています。今回はFastAPIに認証機能を追加します。

**注意** ：FastAPI, Firebase のインストールなどセットアップは前提としてここでは触れません。

# Bearer認証

サーバにリクエストしてきたユーザの識別・認証を行い、要求されたリソースへ適切な許可を制御したい需要は多々あると思います。ここではHTTPヘッダに追加するだけで簡単に実装できるBearer認証を実装します。

[[Qiita] "Bearer認証について"](https://qiita.com/h_tyokinuhata/items/ab8e0337085997be04b1)

> HTTPのAuthorizationヘッダにスキームとして指定でき, `Authorization: Bearer <token>` のようにして指定する.
> トークンの形式はtoken68の形式で指定することが定められている.

`token` の発行や検証など自分で実装するのは大変ですので、今回はFirebaseを利用します。

# 全体の構成

Firebaseを利用したBearer認証の全容を図示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/54b6ef41-f2bf-9722-0f42-9982a9c35605.png)


1. クライアントでAPIサーバへの問い合わせが発生  
   Firebaseに適切なユーザとしてログインして`token`を取得
2. クライアントは`token`を添えてHTTPでAPIサーバに投げる
3. サーバはHTTPヘッダの`token`をFirebaseに問い合わせて検証する  
    検証が成功すればユーザの識別・認証が完了
4. サーバは適切にリクエストに応答

# Firebase Admin SDK のセットアップ

Firebase Admin SDK を利用して認証機能を実装します

## 秘密鍵の準備
Firebaseのアカウントは事前に取得しておきましょう。まず[プロジェクトのコンソールを開きます](https://console.firebase.google.com/u/0/)

右上の歯車アイコンから設定を開きます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/2906191d-f61d-ca9d-2a07-e8a33cc82a6d.png)

「サービスアカウント」タブの最下部にあるボタンから秘密鍵をJSONファイルで取得します。ここでは`account_key.json`として保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/edd31389-08ea-381a-befa-74446d7c68f5.png)

## SDK の準備

```
$ pip install firebase_admin
```

# 実装

## APIエンドポイント
まず簡単なエンドポイントを用意して最低限のAPIサーバを構築します。

```python:main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/api/")
async def hello():
    return {"msg":"Hello, this is API server"}
```

`uvicorn` でテストサーバを立ててみます

```bash
$ uvicorn main:app --port 8001 --reload
```

試しにAPIサーバを叩いてみましょう（WebブラウザでもOK）

```bash
PS > curl http://localhost:8001/api
StatusCode        : 200
StatusDescription : OK
Content           : {"msg":"Hello, this is API server"}
RawContent        : HTTP/1.1 200 OK
                    Content-Length: 35
                    Content-Type: application/json
                    Date: Wed, 18 Nov 2020 11:11:20 GMT
                    Server: uvicorn

                    {"msg":"Hello, this is API server"}
```

## Bearer認証の追加

```python:user.py
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Depends, HTTPException, status, Response
from firebase_admin import auth, credentials
import firebase_admin

cred = credentials.Certificate('./account_key.json')
firebase_admin.initialize_app(cred)

def get_user(res: Response, cred: HTTPAuthorizationCredentials=Depends(HTTPBearer(auto_error=False))):
    if cred is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Bearer authentication required",
            headers={'WWW-Authenticate': 'Bearer realm="auth_required"'},
        )
    try:
        decoded_token = auth.verify_id_token(cred.credentials)
    except Exception as err:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Invalid authentication credentials. {err}",
            headers={'WWW-Authenticate': 'Bearer error="invalid_token"'},
        )
    res.headers['WWW-Authenticate'] = 'Bearer realm="auth_required"'
    return decoded_token
```

まずAuthorizationヘッダより`token`を取り出す必要があります。FastAPIではリクエストの認証ヘッダもValidationできるので便利ですね。

[[FastAPI] Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/)  
[[Qiita] PythonでFirebase Authenticationのトークン取得とFastAPIでトークン検証](https://qiita.com/DeliciousBar/items/d6845d329e37f21a6d4f)

次に`token`を検証します。上のコードでは`token`が存在しない、不正な場合の対応をそれぞれ定義しています。エラーに対するレスポンスの内容はBearer認証を定めたRFC 6750に準じます。`HTTPExeption`を投げるだけでFastAPIが勝手に拾ってレスポンスを生成してくれるので楽ちんです。

[Firebaseの公式ドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja#verify_id_tokens_using_the_firebase_admin_sdk)  
[Firebase Admin Python SDK](https://firebase.google.com/docs/reference/admin/python?h)  
[[Qiita] RFC 6750 に準拠した Bearer スキームを実装するには](https://qiita.com/uasi/items/cfb60588daa18c2ec6f5#rfc-6750-%E3%81%AB%E6%BA%96%E6%8B%A0%E3%81%97%E3%81%9F-bearer-%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%A0%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF)

**注意** ：`HTTPBearer(auto_error=True)`（デフォルト）にすると、`token`無しのリクエストに対して

```
PS > curl http://localhost:8001/api/me                            
curl : {"detail":"Not authenticated"}
PS > $error[0].exception
リモート サーバーがエラーを返しました: (403) 使用不可能  
```

とFastAPIが勝手に例外処理＋レスポンスを生成します。

## 認証ありAPIエンドポイント

ユーザ認証が必要なAPIエンドポイントを追加します。

```python:main.py
from fastapi import FastAPI, Depends
from user import get_user

app = FastAPI()

@app.get("/api/")
async def hello():
    return {"msg":"Hello, this is API server"} 


@app.get("/api/me")
async def hello_user(user = Depends(get_user)):
    return {"msg":"Hello, user","uid":user['uid']} 
```

`uid`はFirebaseにログインしたユーザの識別子で、e-mail&password に限らずTwitter, Googleなど多種サービスで認証したユーザも識別できます。

# テスト
実際にクライアントからAPIを叩いて応答を見てみます。

## `token`の取得
### API key の取得

[Firebaseコンソール](https://console.firebase.google.com/u/0/)からプロジェクトを選択して、設定＞全般よりコピペ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/b72427a6-9eb1-9ba7-60be-d3c660e90927.png)

### REST API を叩く
プロジェクトにユーザ（e-mail&password）が既に登録されているとして、

- Method : `POST`
- URL : `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${API_KEY}`
- Body : JSONで記述したログインに必要な情報

```json
{
  "email":"test@example.com",
  "password":"your password",
  "returnSecureToken":true
}
```

[[Firebase] Auth REST API - 公式ドキュメント](https://firebase.google.com/docs/reference/rest/auth#section-sign-in-email-password)


```bash
PS >  curl -Method Post -Body $Body -Headers @{"content-type"="application/json"} $URL
StatusCode        : 200
StatusDescription : OK
Content           : {
                      "kind": "identitytoolkit#VerifyPasswordResponse",
                      "localId": "OZzdeAtK4VM4OlHHbUXTY6YNr8C3",
                      "email": "test@example.com",
                      "displayName": "",
                      "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjNlNTQ...
RawContent        : HTTP/1.1 200 OK
                    Pragma: no-cache
                    Vary: X-Origin,Referer,Origin,Accept-Encoding
                    X-XSS-Protection: 0
                    X-Frame-Options: SAMEORIGIN
                    X-Content-Type-Options: nosniff
                    Alt-Svc: h3-Q050=":443"; ma=2592000...
```


応答もJSONで、`idToken`の値をBearer認証に使います。

## 実行

### 成功例

```bash
PS > curl -Headers @{"Authorization"="Bearer ${token}"} http://localhost:8001/api/me
StatusCode        : 200                                                         
StatusDescription : OK                                                          
Content           : {"msg":"Hello, user","uid":"OZzdeAtK4VM4OlHHbUXTY6YNr8C3"}  
RawContent        : HTTP/1.1 200 OK                                                                 
                    Content-Length: 58                                                              
                    Content-Type: application/json                                                  
                    Date: Fri, 20 Nov 2020 15:28:18 GMT                                             
                    Server: uvicorn                                                                 
                    WWW-Authenticate: Bearer realm="auth_required"       
                                                                                                           
                    {"msg":"Hello, user","uid":... 
```

### 不正な`token`の例
`token`の値を適当に弄って渡してみる

```bash
PS > curl -Headers @{"Authorization"="Bearer ${token}"} http://localhost:8001/api/me
curl : {"detail":"Invalid authentication credentials. Could not verify token signature."}
```


### `token`欠損の例

```bash
PS > curl http://localhost:8001/api/me                                              
curl : {"detail":"Bearer authentication required"}
```