2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

FastAPISQLModelを使用するプロジェクトにおいて、
ユーザを管理する管理画面をSQLAlchemy Admin(SQLAdmin)で構築する方法をまとめていきます。

類似ライブラリにFastAPI Adminがありますが、SQLModel をサポートしていません(#69)。

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

なお、以降に登場する User モデルは FastAPI UsersSQLModel 用アダプタで作成していますが、その他の実装でも同様の手法が適用可能だと思います。

実行環境

  • 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 モデルを管理画面に登録します。

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

課題

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

  • 新規登録

Create user

  • 更新

Edit user

対策

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

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

実装

ラベルの変更

  • ラベルを Hashed Password から Password に変更する。

column_labelsUser.hashed_password のラベルを変更します。

column_labels = {User.hashed_password: "Password"}

Screenshot 2024-09-11 at 16.53.39.png

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

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

form_overridesUser.hashed_password のフィールドを wtforms.PasswordField に変更します。

from wtforms import PasswordField

form_overrides = {"hashed_password": PasswordField}

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

Screenshot 2024-09-11 at 16.53.23.png

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

  • 更新時にパスワードを変更なし(空)で保存できるようにする

更新時にパスワードの入力を省略するため、form_argsでパスワードを任意項目に変更していきます。

Screenshot 2024-09-11 at 16.54.18.png

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

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

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

Screenshot 2024-09-11 at 16.54.46.png

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

import wtforms

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

validators に空のリストを渡しても、ここwtforms.validators.InputRequired が追加されるようで、 Optional を明示的に指定する必要があります。

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

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

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

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

パスワードのハッシュ化

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

ユーザの登録・更新前に呼び出されるon_model_changeに以下を追加します。

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 )を設定することで実質的にはパスワードが更新されないようにしています。
上記以外の場合は入力値をハッシュ化します。

ハッシュ関数 get_password_hash の実装は対象となるモデルによって異なりますが、
FastAPI Users を使用している場合はPassword hashを参考に UserManager.password_helper.hash(password) を使うのが良いでしょう。

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

  • 新規登録

create-user.gif

  • 更新

edit-user.gif

さいごに

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

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

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

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?