header image

枝折

Flask で EC サイトの機能を作ってみる②

CREATED: 2024 / 04 / 06 Sat

UPDATED: 2024 / 04 / 06 Sat

フォームと CSRF 対応について

前回の続き

Flask で EC サイトの機能を作ってみる①の続きで、ここではフォームと CSRF について考えてみる。

Flask でアプリケーションを作るときに POST を行うような処理が必要な場合は普通、form を html に設けることになる。 これはこれから話すフォーム(python 側でフォームを組み立てて html に表示する)を必ずしも必要とはしませんが、バリデーションなどの機能がついてくるので特に理由がないなら採用して良いと思います。

フォームは Flask WTForm と呼ばれるライブラリで構築します。

Flask WTForm でフォームを構築する

まず requirements.txt に Flask-WTF を追加する。 あと、メールのバリデーションもしたいので email-validator も入れておく。

Flask-WTF>=1.2,<2.0
email-validator>=2.1,<3.0

前回の記事で作成した main.pyFlaskForm 等を import してサインアップフォームを作成してみると以下のような感じになる。 ここでは認証については考えないので、単純なユーザーデータ作成を行うサインアップとして考える。

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import InputRequired, Length, Email
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)

# 省略

csrf = CSRFProtect()
csrf.init_app(app) # CSRF 機能を設定

class SignUpForm(FlaskForm):
    name = StringField('name', validators=[InputRequired(), Length(min=1, max=20)])
    email = StringField('email', validators=[InputRequired(), Email()])

そういえば、前回の記事で作成した users テーブルには email カラムを設けていなかったので、User クラスを修正して再度マイグレーションをかけておく。 email カラムはユニークにして null が入らないようにした。 name もユニークにしている。

class User(db.Model):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    name: Mapped[str] = mapped_column(db.String(20), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(db.String(100), unique=True, nullable=False)
    purchase_transactions: Mapped[List['PurchaseTransaction']] = db.relationship(back_populates='user', cascade='all, delete-orphan')
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'User(id={self.id!r}, name={self.name!r}, created={self.created!r}, updated={self.updated!r})'

そしたらコンテナに入るなりして flask db migrateflask db upgrade を実行する。

root@d4d07ffc9c48:/app# flask db migrate -m 'change user'
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'purchase_transactions_id_seq' as owned by integer column 'purchase_transactions(id)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected added column 'users.email'
INFO  [alembic.autogenerate.compare] Detected added unique constraint None on '('email',)'
INFO  [alembic.autogenerate.compare] Detected added unique constraint None on '('name',)'
  Generating /app/migrations/versions/1070adbc40d0_change_user.py ...  done
root@13582844164c:/app# flask db upgrade

テーブルも作れたので、フォームを html に組み込んでみよう。 sign_up.html を作成する。 sign_up.html ではフォームの原型を定義する _formhelpers.html から render_field をインポートしている。 あとはこの render_fieldform 内に配置してあげるだけで良い。

<!-- sign_up.html -->
{% extends 'base.html' %}
{% from "_formhelpers.html" import render_field %}

{% block header %}
  <h1>{% block title %}SignUp{% endblock %}</h1>
{% endblock %}

{% block content %}
  <section>
    <form method="post">
      {{ form.csrf_token }}
      <dl>
        {{ render_field(form.name, placeholder="name") }}
        {{ render_field(form.email, placeholder="email") }}
      </dl>
      <input type="submit" value="サインアップ">
    </form>
  </section>
{% endblock %}

こっちは _formhelpers.html

<!-- _formhelpers.html -->
{% macro render_field(field) %}
  <dt>{{ field.label }}
  <dd>{{ field(**kwargs)|safe }}
  {% if field.errors %}
    <ul class=errors>
    {% for error in field.errors %}
      <li>{{ error }}</li>
    {% endfor %}
    </ul>
  {% endif %}
  </dd>
{% endmacro %}

そもそも sign_up() API を作ってなかったので追加しておくのも忘れずに。 こいつは GETPOST の両方で使うので methods で2つを許容しておく。

上で触れた SignUpFormrequest.form を突っ込み、そのバリデーションを行った上で問題なければ、User を作成し、作成後は商品一覧ページ(/products)へリダイレクトしている。 バリデーションに問題があれば sign_up.html に戻ってくるようになっている。

@app.route("/sign_up", methods=('GET', 'POST'))
def sign_up():
    form = SignUpForm(request.form)
    if form.validate_on_submit():
        name = form.name.data
        email = form.email.data
        user = User(name=name, email=email)
        db.session.add(user)
        db.session.commit()
        return redirect(url_for('products'))
    return render_template('sign_up.html', form=form)

たったこれだけなのだが、以上でバリデーションと CSRF 対策ができた。 試しにメールアドレスの項目に出鱈目なフォーマットで入力してみると、Invalid email address. と表示されるだろう。

CSRF については、main.py に記載していた secret_key を基に生成されるトークンが form に埋め込まれるので、これが POST 時に検証され、検証に成功すると sign_up() が正常に実行されるようになる。

CSRF トークンは同一オリジンなら cookie の設定や Origin ヘッダーの検証などで置き換えることもできるかと思うが、特に実装に苦労することでもないので黙ってやれば良いと思う。 脆弱性とかっていつどんな形で見つかるかわからないので、CSRF トークンにしか防げないような問題が見つかる可能性もないわけではないと思う(蓋然性はどうあれ)。

cookie と Origin ヘッダーの検証については次回の認証の記事で触れようと思う。 まだそもそも認証の機能がなく CSRF は起こり得ないため、ちょっとここでは説明しづらいというのもある。

を仕舞い

フォームについては以上、次は認証について書こうと思う。