はじめに
FastAPIとSQLModelを使用するプロジェクトにおいて、
ユーザを管理する管理画面をSQLAlchemy Admin(SQLAdmin)で構築する方法をまとめていきます。
類似ライブラリにFastAPI Adminがありますが、SQLModel をサポートしていません(#69)。
なぜこの記事を書こうと思ったかというと、SQLAdmin でパスワードのハッシュ値を扱う方法がわからなかったためです。
公式のクックブック「Working with Passwords」ではパスワードの登録時にハッシュ化する方法が紹介されていますが、更新に対応していません。
そこで、登録・更新の両方でパスワードのハッシュ値を扱う方法を検討し、本記事にまとめることにしました。
なお、以降に登場する User
モデルは FastAPI Usersの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
モデルを管理画面に登録します。
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
モデルを管理画面から操作できるようになりました。
課題
しかしこのままでは「Hashed Password」( User.hashed_password
)でパスワードのハッシュ値が求められてしまいます。
これではユーザがパスワードを入力することができません。
- 新規登録
- 更新
対策
パスワードのハッシュ値を扱うため、以下の対応を行います。
- ラベルを
Hashed Password
からPassword
に変更する。 - フォームをパスワード入力欄(
<input type="password">
)に変更する。 - 更新フォームの初期値を空にする(ハッシュ値を設定しない)。 ※ プレーンなパスワードは補完できないため
- パスワードをプレーンテキストで入力し、サーバサイドでハッシュ化する。
- 更新時にパスワードを変更なし(空)で保存できるようにする。
- 入力あり:パスワードをハッシュ化して保存
- 入力なし:パスワードの更新をスキップ
実装
ラベルの変更
- ラベルを
Hashed Password
からPassword
に変更する。
column_labelsで User.hashed_password
のラベルを変更します。
column_labels = {User.hashed_password: "Password"}
フォームフィールドの上書き
- フォームをパスワード入力欄(
<input type="password">
)に変更する。- 更新フォームの初期値を空にする(ハッシュ値を設定しない)。
form_overridesで User.hashed_password
のフィールドを wtforms.PasswordField
に変更します。
from wtforms import PasswordField
form_overrides = {"hashed_password": PasswordField}
これは input
フィールドのタイプを password
に変更し、更新フォームへの初期値設定を防ぎます。
フォームフィールドの設定
- 更新時にパスワードを変更なし(空)で保存できるようにする
更新時にパスワードの入力を省略するため、form_argsでパスワードを任意項目に変更していきます。
まず、クライアントサイドのバリデーションを迂回するため、 input
フィールドの required
属性を削除します。
form_args = {
"hashed_password": {
"render_kw": {"class": "form-control", "required": False},
},
}
しかしこれだけではサーバサイドのバリデーションで弾かれてしまいます。
そこで、 validators
に wtforms.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)
パスワードのハッシュ化
- パスワードをプレーンテキストで入力し、サーバサイドでハッシュ化する。
- 更新時にパスワードを変更なし(空)で保存できるようにする。
- 入力あり:パスワードをハッシュ化して保存
- 入力なし:パスワードの更新をスキップ
ユーザの登録・更新前に呼び出される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)
を使うのが良いでしょう。
こうして登録・更新のいずれにおいても入力値をハッシュ化し、更新時にはパスワードの入力を省略できるようになりました。
- 新規登録
- 更新
さいごに
コードをいじくり回して検討しましたが、
登録・更新でフォームの挙動を変更できなかったため、正直無理やり感は否めません。
もっとスマートな方法が見つかれば更新します。
本記事の内容は以下のディスカッションでも提案しています。
もう少し内容を精査して、公式ドキュメントに改善の PR を送る予定です。