FastAPI/SQLModelに対応した管理画面「SQLAdmin」でユーザを管理


# はじめに

[FastAPI](https://fastapi.tiangolo.com/)と[SQLModel](https://sqlmodel.tiangolo.com/)を使用するプロジェクトにおいて、
ユーザを管理する管理画面を[SQLAlchemy Admin(SQLAdmin)](https://aminalaee.dev/sqladmin/)で構築する方法をまとめていきます。

https://github.com/aminalaee/sqladmin

:::note info
類似ライブラリに[FastAPI Admin](https://fastapi-admin-docs.long2ice.io/)がありますが、SQLModel をサポートしていません([#69](https://github.com/fastapi-admin/fastapi-admin/issues/69))。
:::

なぜこの記事を書こうと思ったかというと、SQLAdmin でパスワードのハッシュ値を扱う方法がわからなかったためです。
公式のクックブック「[Working with Passwords](https://aminalaee.dev/sqladmin/cookbook/working_with_passwords/)」ではパスワードの登録時にハッシュ化する方法が紹介されていますが、更新に対応していません。
そこで、登録・更新の両方でパスワードのハッシュ値を扱う方法を検討し、本記事にまとめることにしました。

なお、以降に登場する `User` モデルは [FastAPI Users](https://fastapi-users.github.io/fastapi-users/latest/)の[SQLModel 用アダプタ](https://github.com/fastapi-users/fastapi-users-db-sqlmodel)で作成していますが、その他の実装でも同様の手法が適用可能だと思います。

# 実行環境

- Python: 3.10
- fastapi: 0.109.1
- sqlmodel: 0.0.21
- fastapi-users: 13.0.0
- fastapi-users-db-sqlmodel: 0.3.0
- sqladmin: 0.17.0

# モデルの登録

まずはドキュメントに従って `ModelView` を作成し、 `User` モデルを管理画面に登録します。

```python
from fastapi import FastAPI
from sqladmin import Admin, ModelView

from app.core.db import engine
from app.models import User

app = FastAPI()
admin = Admin(app, engine)

class UserAdmin(ModelView, model=User):
    column_list = [
        User.email,
        User.is_active,
        User.is_superuser,
        User.is_verified,
    ]

admin.add_view(UserAdmin)
```

`User` モデルを管理画面から操作できるようになりました。

![List users](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/0e281fd6-b9d3-cdad-8b67-d1db6ff5a330.png)

# 課題

しかしこのままでは「Hashed Password」( `User.hashed_password` )でパスワードのハッシュ値が求められてしまいます。
これではユーザがパスワードを入力することができません。

- 新規登録

![Create user](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/28ab3e2b-4539-0abf-4c33-eeed0296a7d6.png)

- 更新

![Edit user](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/9d1f6b22-0410-8cae-afc2-7b24e7ea0c2f.png)

# 対策

パスワードのハッシュ値を扱うため、以下の対応を行います。

- ラベルを `Hashed Password` から `Password` に変更する。
- フォームをパスワード入力欄( `<input type="password">` )に変更する。
- 更新フォームの初期値を空にする（ハッシュ値を設定しない）。 ※ プレーンなパスワードは補完できないため
- パスワードをプレーンテキストで入力し、サーバサイドでハッシュ化する。
- 更新時にパスワードを変更なし（空）で保存できるようにする。
  - 入力あり：パスワードをハッシュ化して保存
  - 入力なし：パスワードの更新をスキップ

# 実装

## ラベルの変更

> - ラベルを `Hashed Password` から `Password` に変更する。

[column_labels](https://aminalaee.dev/sqladmin/api_reference/model_view/#sqladmin.models.ModelView.column_labels)で `User.hashed_password` のラベルを変更します。

```python
column_labels = {User.hashed_password: "Password"}
```

![Screenshot 2024-09-11 at 16.53.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/c2a172cb-8269-a3b3-8cc8-b9eefbdc2807.png)

## フォームフィールドの上書き

> - フォームをパスワード入力欄( `<input type="password">` )に変更する。
> - 更新フォームの初期値を空にする（ハッシュ値を設定しない）。

[form_overrides](https://aminalaee.dev/sqladmin/api_reference/model_view/#sqladmin.models.ModelView.form_overrides)で `User.hashed_password` のフィールドを `wtforms.PasswordField` に変更します。

```python
from wtforms import PasswordField

form_overrides = {"hashed_password": PasswordField}
```

これは `input` フィールドのタイプを `password` に変更し、更新フォームへの初期値設定を防ぎます。

![Screenshot 2024-09-11 at 16.53.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/b99b5dbb-1b67-5385-8a7e-dc396940c4c8.png)

## フォームフィールドの設定

> - 更新時にパスワードを変更なし（空）で保存できるようにする

更新時にパスワードの入力を省略するため、[form_args](https://aminalaee.dev/sqladmin/api_reference/model_view/#sqladmin.models.ModelView.form_args)でパスワードを任意項目に変更していきます。

![Screenshot 2024-09-11 at 16.54.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/ec390146-584d-bbda-a074-05cdb1d7dfe3.png)

まず、クライアントサイドのバリデーションを迂回するため、 `input` フィールドの `required` 属性を削除します。

```python
form_args = {
    "hashed_password": {
        "render_kw": {"class": "form-control", "required": False},
    },
}
```

しかしこれだけではサーバサイドのバリデーションで弾かれてしまいます。

![Screenshot 2024-09-11 at 16.54.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/3fda5860-b747-b950-12c2-a3c6f1637d6f.png)

そこで、 `validators` に `wtforms.validators.Optional()` を追加して任意項目化します。

```diff_python
import wtforms

form_args = {
    "hashed_password": {
        "render_kw": {"class": "form-control", "required": False},
+       "validators": [wtforms.validators.Optional()],
    },
}
```

:::note info
`validators` に空のリストを渡しても、[ここ](https://github.com/aminalaee/sqladmin/blob/e5e7d5073f5bd8a2a48adf1a30b8df4ffafdc647/sqladmin/forms.py#L201)で `wtforms.validators.InputRequired` が追加されるようで、 `Optional` を明示的に指定する必要があります。
:::

なお、この設定は**登録・更新時の両方に適用**されます。
本来であれば更新時のみ `form_args` を書き換えたいところですが、そのような手段は見つかりませんでした。
弊害として失われる登録時のパスワードの入力確認は[次項](#登録時のパスワード入力確認)で述べる自前のバリデーションで対応します。

## 登録時のパスワード入力確認

登録時にはパスワードが入力されているかを確認する必要があります。
そこで[insert_model](https://aminalaee.dev/sqladmin/cookbook/using_request_object/)に以下を実装します。
(ここでは `wtforms.ValidationError` を使用しましたが、より適切な例外があるかもしれません。)

```python
from starlette.requests import Request
from wtforms import ValidationError

async def insert_model(self, request: Request, data: dict) -> Any:
    if not data["hashed_password"]:
        raise ValidationError("Password is required.")
    return await super().insert_model(request, data)
```

![Screenshot 2024-09-11 at 16.55.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/f482410c-296b-e96a-245a-30920e23c1ac.png)

## パスワードのハッシュ化

> - パスワードをプレーンテキストで入力し、サーバサイドでハッシュ化する。
> - 更新時にパスワードを変更なし（空）で保存できるようにする。
>   - 入力あり：パスワードをハッシュ化して保存
>   - 入力なし：パスワードの更新をスキップ

ユーザの登録・更新前に呼び出される[on_model_change](https://aminalaee.dev/sqladmin/api_reference/model_view/#sqladmin.models.ModelView.on_model_change)に以下を追加します。

```python
async def on_model_change(
    self, data: dict[str, Any], model: Any, is_created: bool, request: Request
) -> None:
    # パスワード更新のスキップ
    if not is_created:
        if not data["hashed_password"]:
            data["hashed_password"] = model.hashed_password
            return
    # ハッシュ処理
    data["hashed_password"] = await get_password_hash(data["hashed_password"]) # 任意のハッシュ関数
```

更新時( `not is_created` )にパスワードが入力されていない場合( `not data["hashed_password"]` )、元のハッシュ値( `model.hashed_password` )を設定することで実質的にはパスワードが更新されないようにしています。
上記以外の場合は入力値をハッシュ化します。

:::note info
ハッシュ関数 `get_password_hash` の実装は対象となるモデルによって異なりますが、
FastAPI Users を使用している場合は[Password hash](https://fastapi-users.github.io/fastapi-users/latest/configuration/password-hash/)を参考に `UserManager.password_helper.hash(password)` を使うのが良いでしょう。
:::

こうして登録・更新のいずれにおいても入力値をハッシュ化し、更新時にはパスワードの入力を省略できるようになりました。

- 新規登録

![create-user.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/5d4c2448-f6ce-372d-2430-9e6aa59d0291.gif)

- 更新

![edit-user.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303869/89f708ed-a050-4e87-2e15-b7817c6f67ad.gif)

# さいごに

コードをいじくり回して検討しましたが、
登録・更新でフォームの挙動を変更できなかったため、正直無理やり感は否めません。
もっとスマートな方法が見つかれば更新します。

本記事の内容は以下のディスカッションでも提案しています。

https://github.com/aminalaee/sqladmin/discussions/359#discussioncomment-10589386

もう少し内容を精査して、公式ドキュメントに改善の PR を送る予定です。