0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python × Dify × RAGで学ぶ業務システム開発入門【第4回 Flaskログイン機能編】

0
Last updated at Posted at 2026-06-18

はじめに

前回は、ER図と画面設計を通じて「実装の地図」を作成しました。

前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第3回 ER図・画面設計編】

第4回では、いよいよ実装フェーズに入ります。
最初に実装するのはログイン機能です。

なぜ最初にログイン機能を実装するのか、理由は明確です。

ログインを最初に実装する理由
  → すべての画面はログイン後にしかアクセスできない
  → 認証の仕組みが決まれば、全ルートに一括で適用できる
  → パスワードの扱い方はセキュリティの基本中の基本

本連載は「作って終わり」ではなく、なぜそう設計するのかを重視しています。
設計の意図を理解することで、他のシステムにも応用できる力が身につきます。

本記事で学ぶこと

項目 内容
パスワードハッシュ化 平文保存を避け、安全にパスワードを管理する
セッション管理 ログイン状態をサーバー側で保持する仕組み
ログイン・ログアウト Flaskでの認証フローの実装
認証デコレータ ログイン必須チェックを全ルートに一括適用する
権限管理 ロール(admin / staff / user)によるアクセス制御

認証の基本概念

実装前に、認証に関する3つの基本概念を整理します。

1. パスワードのハッシュ化

パスワードをデータベースに平文(そのままの文字列)で保存してはいけません

NG(平文保存)
  DB: password = "password123"
  → DBが流出した瞬間、全ユーザーのパスワードが漏洩する

OK(ハッシュ化して保存)
  DB: password = "pbkdf2:sha256:260000$xxxx..."
  → 元のパスワードに戻すことが事実上できない
  → DBが流出しても、パスワードは守られる

本システムでは werkzeug.security を使ってハッシュ化します。

from werkzeug.security import generate_password_hash, check_password_hash

# 登録時:ハッシュ化して保存
hashed = generate_password_hash("password123")
# → "pbkdf2:sha256:260000$xxxx..." のような文字列になる

# ログイン時:入力値とハッシュを比較
is_valid = check_password_hash(hashed, "password123")  # True
is_valid = check_password_hash(hashed, "wrong")        # False

2. セッション管理

HTTP はステートレス(状態を持たない)プロトコルです。
そのままでは「このリクエストは誰が送ってきたか」がわかりません。

セッションの仕組み
  1. ログイン成功 → サーバーがセッションIDを発行
  2. セッションIDをCookieとしてブラウザに保存
  3. 以降のリクエストにCookieが自動付与される
  4. サーバーはCookieのセッションIDを見て「誰か」を特定する

Flaskでは session オブジェクトを使うだけで実現できます。

from flask import session

# ログイン成功時
session["user_id"] = user["id"]
session["username"] = user["username"]
session["role"] = user["role"]

# ログアウト時
session.clear()

# 他のルートで現在のユーザーを取得
user_id = session.get("user_id")  # 未ログインなら None

3. 認証デコレータ

ログインが必要なルートに毎回同じチェックを書くのは冗長です。

# NG:毎回チェックを書く
@app.route("/employees")
def employee_list():
    if "user_id" not in session:          # これを全ルートに書く?
        return redirect(url_for("login"))
    ...

# OK:デコレータで一括管理
@app.route("/employees")
@login_required          # このデコレータ1行だけ追加すればよい
def employee_list():
    ...

実装:データベース(user テーブル)

第3回で設計した user テーブルを database.py に実装します。

# database.py
import sqlite3
from config import Config


def get_db():
    """データベース接続を返す"""
    conn = sqlite3.connect(Config.DATABASE)
    conn.row_factory = sqlite3.Row  # カラム名でアクセスできるようにする
    return conn


def init_db():
    """テーブルを作成する(初回起動時に呼び出す)"""
    conn = get_db()
    cur = conn.cursor()
    cur.executescript("""
        CREATE TABLE IF NOT EXISTS user (
            id         INTEGER  PRIMARY KEY AUTOINCREMENT,
            username   TEXT     NOT NULL UNIQUE,
            password   TEXT     NOT NULL,
            role       TEXT     NOT NULL DEFAULT 'user',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        );
    """)
    conn.commit()
    conn.close()
    print("データベースを初期化しました。")

実装:auth_service.py(認証ロジック)

認証に関するビジネスロジックを services/auth_service.py にまとめます。
ルーティング(app.py)と処理(service)を分離することで、コードの見通しが良くなります。

# services/auth_service.py
from werkzeug.security import generate_password_hash, check_password_hash
from database import get_db


def get_user_by_username(username: str) -> dict | None:
    """
    ユーザー名でユーザーを1件取得する
    見つからない場合は None を返す
    """
    conn = get_db()
    user = conn.execute(
        "SELECT * FROM user WHERE username = ?", (username,)
    ).fetchone()
    conn.close()
    # sqlite3.Row を dict に変換して返す
    return dict(user) if user else None


def verify_password(username: str, password: str) -> dict | None:
    """
    ユーザー名とパスワードを検証する
    認証成功:ユーザー情報の dict を返す
    認証失敗:None を返す
    """
    user = get_user_by_username(username)
    if user is None:
        return None
    if not check_password_hash(user["password"], password):
        return None
    return user


def create_user(username: str, password: str, role: str = "user") -> int:
    """
    ユーザーを新規登録する
    パスワードはハッシュ化して保存する
    戻り値:登録したユーザーの id
    """
    hashed = generate_password_hash(password)
    conn = get_db()
    cur = conn.execute(
        "INSERT INTO user (username, password, role) VALUES (?, ?, ?)",
        (username, hashed, role)
    )
    conn.commit()
    user_id = cur.lastrowid
    conn.close()
    return user_id

check_password_hash必ずハッシュ値と比較します。
入力されたパスワードを直接DBの値と比較(==)してはいけません。

実装:認証デコレータ

functools.wraps を使ってカスタムデコレータを作ります。
このデコレータを適用したルートは、ログインしていない場合に自動でログイン画面へリダイレクトします。

# auth.py(または app.py の先頭部分に定義)
from functools import wraps
from flask import session, redirect, url_for, abort


def login_required(f):
    """
    ログイン必須チェックデコレータ
    セッションに user_id がない場合はログイン画面へリダイレクト
    使い方:
        @app.route("/employees")
        @login_required
        def employee_list():
            ...
    """
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if "user_id" not in session:
            return redirect(url_for("login"))
        return f(*args, **kwargs)
    return decorated_function


def roles_required(*roles):
    """
    ロール(権限)チェックデコレータ
    指定したロール以外のユーザーは 403 Forbidden を返す
    使い方:
        @app.route("/employees/new")
        @login_required
        @roles_required("admin", "staff")
        def employee_new():
            ...
    """
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            user_role = session.get("role")
            if user_role not in roles:
                abort(403)
            return f(*args, **kwargs)
        return decorated_function
    return decorator

実装:app.py(ログイン・ログアウトルート)

# app.py(ログイン・ログアウト部分)
from flask import (
    Flask, render_template, request,
    redirect, url_for, session, flash
)
from config import Config
from database import init_db
from services.auth_service import verify_password
from auth import login_required, roles_required

app = Flask(__name__)
app.config.from_object(Config)


@app.route("/login", methods=["GET", "POST"])
def login():
    """ログイン画面"""
    # すでにログイン済みならダッシュボードへ
    if "user_id" in session:
        return redirect(url_for("index"))

    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "")

        # 入力値チェック
        if not username or not password:
            flash("ユーザーIDとパスワードを入力してください", "error")
            return render_template("login.html")

        # 認証
        user = verify_password(username, password)
        if user is None:
            flash("ユーザーIDまたはパスワードが正しくありません", "error")
            return render_template("login.html")

        # セッションにユーザー情報を保存
        session["user_id"]  = user["id"]
        session["username"] = user["username"]
        session["role"]     = user["role"]

        flash(f"ようこそ、{user['username']} さん", "success")
        return redirect(url_for("index"))

    return render_template("login.html")


@app.route("/logout")
def logout():
    """ログアウト:セッションをクリアしてログイン画面へ"""
    username = session.get("username", "")
    session.clear()
    flash(f"{username} さん、ログアウトしました", "info")
    return redirect(url_for("login"))


@app.route("/")
@login_required
def index():
    """ダッシュボード(ログイン必須)"""
    return render_template("index.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>ログイン - AI人事・研修システム</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="login-page">

  <div class="login-container">
    <h1>🏢 AI人事・研修システム</h1>
    <h2>ログイン</h2>

    <!-- フラッシュメッセージ -->
    {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
        {% for category, message in messages %}
          <div class="alert alert-{{ category }}">{{ message }}</div>
        {% endfor %}
      {% endif %}
    {% endwith %}

    <form method="POST" action="{{ url_for('login') }}">
      <div class="form-group">
        <label for="username">ユーザーID</label>
        <input type="text" id="username" name="username"
               placeholder="ユーザーIDを入力" required autofocus>
      </div>
      <div class="form-group">
        <label for="password">パスワード</label>
        <input type="password" id="password" name="password"
               placeholder="パスワードを入力" required>
      </div>
      <button type="submit" class="btn btn-primary btn-block">
        ログイン
      </button>
    </form>
  </div>

</body>
</html>

実装:初期ユーザーの登録

初回起動時に管理者ユーザーを登録するスクリプトを作成します。

# create_admin.py
"""
初回セットアップ用:管理者ユーザーを作成する
使い方: python create_admin.py
"""
from database import init_db
from services.auth_service import create_user


def setup():
    # DBとテーブルを初期化
    init_db()

    # 管理者ユーザーを作成
    admin_id = create_user(
        username="admin",
        password="admin1234",   # ← 初回ログイン後に必ず変更すること
        role="admin"
    )
    print(f"管理者ユーザーを作成しました(id={admin_id}")

    # スタッフユーザーを作成
    staff_id = create_user(
        username="staff01",
        password="staff1234",
        role="staff"
    )
    print(f"スタッフユーザーを作成しました(id={staff_id}")

    # 一般ユーザーを作成
    user_id = create_user(
        username="user01",
        password="user1234",
        role="user"
    )
    print(f"一般ユーザーを作成しました(id={user_id}")


if __name__ == "__main__":
    setup()

create_admin.py の初期パスワードは開発用の仮パスワードです。
実際の運用では初回ログイン後に必ず変更してください。
また、このファイルをGitにコミットする場合はパスワードを直書きしないでください。

動作確認

ここまでの実装が正しく動くか確認します。

手順

# 1. 初期ユーザーを作成
python create_admin.py

# 2. Flaskアプリを起動
python app.py

# 3. ブラウザで http://localhost:5000/login にアクセス

確認ポイント

確認内容 期待する動作
未ログインで / にアクセス ログイン画面へリダイレクトされる
誤ったパスワードでログイン 「ユーザーIDまたはパスワードが正しくありません」が表示される
正しいID・パスワードでログイン ダッシュボードへ遷移し、ウェルカムメッセージが表示される
ログアウトボタンを押す ログイン画面へ戻り、ログアウトメッセージが表示される
ログアウト後に / にアクセス ログイン画面へリダイレクトされる

権限管理の使い方

roles_required デコレータを使って、ページごとにアクセス制限をかけます。

# app.py(権限管理の適用例)

# 社員登録:admin と staff のみ
@app.route("/employees/new", methods=["GET", "POST"])
@login_required
@roles_required("admin", "staff")
def employee_new():
    ...

# 社員削除:admin のみ
@app.route("/employees/<int:employee_id>/delete", methods=["POST"])
@login_required
@roles_required("admin")
def employee_delete(employee_id):
    ...

# 社内文書検索・FAQチャット:全ロール(ログインしていれば誰でも)
@app.route("/search", methods=["GET", "POST"])
@login_required
def search():
    ...

ロール別のアクセス権限をまとめると次の通りです。

機能 user staff admin
ダッシュボード
社員一覧・検索
社員登録・更新
社員削除
研修一覧
研修登録・履歴入力
評価管理・AIコメント生成
Excel取込・出力
日報登録・確認
社内文書検索
FAQチャット

テンプレートでの現在ユーザー表示

ナビゲーションバーにログイン中のユーザー名・ロールを表示します。
session はJinja2テンプレートから直接参照できます。

<!-- templates/base.html(ナビゲーション部分) -->
<nav class="navbar">
  <div class="navbar-brand">
    <a href="{{ url_for('index') }}">🏢 AI人事・研修システム</a>
  </div>

  <ul class="navbar-menu">
    <!-- staff / admin のみ表示するメニュー -->
    {% if session.get('role') in ['staff', 'admin'] %}
      <li><a href="{{ url_for('employee_list') }}">社員管理</a></li>
      <li><a href="{{ url_for('training_list') }}">研修管理</a></li>
      <li><a href="{{ url_for('evaluation_list') }}">評価管理</a></li>
      <li><a href="{{ url_for('import_excel') }}">Excel取込</a></li>
    {% endif %}

    <!-- 全員表示 -->
    <li><a href="{{ url_for('report_list') }}">日報</a></li>
    <li><a href="{{ url_for('search') }}">文書検索</a></li>
    <li><a href="{{ url_for('chat') }}">FAQ</a></li>
  </ul>

  <div class="navbar-user">
    <!-- ログイン中のユーザー名とロールを表示 -->
    <span>{{ session.get('username') }}({{ session.get('role') }})</span>
    <a href="{{ url_for('logout') }}" class="btn btn-sm">ログアウト</a>
  </div>
</nav>

セキュリティ上の注意点まとめ

本回で実装した認証機能のセキュリティポイントを整理します。

項目 実装内容 理由
パスワードハッシュ化 generate_password_hash で保存 平文保存はDB漏洩時に全パスワードが露出する
パスワード比較 check_password_hash を使用 文字列直接比較はタイミング攻撃に脆弱
SECRET_KEY 環境変数から読み込む セッションの改ざんを防ぐ暗号化キー
セッションクリア session.clear() でログアウト 古いセッション情報を完全に削除する
ログイン済み確認 login_required デコレータ 未認証アクセスをルート単位でブロックする
権限チェック roles_required デコレータ 権限外の操作を403エラーで拒否する

SECRET_KEY は必ず .env ファイルで管理し、Gitにコミットしないでください。
推測されにくいランダムな文字列を使用してください。

# ランダムなSECRET_KEYの生成方法
import secrets
print(secrets.token_hex(32))
# → "a3f8c2d1e9b4..." のような64文字の文字列が生成される

今後の連載予定

タイトル 主な内容
第1回 業務システム全体設計編 システム概要・技術選定・アーキテクチャ
第2回 要求定義・要件定義編 業務分析・機能要件・非機能要件
第3回 ER図・画面設計編 テーブル設計・画面遷移図・ワイヤーフレーム
第4回 Flaskログイン機能編(本記事) セッション・パスワードハッシュ・認証ミドルウェア
第5回 社員管理CRUD編 一覧・登録・更新・削除・バリデーション
第6回 研修管理編 リレーション・集計・出席率自動計算
第7回 Excel業務自動化編 pandas取込・openpyxl出力・テンプレート活用
第8回 Dify API連携編 プロンプト設計・API呼び出し・エラーハンドリング
第9回 RAG構築編 Knowledgeへの登録・Embedding・検索精度改善
第10回 FAQチャットボット編 チャットUI・会話履歴・ストリーミングレスポンス
第11回 GitHubチーム開発編 ブランチ戦略・Pull Request・コードレビュー
第12回 テスト・振り返り編 pytest・テスト設計・デモ・振り返り

おわりに

第4回では、Flaskのログイン機能を実装しました。

ポイントを振り返ります。

  • パスワードハッシュ化werkzeug.security で安全に保存・検証する
  • セッション管理session オブジェクトでログイン状態を保持する
  • ログイン・ログアウト:フォーム入力 → 認証 → セッション保存 → リダイレクトの流れを実装した
  • 認証デコレータ@login_required を1行追加するだけで全ルートを保護できる
  • 権限管理@roles_required でロールごとのアクセス制御を実現した

次回は「社員管理CRUD編」として、社員情報の一覧・登録・更新・削除と入力バリデーションを実装します。
今回作った @login_required@roles_required をフル活用します。

次回 : Python × Dify × RAGで学ぶ業務システム開発入門【第5回 社員管理CRUD編】

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?