[Flask]ログイン機能の実装

Python

このページでは、公式の日本語訳チュートリアルを参考にして、Flaskで自分なりにログイン機能を実装してみました。

データベースは「sqlite3」モジュールを使用。

参考にしたページ
青写真とビュー(Blueprints and Views) — Flask Documentation (2.0.x)

使用した環境
WSL2 Ubuntu - 20.04
Python 3.10.0
Flask 2.1.3

 Androidアプリを作成しました。
 感情用のメモ帳です。

スポンサーリンク
スポンサーリンク

データベースと設定ファイル

アプリ用のパッケージとは別にディレクトリを作成し、この中にデータベースと設定ファイルを置こうと思います。

data」というディレクトリ名にしました。

この項目終わりのファイル構成は次のようになります。

$ tree .
.
└── data
    ├── config.py
    ├── create_db.py
    └── users.db

1 directory, 3 files

まずデータベースを作成するモジュールを書きます。

import sqlite3


def main():
    con = sqlite3.connect('users.db')
    con.execute("DROP TABLE IF EXISTS USERS")
    con.execute("CREATE TABLE USERS (\
        USERNAME TEXT PRIMARY KEY UNIQUE NOT NULL, \
        PASSWORD TEXT NOT NULL)"
    )
    print('データベースを初期化しました')


if __name__ == '__main__':
    main()

ユーザー名とパスワードを保持するテーブルです。
ユーザー名には「UNIQUE」制約を付け、名前の重複を防いでいます。

このモジュールを「data」上で実行すると、新たに「users.db」が出来るはずです。

~/data$ python create_db.py 
データベースを初期化しました

続いて設定ファイル。「config.py」にしました。

この中にセッション(ログイン状態を把握するためのもの)を利用するための秘密鍵と、データベースのパスを記述します。

Cookieとセッションをちゃんと理解する - Qiita

キーと値をペアで書きます。
※キーはアルファベット大文字

from pathlib import Path


SECRET_KEY = b'r9\xee*\x13\x96\xf7L\x9c8\x98\x8aM\xfb\x8e~'
DATABASE = Path().resolve() / 'data/users.db'

秘密鍵は開発段階なので何でも良かったのですが、本番でも使えるような以下を実行してコピペしました。

$ python -c 'import os; print(os.urandom(16))'
b'r9\xee*\x13\x96\xf7L\x9c8\x98\x8aM\xfb\x8e~'

os.urandom[docs.python.org]

secretsでも安全な乱数を生成できます。

$ python -c 'import secrets; print(secrets.token_hex())'
a062e8f2aa025347aba8c276b1ff90975b691a7055af368c2805ed3211a597d9

アプリケーションの作成

「data」と同階層に「login」というディレクトリを作成します。
これをパッケージとしてFlaskでログイン機能を実装していきます。

.
├── data
│   ├── config.py
│   ├── create_db.py
│   └── users.db
└── login

2 directories, 3 files

データベースモジュール

「login」内に「database.py」を作成し、データベース接続と閉じる機能をまとめます。

import sqlite3

from flask import current_app, g


def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(current_app.config['DATABASE'])
        g.db.row_factory = sqlite3.Row
    return g.db

def close_db(exception=None):
    db = g.pop('db', None)

    if db is not None:
        db.close()
 

このモジュールはアプリケーションから利用されます。

インポートした「current_app」を使い、現在のアプリケーションのconfig変数(文字通りFlaskの設定を入れるためのもの)を使用。
そこからデータベースのパスを取り出して「g.db」に代入しています。
※まだこの段階でアプリは存在せず、config['DATABASE']は設定されていません。

アプリケーション

「login」内に「__init__.py」を作成。

アプリケーションの生成とその設定を行います。

import pathlib

from flask import Flask

from . import database


def create_app():
    app = Flask(
        __name__,
        instance_path=pathlib.Path().resolve()/'data',
        instance_relative_config=True
    )
    app.config.from_pyfile('config.py')
    app.teardown_appcontext(database.close_db)

    @app.route('/')
    def index():
        return 'Index Page'

    return app

instance_path=pathlib.Path().resolve() / 'data'

インスタンスフォルダはデフォルトだと環境によって場所が異なるため、「data」をインスタンスフォルダとして絶対パスで指定しました。
(インスタンスフォルダはGitなどでバージョン管理したくないものを入れるのに最適な場所です。)

instance_relative_config=True」にして、instance_pathからの相対パスで設定を読めるように。

設定を読み込むのは、次の「app.config.from_pyfile('config.py')」です。
インスタンスフォルダに指定した「./data/」内の「config.py」の内容でapp.configを更新します。

ちなみにapp.configにはデフォルトで次のようなものが設定されています。

$ python
Python 3.10.0 (default, Oct 12 2021, 16:02:08) [GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from flask import Flask
>>> app = Flask(__name__)
>>> app.config
<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

config.pyが読み込まれると、この中にある「SECRET_KEY」の値が更新され、「DATABASE」と名付けたキーにそのパスを紐づけます。

app.teardown_appcontext(database.close_db)

これはリクエスト処理の終了時にデータベースを閉じます。


私も設定に関して完全に理解しているわけではありません。
メソッドによる値の更新、クラスオブジェクトや環境変数の読み込み等、いくつか方法があります。
詳しくは以下を参照してください。

設定の処理の仕方(Configuration Handling) — Flask Documentation (2.0.x)

現在のディレクトリ構成

$ tree .
.
├── data
│   ├── config.py
│   ├── create_db.py
│   └── users.db
└── login
    ├── database.py
    └── __init__.py

2 directories, 5 files

それではカレントディレクトリが「data」や「login」のであることを確認し、サーバーを起動してみましょう。

$ export FLASK_APP=login
$ export FLASK_ENV=development
$ flask run
 * Serving Flask app 'login' (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!

export FLASK_APP=login」とパッケージ名を指定してからrunをかけると、login内の「__init__.py」が最初に読み込まれます。

ちなみにコードには実行文「create_app()」がどこにも書いていませんが、flask runコマンドは、「create_app」という名前の関数を自動実行するようになっているため、アプリケーションは動作します。

ブラウザで「http://127.0.0.1:5000」にアクセスすると、「Index Page」と表示されています。

Blueprint

これからログイン機能を作っていきますが、その前にここではBlueprintの解説を行います。

Blueprintはモジュールをグループ化するためのクラスです。
アプリとは別のモジュールに機能を作ってグループ化し、アプリにBlueprintクラスのインスタンスを登録します。

サインアップ・ログイン・ログアウトなどの認証機能は、このページではそれをメインとしていますが、一般的には何かしらのWebアプリの機能の一部であり、モジュールを分けた方が管理しやすいため利用します。

サンプルとしてログインとは関係ないものを用意しました。

from flask import Blueprint


bp = Blueprint('two', __name__, url_prefix='/2nd')

@bp.route('/')
def bp_index():
    return 'Blueprint Page1'

@bp.route('/2')
def bp_page():
    return 'Blueprint Page2'

Blueprintクラスのインスタンスを変数に入れます。

bp = Blueprint('two', __name__, url_prefix='/2nd')

第一引数で付けた名前は、url_forなどで関数名を渡すときに使います。
例・url_for('two.bp_index')

url_prefixを使うと、その文字列がURLに付与されます。

あとはアプリケーションのときと同じように、デコレータとしてrouteメソッドを使います。

from flask import Flask

import second

app = Flask(__name__)

app.register_blueprint(second.bp)

@app.route('/')
def index():
    return 'Index Page'

if __name__ == '__main__':
    app.run()

app.register_blueprint(second.bp)

アプリ側ではモジュールをインポートし、メソッドを使って、ブループリントを登録します。

サーバーを起動すると、ブラウザから「http://127.0.0.1:5000」で「Index Page」「http://127.0.0.1:5000/2nd」へのアクセスで「Blueprint Page1」、「http://127.0.0.1:5000/2nd/2」で「Blueprint Page2」が表示されます。

サインアップ

「login」ディレクトリ内に新しく「user.py」を作成し、ここに認証機能を作っていきます。

まずはユーザーの新規登録からはじめます。

ルーティング

from flask import (
    Blueprint, flash, redirect, render_template, request, session, url_for
)
from werkzeug.security import check_password_hash, generate_password_hash

from . import database


bp = Blueprint('user', __name__)

@bp.route('/signup')
def sign_up():
    return render_template('signup.html')

@bp.route('/register', methods=('GET', 'POST'))
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = database.get_db()
        user = db.execute(
            "SELECT * FROM USERS WHERE USERNAME = ?", (username, )
        ).fetchone()

        if user:
            flash(f'ユーザー「{username}」はすでに存在しています')
            return redirect(url_for('user.sign_up'))

        db.execute(
            "INSERT INTO USERS (USERNAME, PASSWORD) VALUES (?, ?)",
            (username, generate_password_hash(password))
        )
        db.commit()
        return 'ユーザー登録が完了しました'

    return redirect(url_for('index'))

冒頭で必要なものをインポート。
つぎの項目で使うものもあらかじめ入れています。

register関数で「/signup」からのデータを検証して登録します。

もしフォームから送られてきたユーザー名がすでにデータベースに存在した場合、flashでメッセージを残し、signupのページにリダイレクトします。

flashは次のリクエスト時に使えるメッセージをセッションに保持します。

メッセージのフラッシュ表示(Message Flashing) — Flask Documentation (2.0.x)

またパスワードはそのものを保存するのではなく、パスワードのハッシュを保存します。

ブループリントを作成したので、アプリに登録します。

# userの追加
from . import database, user

def create_app():
    # 省略
    app.register_blueprint(user.bp)

テンプレート

templates」ディレクトリを作成し、2つのhtmlファイルを入れます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    {% block title%}
      <title>Document</title>
    {% endblock %}
  </head>
  <body>
  {% block content %}
  {% endblock %}
  </body>
</html>
{% extends 'base.html' %}

{% block title %}
<title>サインアップ</title>
{% endblock %}

{% block content %}
<h1>新規登録</h1>
{% with messages = get_flashed_messages() %}
  {% if messages %}
    {% for message in messages %}
      <p>{{ message }}</p>
    {% endfor %}
  {% endif %}
{% endwith %}
<form action="{{ url_for('user.register') }}" method="post">
  <div>
    <label for="user">ユーザーネーム</label><br>
    <input type="text" name="username" id="user" required>
  </div>
  <div>
    <label for="pass">パスワード</label><br>
    <input type='password' name="password" id="pass" required minlength="4">
  </div>
  <button>登録</button>
</form>
{% endblock %}

もしflashで保存されたメッセージがあるなら、with文とget_flashed_messagesを使い、forなどと合わせて中身を取り出します。

url_forで指定する関数名は、Blueprintインスタンス作成時に付けた名前をドットでつなげる必要があります。「url_for('user.register')」

現在の「login」内のファイル構成

$ tree login
login
├── database.py
├── __init__.py
├── templates
│   ├── base.html
│   └── signup.html
└── user.py

1 directory, 5 files

動作確認

実際にブラウザから新規登録ができるか試してみましょう。

$ flask run
 * Serving Flask app 'login' (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!

「http://127.0.0.1:5000/signup」にアクセスします。

ユーザーネームとパスワードを入れて、「登録」をクリック。

ユーザーネームとパスワードの入力フォーム。
「ユーザー登録が完了しました」と表示。

登録できたようです。

再び「/signup」に戻り、先ほどと同じユーザーネームを入れてボタンを押すと、

入力フォームの上に、ユーザー「fujino」はすでに存在しています、と表示された。

flashで保存されていたメッセージが表示されます。

ログイン

ルーティング

「user.py」にコードを追加します。

ログイン・ログアウト・ログインできているか、などを設定。

# 追記
@bp.route('/login')
def log_in():
    return render_template('login.html')

@bp.route('/auth', methods=('GET', 'POST'))
def auth():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = database.get_db()

        user = db.execute(
            "SELECT * FROM USERS WHERE USERNAME = ?", (username, )
        ).fetchone()

        if user is None:
            flash('ユーザー名が間違っています')
        elif not check_password_hash(user['PASSWORD'], password):
            flash('パスワードが間違っています')
        else:
            session.pop('username', None)
            session['username'] = username
            return redirect(url_for('user.member'))
        return redirect(url_for('user.log_in'))

    return redirect(url_for('index'))

@bp.route('/member')
def member():
    if 'username' in session:
        return f"こんにちは。{session['username']}さん"
    else:
        flash('メンバーページにアクセスするにはログインしてください')
        return redirect(url_for('user.log_in'))

@bp.route('/logout')
def log_out():
    session.clear()
    return redirect(url_for('index'))

auth関数は、「/login」から送られてきたデータを処理します。
register関数のときと似ていますが、条件文が異なります。

ユーザー名がデータベースに存在し、保存されているハッシュとパスワードをハッシュ化したものが一致すれば、セッション(sessoin['username'])にユーザー名を保存します。

session.pop('username', None)

セッションから'username'を取り除き、値を返します。
第二引数は該当するキーがセッションになかった場合に返す値です。

session.clear()は、セッションの内容が消去されるためタイミングに注意です。(flashメッセージなども消える)

member関数でログインしているかチェックします。
セッションに「username」というキーが入っているかで判断しています。

テンプレート

ルーティングで指定した「login.html」を作成します。

といっても内容は「signup.html」の流用です。
タイトルやフォームの送信先を変更。

{% extends 'base.html' %}

{% block title %}
<title>ログイン</title>
{% endblock %}

{% block content %}
<h1>ログイン</h1>
{% with messages = get_flashed_messages() %}
  {% if messages %}
    {% for message in messages %}
      <p>{{ message }}</p>
    {% endfor %}
  {% endif %}
{% endwith %}
<form action="{{ url_for('user.auth') }}" method="post">
  <div>
    <label for="user">ユーザーネーム</label><br>
    <input type="text" name="username" id="user" required>
  </div>
  <div>
    <label for="pass">パスワード</label><br>
    <input type='password' name="password" id="pass" required minlength="4">
  </div>
  <button>ログイン</button>
</form>
{% endblock %}

現在の「login」内のファイル構成

$ tree login
login
├── database.py
├── __init__.py
├── templates
│   ├── base.html
│   ├── login.html
│   └── signup.html
└── user.py

1 directory, 6 files

動作確認

$ flask run
 * Serving Flask app 'login' (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!

ブラウザで「http://127.0.0.1:5000/login」にアクセス。

存在しないユーザーでログインしてみます。

「ユーザー名が間違っています」と表示。

ユーザー名は正しいけれど、パスワードが違う場合。

「パスワードが間違っています」と表示。

正しいアカウントとパスワードを埋めて、ログインをクリックすると、

ログインページ。ユーザーネームに「fujino」、パスワードに「******」が入力済み。
「こんにちは。fujinoさん」と表示されている。

「http://127.0.0.1:5000/member」に移動しました。

今度は「http://127.0.0.1:5000/logout」にアクセスしてログアウトを試します。「http://127.0.0.1:5000/」にリダイレクトされ「Index Page」に移動しました。

ログアウトできているはずです。
ふたたび「http://127.0.0.1:5000/member」を手打ちして、さっきのメンバー用ページに移動すると、

ログインページにリダイレクトされ、flashメッセージが表示されました。

問題ありません。

認証機能を作ることができました。

セッションの有効期限

最後にセッションの補足を。

セッションはデフォルトではブラウザを閉じると消去されます。

ブラウザを閉じてもセッションが削除されないようにするには、sessionをインポートし、

session.permanent = True

セッションの有効期限を設定するには、標準ライブラリdatetimeのtimedeltaをインポートした上、アプリを通して、

app.permanent_session_lifetime = timedelta(time)

のように行います。

permanet_session_lifetimeの設定が有効になるのは「session.permanent」がTrueの場合で、デフォルト値はtimedelta(days=31)となっています。

from datetime import timedelta

from flask import Flask, session


app = Flask(__name__)
app.secret_key = 'dev'

@app.route('/')
def index():
    session.permanent = True
    app.permanent_session_lifetime = timedelta(seconds=30)
    session['text'] = 'Hello'
    return 'Index'

@app.route('/text')
def text():
    if 'text' in session:
        return session['text']
    else:
        return 'No Text'


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

session.permanentの設定はルーティング内で行います。

有効期限の設定は、アプリを通さなければいけませんが、ブループリント内で行いたい場合、current_appをインポートして「current_app.permanent_session_lifetime」を使えば可能です。

$ python session_sample.py 
 * Serving Flask app 'session_sample' (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!

上記を実行してブラウザから「http://127.0.0.1:5000/」にアクセス、そしてすぐに「http://127.0.0.1:5000/text」に行くと「Hello」が表示されます。

有効期限内であればブラウザを閉じてもセッションは削除されていません。

それから30秒以上経ってからふたたび「http://127.0.0.1:5000/text」に移動すると、今度は「No Text」が表示されます。

まとめ

フォームの入力値制限などができていないため、本番環境で使うことはまだできないでしょうが、ここで実装を終わります。

今回自分で一からやってみましたが、認証には「Flask-Login」パッケージもあります。

<<前の記事
データベースとの連携

タイトルとURLをコピーしました