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.py
で import
する。
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 関連で一区切りとなるエラーハンドリングとテストについて書いてみる。