PR
PR

【Python_study_Day20】ユーザの追加とログイン機能を実装

記事内に広告が含まれています。

Pythonの学習20日目です。

前回、データベースとテーブルの作成が完了しました。

今回は、ユーザーの追加と削除、そしてログイン・ログアウトの機能を実装していきます。

今回作成するプログラムのファイル構成は以下の通りです。

app
├── __init__.py           既存のソースにBlueprint設定を登録
├── auth
│   ├── __init__.py      Blueprintの宣言を行う場所
│   ├── forms.py         ログインや登録の「入力フォーム」の定義を書く場所
│   └── routes.py        URLと処理(ビュー関数)を書く場所
├── extensions.py
├── models.py
├── static
│   ├── css
│   └── js
├── templates
│   ├── auth
│   │   ├── login.html     ログイン用フォーム
│   │   └── register.html  ユーザ登録用フォーム
│   ├── base.html      HTMLのベーステンプレート
│   └── tracker
└── tracker

Pythonプログラム

auth/__init__.py

このディレクトリを「Pythonのパッケージ」として認識させるために必須のファイルです。

ここでBlueprint本体を作成します。

from flask import Blueprint

# ここでBlueprintを作成
# 「auth」はBlueprintの名前
bp = Blueprint('auth', __name__)

# routes を読み込みBlueprintに紐付け
from app.auth import routes

Blueprintって?

Blueprintを簡単に説明すると、「アプリケーションを機能ごとの部品に分割して管理するための仕組み」ということになります。

大きなアプリケーションを一つのファイル(例えば app.py)にすべて書くと管理が大変になりますが、Blueprintを使うと機能毎に「認証機能(auth)」「メイン機能(main)」のように部品化することができます。

auth/routes.py

以下のURLにアクセスした際の処理(ビュー関数)を記述するコードです。

  • auth/login
  • auth/logout
  • auth/register
  • auth/delete_account
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash

# 拡張機能とモデルの読み込み
from app import db
from app.models import User

# Blueprint と フォームクラス の読み込み
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm

# --- ユーザー登録 (Register) ---
@bp.route('/register', methods=['GET', 'POST'])
def register():
    # ユーザがログイン済みの場合はトップページにリダイレクトさせる
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    
    # フォームのインスタンスを作成
    form = RegistrationForm()

    # POST送信され、かつバリデーション(入力チェック)に合格した場合のみ実行される
    if form.validate_on_submit():
        # フォームからデータを取り出すときは .data を使う
        hashed_password = generate_password_hash(form.password.data)
        
        user = User(username=form.username.data, password_hash=hashed_password)
        
        db.session.add(user)
        db.session.commit()
        
        flash('登録が完了しました!ログインしてください。')
        return redirect(url_for('auth.login'))

    # 初回アクセス時やエラー時は、フォームをテンプレートに渡して表示
    return render_template('auth/register.html', title='Register', form=form)


# --- ログイン (Login) ---
@bp.route('/login', methods=['GET', 'POST'])
def login():
    # ユーザがログイン済みの場合はトップページにリダイレクトさせる
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    
    form = LoginForm()

    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        
        # ユーザーがいない、またはパスワードが違う場合
        if user is None or not check_password_hash(user.password_hash, form.password.data):
            flash('ユーザー名またはパスワードが間違っています。')
            return redirect(url_for('auth.login'))
        
        login_user(user)
        
        next_page = request.args.get('next')
        if not next_page or not next_page.startswith('/'):
            next_page = url_for('index')
        return redirect(next_page)

    return render_template('auth/login.html', title='Sign In', form=form)


# --- ログアウト (Logout) ---
@bp.route('/logout')
def logout():
    logout_user()
    flash('ログアウトしました。')
    return redirect(url_for('index'))


# --- アカウント削除 (Delete) ---
# POSTのみで機能のみ実装
@bp.route('/delete_account', methods=['POST'])
@login_required
def delete_account():
    user = User.query.get(current_user.id)
    if user:
        db.session.delete(user)
        db.session.commit()
        logout_user()
        flash('アカウントを削除しました。')
    return redirect(url_for('index'))

auth/forms.py

ログインや登録で使用する「入力フォーム」の定義を行います。

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Regexp
from app.models import User

# --- ユーザー登録用フォーム ---
class RegistrationForm(FlaskForm):
    username = StringField('ユーザー名', validators=[
        DataRequired(message='ユーザー名は必須です。'),
        Length(min=2, max=20, message='ユーザー名は2文字以上20文字以内で入力してください。'),

        Regexp(
            r'^[a-zA-Z0-9_-]+$', 
            message='ユーザー名は半角英数字、アンダースコア(_)、ハイフン(-)のみ使用できます。'
        )
    ])
    
    password = PasswordField('パスワード', validators=[
        DataRequired(message='パスワードは必須です。')
    ])
    
    # パスワード(確認用):入力ミスを防ぐためのフィールド
    confirm_password = PasswordField('パスワード(確認)', validators=[
        DataRequired(),
        EqualTo('password', message='パスワードが一致しません。')
    ])
    
    submit = SubmitField('登録する')

    # カスタムバリデーション
    # フォーム送信時に自動で実行され、同じユーザー名がないかチェックしてくれる
    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user:
            raise ValidationError('そのユーザー名は既に使用されています。別の名前を選んでください。')


# --- ログイン用フォーム ---
class LoginForm(FlaskForm):
    username = StringField('ユーザー名', validators=[
        DataRequired(message='ユーザー名を入力してください。')
    ])
    
    password = PasswordField('パスワード', validators=[
        DataRequired(message='パスワードを入力してください。')
    ])
    
    submit = SubmitField('ログイン')

app/__init__.py

既存の「app/__init__.py」に Blueprintに関する設定を追加します。(追加分のみ記述しています。)

def create_app():
    app = Flask(__name__)
    # ... 設定など ...

    # Blueprintの設定を追加
    from app.auth import bp as auth_bp
    app.register_blueprint(auth_bp, url_prefix='/auth') 
    # ↑ これで /auth/login や /auth/register というURLになる

    return app

HTMLテンプレート

HTML部分のテンプレートです。

今回もGemini先生に丸投げしました…

app/templates/base.html

HTMLのベース部分となるテンプレートです。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    
    <title>{% block title %}{% endblock %} - My Flask App</title>
    
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
      <div class="container">
        <a class="navbar-brand" href="{{ url_for('index') }}">Flask App</a>
        
        <div class="collapse navbar-collapse">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{{ url_for('index') }}">ホーム</a>
            </li>

            {% if current_user.is_authenticated %}
              <li class="nav-item">
                <span class="nav-link disabled">ようこそ、{{ current_user.username }}さん</span>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{{ url_for('auth.logout') }}">ログアウト</a>
              </li>
            {% else %}
              <li class="nav-item">
                <a class="nav-link" href="{{ url_for('auth.login') }}">ログイン</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{{ url_for('auth.register') }}">新規登録</a>
              </li>
            {% endif %}
          </ul>
        </div>
      </div>
    </nav>

    <div class="container">
      
      {% with messages = get_flashed_messages() %}
        {% if messages %}
          {% for message in messages %}
            <div class="alert alert-info" role="alert">
              {{ message }}
            </div>
          {% endfor %}
        {% endif %}
      {% endwith %}

      {% block content %}{% endblock %}
      
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  </body>
</html>

app/templates/login.html

ログイン用のHTMLテンプレートです。

{% extends "base.html" %}

{% block title %}ログイン{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <h2 class="my-4 text-center">ログイン</h2>

        <form method="POST" action="">
            {{ form.hidden_tag() }}

            <div class="mb-3">
                {{ form.username.label(class="form-label") }}
                {{ form.username(class="form-control") }}

                {% for error in form.username.errors %}
                <span class="text-danger small">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.password.label(class="form-label") }}
                {{ form.password(class="form-control") }}

                {% for error in form.password.errors %}
                <span class="text-danger small">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="d-grid gap-2">
                {{ form.submit(class="btn btn-success") }}
            </div>
        </form>

        <p class="mt-3 text-center">
            アカウントをお持ちでないですか? <a href="{{ url_for('auth.register') }}">新規登録はこちら</a>
        </p>
    </div>
</div>
{% endblock %}

app/templates/register.html

ユーザ登録用のHTMLテンプレートです。

{% extends "base.html" %}

{% block title %}ユーザー登録{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <h2 class="my-4 text-center">ユーザー登録</h2>

        <form method="POST" action="">
            {{ form.hidden_tag() }}

            <div class="mb-3">
                {{ form.username.label(class="form-label") }}
                {{ form.username(class="form-control") }}

                {% for error in form.username.errors %}
                <span class="text-danger small">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.password.label(class="form-label") }}
                {{ form.password(class="form-control") }}

                {% for error in form.password.errors %}
                <span class="text-danger small">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.confirm_password.label(class="form-label") }}
                {{ form.confirm_password(class="form-control") }}

                {% for error in form.confirm_password.errors %}
                <span class="text-danger small">{{ error }}</span>
                {% endfor %}
            </div>

            <div class="d-grid gap-2">
                {{ form.submit(class="btn btn-primary") }}
            </div>
        </form>

        <p class="mt-3 text-center">
            すでにアカウントをお持ちですか? <a href="{{ url_for('auth.login') }}">ログインはこちら</a>
        </p>
    </div>
</div>
{% endblock %}

動作確認

ブラウザで「http://localhost:8080/」にアクセスして動作確認を行っていきます。

エラーが発生

トップページは問題なく表示されましたが、「auth/login」や「auth/register」にアクセスすると「Internal Server Error」が発生しました。

エラーの確認

エラーの内容を確認するために、以下のコマンドを実行してログを確認してみます。

podman logs コンテナ

「podman logs」は、コンテナの標準出力(stdout)と標準エラー出力(stderr)に出力された内容を表示するコマンドです。

ブラウザで「Internal Server Error」が出た場合、エラーが発生したことしか表示されませんが、このログを見れば「プログラムの裏側で具体的にどんなPythonエラー(例外)が発生したか」を知ることができます。

podman-composeを使うと、自動的に [フォルダ名]_[サービス名]_[連番] という名前が付きます。

今回は「time_report」フォルダの「app」サービスなので、「time_report_app_1」という名前になっていました。

実際にログを確認

ログを確認してみると、以下の内容が表示されていました。

$ podman logs time_report_app_1
## 省略 ##

 RuntimeError: A secret key is required to use CSRF.

## 省略 ##

「CSRF対策を使おうとしたけど、秘密鍵(SECRET_KEY)が無い」というPythonのエラーメッセージです。

ホスト側にある「.env」に「SECRET_KEY」は設定しているのに、それがコンテナ側に渡っていないようです。

app/__init__.pyを確認すると、環境変数から設定を読み込むようになっています。

def create_app():
    app = Flask(__name__)

    # --- 環境変数から設定を読み込む ---
    app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')

なぜ、「.env」に「SECRET_KEY」が設定されているのに、環境変数から読みこまなかったのでしょうか?

Gemini先生に聞いてみると、以下のことを教えてくれました。

「.env」 ファイルはホストPCにありますが、Pythonプログラムは独立したコンテナ(time_report_app_1)の中で動いているからです。

ということで、「time_report_app_1」コンテナの中に「.env」が無かったので、環境変数が読み込めなかったのが原因でした。

では、どのようにしてホストPCにある「.env」の内容を、コンテナに読み込ませることができるのかというと、「docker-compose.yml」の「environment」部分で、明示的にコンテナに渡したい変数を設定する必要があります。

environment:
  SECRET_KEY: ${SECRET_KEY}
環境変数が渡る仕組み
  1. 現在はホストPCの「.env」に「SECRET_KEY=dev_secret」と書いてあります。
  2. 「podman-compose up -d」でコンテナを起動すると、podman-composeはホストの「.env」 を環境変数として読み込みます。
  3. 「docker-compose.yml」で「SECRET_KEY: ${SECRET_KEY}」と設定しているので、環境変数として読み込んだ「SECRET_KEY」の値をコンテナに渡します。
  4. これで、コンテナ上の「os.environ.get('SECRET_KEY')」は、渡された内容を自身の環境変数として参照できるようになります。

補足:python-dotenv について

なぜ python-dotenv を入れてもダメだったのか?

「pyproject.toml」に「python-dotenv」が入っていますが、これは「flask run」コマンドで(PodmanやDockerを使わずに)ローカル起動する際に、自動で「.env」 を探してくれるライブラリです。

Podman(Docker)環境では、アプリの実行ディレクトリやファイル構成がホストとは異なるため、「環境変数はコンテナ管理側(docker-compose.yml)で設定する」 のがベストプラクティスとされています。

そうすることで、本番環境など「.env」ファイルがない環境でも、サーバーの設定で環境変数を渡すだけで動くようになるからです。

docker-compose.ymlの修正内容

コンテナ「app」の「environment」部分に、「SECRET_KEY: ${SECRET_KEY} 」を追加しました。

  # --- 2. Webアプリ (Flask + Gunicorn) ---
  app:
    build:
      context: .                           # プロジェクトのルートを基準にする
      dockerfile: docker/python/Dockerfile # 設計図の場所を指定
    restart: always
    # コマンド: Gunicornでアプリを起動 (バインド先はコンテナ内の全IP)
    command: gunicorn --bind 0.0.0.0:8000 "app:create_app()"
    volumes:
      - .:/app            # ホストのコードをコンテナに同期 (ホットリロード用)
    depends_on:
      - db
    environment:
      # SQLAlchemy用の接続URL (ホスト名は 'db' になる)
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      FLASK_APP: wsgi.py
      SECRET_KEY: ${SECRET_KEY}      ### 追加
      FLASK_DEBUG: ${FLASK_DEBUG}     

設定の反映

以下のコマンドでコンテナを再起動させて設定を反映させます。

podman-compose up -d

動作確認(再)

再度Webブラウザで動作確認を行うと、無事以下のURLが表示されるようになりました。

http://localhost:8080

http://localhost:8080/auth/login

http://localhost:8080/auth/register

ユーザ登録とログインのテスト

実際にユーザを登録してみます。



ユーザが登録できたので、ログインを行ってみます。

無事ログインを行うことができました。

コメント

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