panco’s blog

興味が沸いたことを書く

werkzeug.security.check_password_hash() について

ログイン処理(認証)の実装で躓いた点を書く。

実行環境

処理の流れ

  1. 新規登録画面からユーザ ID、パスワードを入力
  2. 新規登録ボタン押下
  3. 入力チェック OK なら、パスワードをハッシュ化してテーブルへ登録しログイン画面へ遷移(NG なら新規登録画面へ遷移)
  4. ログイン画面にユーザ ID、パスワードを入力
  5. 認証して OK なら、別の画面に遷移(NG ならログイン画面へ遷移)

5 が今回作ったところ。認証には werkzeug.security.check_password_hash() を使う

出来上がったソースコードたち

一応期待通りに動いている。
app.py

from  flask import Flask, render_template, redirect, g, request
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, EqualTo
import sqlite3
from werkzeug.security import generate_password_hash, check_password_hash
from IPython.core.display import display

app = Flask(__name__)

# 秘密鍵設定
app.config['SECRET_KEY'] = 'mysecretkey'

# DB 指定
DATABASE='pog-draft-project/pog.db'
# DB 接続
def connect_db():
    rv = sqlite3.connect(DATABASE)
    rv.row_factory = sqlite3.Row
    return rv
# DB 取得
def get_db():
    if not hasattr(g,'sqlite_db'):
        g.sqlite_db = connect_db()
    return g.sqlite_db

# 新規登録の form クラス
class registrationForm(FlaskForm):
    # 各項目と入力チェックの内容を設定
    username = StringField('ユーザ名', validators=[DataRequired()])
    password = PasswordField('パスワード', validators=[DataRequired()])
    pw_check = PasswordField('パスワード確認', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('新規登録')

# 新規登録処理
@app.route('/index', methods=['GET','POST'])
def index():
    form = registrationForm()
    # 入力チェックを実施して問題なければ users テーブルに格納
    if request.method == 'POST':
        if form.validate_on_submit():
            # パスワードをハッシュ値に変換
            pw_hash = generate_password_hash(form.password.data, method='pbkdf2:sha256')
            # DB へユーザ情報を登録
            con = get_db()
            cur = con.cursor()
            sql = "INSERT INTO users(user_name, password) VALUES(?, ?)"
            param = [form.username.data, pw_hash]
            cur.execute(sql, param)
            con.commit()
            con.close()
            return redirect('/login')
        return render_template('index.html', form=form)
    return render_template('index.html', form=form)

# ログイン画面の form クラス
class loginForm(FlaskForm):
    # 各項目と入力チェックの内容を設定
    username = StringField('ユーザ名', validators=[DataRequired()])
    password = PasswordField('パスワード', validators=[DataRequired()])
    submit = SubmitField('ログイン')

# ログイン処理
@app.route('/login', methods=['GET','POST'])
def login():
    form = loginForm()
    if request.method == 'POST':
        # Users テーブルから対象ユーザの password を取得
        con = get_db()
        cur = con.cursor()
        sql = "SELECT password FROM users WHERE user_name = ?"
        param = form.username.data
        res = cur.execute(sql, (param,)).fetchone()
        # tuple -> string
        res = ''.join(res)
        con.close()

        # パスワード認証
        pw_auth = check_password_hash(pwhash=res, password=form.password.data)
        print(pw_auth)
        if pw_auth == True:
            # 認証 OK の場合
            return redirect('/selection')
        msg = 'ログインエラー:ユーザ名またはパスワードが一致しません'
        return render_template('login.html', form=form, msg=msg)
    return render_template('login.html', form=form)

if __name__ == "__main__":
    app.run(debug=True)

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>新規登録</title>
  </head>
  <body>
    <h2>新規登録</h2>
    <div>
      <form method="POST">
        {{ form.hidden_tag() }}
        <!-- 入力フォーム設定 -->
        <div>{{ form.username.label }} {{ form.username() }}</div>
        <div>{{ form.password.label }} {{ form.password() }}</div>
        <div>
          {{ form.pw_check.label }} {{ form.pw_check() }} {{ form.pw_check.error
          }}
        </div>
        {{ form.submit() }}
        <div>
          {% if form.pw_check.errors %}
          <ul>
            {% for error in form.pw_check.errors %}
            <li>{{ form.pw_check.label ~' '~ error }}</li>
            {% endfor %}
          </ul>
          {% endif %}
        </div>
        <div><a href="/login">ログイン画面はこちら</a></div>
      </form>
    </div>
  </body>
</html>

login.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ログイン</title>
  </head>
  <body>
    <h2>ログイン</h2>
    <div>
      <form method="POST">
        <!-- 入力フォーム設定 -->
        <div>{{ form.username.label }} {{ form.username() }}</div>
        <div>{{ form.password.label }} {{ form.password() }}</div>
        <div>{{ form.submit() }}</div>
    <div><a href="/index">新規登録画面はこちら</a></div>
    <div>{{ msg }}</div>
  </form>
  </body>
</html>

werkzeug.security.check_password_hash() について

generate_password_hash() とセットで使う。generate_password_hash() については、以下記事を参照。

pancokeiba.hatenablog.com

check_password_hash() のパラメータと戻り値は以下の通り。

Parameters: pwhash (str) – a hashed string like returned by generate_password_hash().

password (str) – the plaintext password to compare against the hash.

Return type: bool

Utilities — Werkzeug Documentation (2.2.x)

  • 第一引数: 新規登録処理で generate_password_hash()でハッシュ化してテーブルに登録したパスワードの値
  • 第二引数: ログイン画面でユーザが入力した平文パスワード
    check_password_hash() が第二引数の平文パスワードをハッシュ化し、第一引数のハッシュ値(つまり正解の値)と比較してくれる。比較した結果、2 つの値が一致していれば True, 不一致であれば False を返してくれる。シンプルな仕組みで分かりやすい。

ログイン処理の実装で躓いた点

前提

以下、1 ~ 3 が実行済みであり、テーブルに以下のレコードが格納されていること。

  1. 新規登録画面からユーザ ID、パスワードを入力
  2. 新規登録ボタン押下
  3. 入力チェック OK なら、パスワードをハッシュ化してテーブルへ登録しログイン画面へ遷移(NG なら新規登録画面へ遷移)

テーブル状態(正常終了時)

user_id user_name password user_role updated_at
1 panco pbkdf2:sha256:260000$aA42TAOrOJ1mYet1$16b8aa3305971337a49db43b1ff9624589d5963c10c546941ec7692ac36d0228 0 2023-10-14 16:39:08

躓き ① 第一引数の取得で ProgrammingError

sqlite3.ProgrammingError: Incorrect number of bindings supplied. The current statement uses 1, and there are 5 supplied.

ProgrammingError
ProgrammingError

ProgrammingError になるコード

if request.method == 'POST':
    # Users テーブルから対象ユーザの password を取得
    con = get_db()
    cur = con.cursor()
    sql = "SELECT password FROM users WHERE user_name = ?"
    param = form.username.data
    res = cur.execute(sql, param).fetchone()
    con.close()

修正後のコード
ProgrammingError は解消したが、後述する AttributeError が発生した。

if request.method == 'POST':
    # Users テーブルから対象ユーザの password を取得
    con = get_db()
    cur = con.cursor()
    sql = "SELECT password FROM users WHERE user_name = ?"
    param = form.username.data
 # (sql, (param,)) と書く必要がある
    res = cur.execute(sql, (param,)).fetchone()
    con.close()

躓き ② 第一引数の取得で AttributeError

AttributeError: 'sqlite3.Row' object has no attribute 'count'

AttributeError
AttributeError

AttributeError になるコード

if request.method == 'POST':
    # Users テーブルから対象ユーザの password を取得
    con = get_db()
    cur = con.cursor()
    sql = "SELECT password FROM users WHERE user_name = ?"
    param = form.username.data
 # (sql, (param,)) と書く必要がある
    res = cur.execute(sql, (param,)).fetchone()
    con.close()

テーブルに格納された password は SQL で取得できているが、取得結果(変数 res)の値が以下のようになっていた。

<sqlite3.Row object at 0x0000019D20857970>

期待値は以下の文字列となるが、'sqlite3.Row'という異なる型で出力してしまった。str 型ではないため count が使えず怒られている。

pbkdf2:sha256:260000$aA42TAOrOJ1mYet1$16b8aa3305971337a49db43b1ff9624589d5963c10c546941ec7692ac36d0228

補足
count は check_password_hash() で、第一引数の "$" の数を数える際に使っている。
werkzeug>security.py

def check_password_hash(pwhash: str, password: str) -> bool:
    if pwhash.count("$") < 2:
        return False

修正後のコード

if request.method == 'POST':
    # Users テーブルから対象ユーザの password を取得
    con = get_db()
    cur = con.cursor()
    sql = "SELECT password FROM users WHERE user_name = ?"
    param = form.username.data
    res = cur.execute(sql, (param,)).fetchone()
    # tuple -> str に型変換
    res = ''.join(res)
    con.close()

型を str に変換することで解消した。
こうすることで、変数 res には期待通りに以下の値が入った。

pbkdf2:sha256:260000$aA42TAOrOJ1mYet1$16b8aa3305971337a49db43b1ff9624589d5963c10c546941ec7692ac36d0228

以上で、正常に第一引数を渡せた。第二引数はログイン画面から入力されたパスワードを渡すだけのため特に引っかかる点はなし。無事に True or False が返るようになった。