fastapi-usersでJWT認証 + MFAを使ったログイン実装

FastAPIを利用したことはあるものの、fastapi-usersを使ったことがなかったため、トークン認証と多要素認証（MFA）の実装してみました。fastapi-usersを利用すると、ユーザー認証や管理が簡単に実装できるようなので、どのように動作するのかも含め検証してみました。

# 前提（Docker環境）
Python: 3.9.2
pip3: 24.2
fastapi: 0.112.0
fastapi-users: 13.0.0
SQLAlchemy: 2.0.32


# 1. React/FastAPIコード
今回はGithubにアップ済みのコードを使って進めたいと思います。

*sample code*

https://github.com/nw-engineer/SecureAuthSite

# 2. ディレクトリ構成
GitHubに登録されているSourceコードは以下の通りです。
```bash
.
└── SecureAuthSite/
    ├── api/
    │   ├── main.py              # FastAPI/Endpoint/ルータ設定
    │   ├── auth.py              # 認証関連
    │   ├── schemas.py           # クラス定義
    │   └── database.py          # DB関連
    └── react/
        ├── .env                 # PORT/API向けURL設定
        ├── package.json
        ├── package-lock.json
        └── src/
            ├── App.js           # ルーティング
            ├── LoginForm.js     # ログイン画面
            ├── RegisterForm.js  # ユーザ登録用
            ├── TfaForm.js       # MFAチェック画面
            ├── Dashboard.js     # ログイン後画面
            └── TotpSetup.js     # MFA登録


```
こちらのコードをコンテナ上でcloneしていただき動作確認をしていきたいと思います。

# 3. main.py / シーケンス
Sourceコードも複数ありますので、main.pyのみ記載いたします。

```python:main.py
from fastapi_users import schemas
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from api.auth import fastapi_users, auth_backend, current_active_user, get_user_manager, get_jwt_strategy
from api.schemas import TOTPRequest, TfaRequest
from api.database import engine, Base
import uuid
import pyotp

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.on_event("startup")
async def on_startup():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

@app.get("/auth/check-totp")
async def check_totp(user=Depends(current_active_user)):
    if not user.totp_secret:
        return {"totp_setup_required": True}
    return {"totp_required": True}

@app.get("/auth/totp-setup")
async def totp_setup(user=Depends(current_active_user), user_manager=Depends(get_user_manager)):
    if not user.totp_secret:
        user.totp_secret = pyotp.random_base32()
        update_dict = {"totp_secret": user.totp_secret}
        await user_manager.user_db.update(user, update_dict)

    totp_uri = pyotp.TOTP(user.totp_secret).provisioning_uri(user.email, issuer_name="DemoSite")
    return {"totp_uri": totp_uri}

@app.post("/auth/setup-totp")
async def setup_totp(request: TOTPRequest, user=Depends(current_active_user), user_manager=Depends(get_user_manager)):
    totp = pyotp.TOTP(user.totp_secret)
    if totp.verify(request.otp):
        return {"success": True}
    else:
        return {"success": False, "detail": "Invalid TOTP code"}

@app.post("/auth/login-tfa")
async def login_tfa(request: TfaRequest, user_manager=Depends(get_user_manager)):
    user = await user_manager.get_by_email(request.username)
    if not user or not await user_manager.verify_password(request.password, user.hashed_password):
        raise HTTPException(status_code=400, detail="Invalid username or password")

    totp = pyotp.TOTP(user.totp_secret)
    if not totp.verify(request.otp):
        raise HTTPException(status_code=400, detail="Invalid OTP")

    jwt_strategy = get_jwt_strategy()
    token = await jwt_strategy.write_token(user)
    return {"token": token}

app.include_router(fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"])
app.include_router(fastapi_users.get_register_router(schemas.BaseUser[uuid.UUID], schemas.BaseUserCreate), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_verify_router(schemas.BaseUser[uuid.UUID]), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_users_router(schemas.BaseUser[uuid.UUID], schemas.BaseUserUpdate), prefix="/users", tags=["users"])
```

:::note info
**ポイント（FastAPI-Users/MAF関連）**
- on_startup: アプリケーションの起動時に、データベースのテーブルが自動的に作成される
- 二要素関連
    - /auth/check-totp: ユーザーにTOTPが設定されているか確認
    - /auth/totp-setup: TOTPシークレットを生成し、それを使ってQRコードを表示するためのURIを返却
    - /auth/setup-totp: ユーザーの入力したTOTPコードが正しいか検証、正しければ成功メッセージを返却
    - /auth/login-tfa: ユーザー名とパスワードを検証した後、TOTPコードをチェックしコードが正しければ、JWTトークンを発行
- FastAPI-Users関連
    - /auth/jwt: JWTを使った認証ルーターを追加
    - /auth: ユーザー登録、パスワードリセット、メール認証などのルーターを追加
    - /users: ユーザー管理のためのルーターを追加
:::

FastAPI-Usersについては下記マニュアルを参照ください。

https://fastapi-users.github.io/fastapi-users/13.0/


またざっくりした流れですが、sequence図にすると以下のような形になります。
```mermaid
sequenceDiagram
    participant ユーザ as ユーザ
    participant ログイン画面 as ログイン画面
    participant 認証処理 as 認証処理
    participant MFA認証画面 as MFA認証画面
    participant QRコード表示 as QRコード表示
    participant ダッシュボード as ダッシュボード
    
    ユーザ ->> 認証処理: ユーザ登録
    認証処理 -->> ユーザ: 終了

    ユーザ ->> ログイン画面: ログイン
    ログイン画面 ->> 認証処理: 認証処理
    認証処理 ->> 認証処理: MFA登録あり?
    alt MFA登録あり
        認証処理 ->> MFA認証画面: MFA認証画面表示
        MFA認証画面 ->> ダッシュボード: ダッシュボード表示
    else MFA登録なし
        認証処理 ->> QRコード表示: QRコード表示
        QRコード表示 ->> ダッシュボード: ダッシュボード表示
    end
```




# 4. デプロイ
gitからcloneして、ディレクトリを遷移します。
```bash
git clone https://github.com/nw-engineer/SecureAuthSite.git
cd SecureAuthSite
```

uvicornでAPIを使える状態にしておきます。
```bash
uvicorn api.main:app --host 0.0.0.0 --port 4002 --reload
```

続いて、別ターミナルを起動させてreactの準備をします。ディレクトリ移動後にpackageをインストールします。
```bash
cd SecureAuthSite/react
npm install
```

起動前に.envのIP部分を変更します。
```abl:.env
PORT=4001
REACT_APP_API_URL=http://x.x.x.x:4002  # x.x.x.xを自身の環境に合わせる。
```

Reactを起動しましょう。
```bash
npm start
```

# 5. 動作確認

それでは、ブラウザからアクセスします。まだユーザが一人も登録されていない状態なので、ユーザ登録から実施します。 *http://ipaddoress:4001/register* を開きます。mailアドレスとパスワードを入れて、Registerをクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/9c63ea4b-05ac-5628-9d10-c0b3925b94c3.png)


続いて、 *http://ipaddoress:4001/* にアクセスし、先ほど登録したmail、パスワードを入れてログインをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/8e9c28e9-0c28-2814-0555-18c7694e6347.png)




二要素認証の画面が表示されますので、アプリを登録しTOTPコードを入れて「MFA登録」ボタンをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/6a6382f7-2d55-24f9-575c-50c4e1e95ac5.png)

ダッシュボード画面が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/da17774a-d74d-f9ee-e984-a6e90053b7e0.png)


MFA登録後は、QRコード画面の代わりに以下ページが表示され成功するとダッシュボード画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335933/7bce9142-448f-f73b-f2ad-e69aef296fa7.png)


デベロッパーツールから、ローカルストレージに保存されているtokenを削除して/dashboardにアクセスするとログイン画面が強制されていることがわかります。

# 6. その他
ユーザー認証、登録、パスワードリセット、メール確認、OAuth2（Google, Facebookなど）によるログインなど、標準的なユーザー管理機能をすぐに利用できるのは便利ですね。