header image

枝折

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

CREATED: 2024 / 04 / 07 Sun

UPDATED: 2024 / 04 / 07 Sun

認証を追加してみる

認証を追加する

今回は認証機能を追加してみる。 巷には Flask-Login などのライブラリもあるようだが、その辺は使わずに作ってみる。

認証の方法はパスワード方式とする。 パスワードの管理は Modular Crypt Format(MCF) に従う。

Python のライブラリである passlib のページにもあるが、MCF はハッシュパスワードの標準的なフォーマットで、世間にあるフレームワークたちの認証機能でもよく使われるものである。

MCF のパスワードは以下のようにアルゴリズムのバージョン、ストレッチングパラメーター、ソルト、ハッシュ値が $ 区切りで連結された文字列として管理される。

$2a$12$M1VFsgOHnMfB5q.XHX9UwuYTlURtdN9Y2Jmoj3ZkxxBSrJc8r1V.6

ハッシュアルゴリズムには MD5 のような高速なアルゴリズムではなく、bcrypt などの低速なアルゴリズムを使った方が良いとされている(この辺は徳丸さんの記事が勉強になる)。 このような低速アルゴリズムを利用したハッシュパスワードも解読が不可能というわけではないが、ソルトやストレッチングによって解読に時間をかけさせることでパスワードのローテーションをするための十分な時間を稼ぐことができる。

ここでは bcrypt を使って MCF に従ったハッシュパスワードを生成する。

実際の運用では、開発の要件にもよるが、PBKDF2 や Argon2id を使うのがベターかもしれない。 この辺参考になりそう👇 hashアルゴリズムとハッシュ値の長さ一覧(+ハッシュ関数の基本と応用) OWASPに学ぶパスワードの安全なハッシュ化

ユーザーの作成時にパスワードを保存する

まず、以前の記事で作成した User モデルに password カラムを追加する。

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)
    password: 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 migrate および flask db upgrade すれば DB に反映されるが、すでにデータを作ってる場合は password にデフォルト値が入らないのでエラーになるだろう。 その場合は db/data を消して再度コンテナを起動し直せば良い。

そしたら前回作成した sing_up() API でパスワード生成を行う。 まずは bcrypt をインストールして main.pyimport する。

bcrypt>=4.1.0,<5.0
import bcrypt

@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
        password = form.password.data # form からパスワードを取得する

        # ソルトを生成する
        # ストレッチングパラメーターは12(10以上がベター) = 2^12 回ストレッチングを実施
        # bcrypt のバージョンは最新の2bを指定
        salt = bcrypt.gensalt(rounds=12, prefix=b'2b')

        # ソルトを用いてパスワードをハッシュ化し、MCF のハッシュパスワードを生成する
        hashpw = bcrypt.hashpw(password.encode('utf8'), salt)

        # 生成されたハッシュパスワードを User レコードに保存する
        user = User(name=name, email=email, password=hashpw.decode('utf8'))
        db.session.add(user)
        db.session.commit()

        # セッション変数に email を加える
        session['email'] = user.email
        return redirect(url_for('products'))
    return render_template('sign_up.html', form=form)

以上で MCF のハッシュパスワードを保存し、次回のリクエストからセッションの email を参照して現在のログインユーザーを特定することができるようになった。 一応、サインインとサインアウトの API も作ってみる。

@app.route("/sign_in", methods=('GET', 'POST'))
def sign_in():
    form = SignInForm(request.form)
    if form.validate_on_submit():
        email = form.email.data
        password = form.password.data
        user = db.session.execute(db.select(User).filter_by(email=email)).scalar_one()

        # 入力されたパスワードとユーザーのパスワードが一致するかを確認
        if bcrypt.checkpw(password.encode('utf8'), user.password.encode('utf8')):
            session['email'] = user.email
            return redirect(url_for('products'))
    return render_template('sign_in.html', form=form)

@app.route("/products")
def products():
    form = SignOutForm(request.form)
    products = db.session.query(Product).all()
    return render_template('products.html', products=products, form=form)

サインアウトは商品一覧ページから実行できるようにしている。

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

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

{% block content %}
  <section>
    <form method="post">
      {{ form.csrf_token }}
      <dl>
        {{ render_field(form.email, placeholder="email") }}
        {{ render_field(form.password, placeholder="password") }}
      </dl>
      <input type="submit" value="サインイン">
    </form>
  </section>
{% endblock %}
<!-- products.html -->
<!-- 省略 -->
<form action="/sign_out" method="post">
  {{ form.csrf_token }}
  <input type="submit" value="サインアウト">
</form>
<!-- 省略 -->

あとはサインインが必要な画面ではセッション変数の email が存在するかを確認する処理を含めるとよさそうだ。

@app.route("/products")
def products():
    if 'email' in session: # セッション変数の email が存在するかを確認
        form = SignOutForm(request.form)
        products = db.session.query(Product).all()
        return render_template('products.html', products=products, form=form)
    return redirect(url_for('sign_in')) # email がなければサインイン画面へ戻す

を仕舞い

今回は MCF でパスワードを保存し認証機能を作成した。 実際はこのように自作する(とはいってもパスワードハッシュ生成や検証などは bcrypt ライブラリに任せているが)よりも、こういう処理をラップして提供しているライブラリや機能を利用するのが良いかと思う。

次は Flask 関連で一区切りとなるエラーハンドリングとテストについて書いてみる。