はじめに
前回は、ER図と画面設計を通じて「実装の地図」を作成しました。
前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第3回 ER図・画面設計編】
第4回では、いよいよ実装フェーズに入ります。
本連載で使用するソースコードは、以下のGitHubリポジトリで公開しています。
最初に実装するのはログイン機能です。
なぜ最初にログイン機能を実装するのか、理由は明確です。
ログインを最初に実装する理由
→ すべての画面はログイン後にしかアクセスできない
→ 認証の仕組みが決まれば、全ルートに一括で適用できる
→ パスワードの扱い方はセキュリティの基本中の基本
本連載は「作って終わり」ではなく、なぜそう設計するのかを重視しています。
設計の意図を理解することで、他のシステムにも応用できる力が身につきます。
本記事で学ぶこと
| 項目 | 内容 |
|---|---|
| パスワードハッシュ化 | 平文保存を避け、安全にパスワードを管理する |
| セッション管理 | ログイン状態を保持する仕組み |
| SECRET_KEY | Flaskのセッションを安全に利用するための秘密鍵を設定する |
| ログイン・ログアウト | 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はステートレス(状態を持たない)プロトコルです。
そのままでは、ログイン後のリクエストが「誰から送られてきたものか」を継続して判断できません。
そこでFlaskでは、session を利用してログイン状態を管理します。
Flaskの標準セッションでは、セッション情報を署名付きCookieとしてブラウザに保存します。
ログイン成功
↓
session にユーザー情報を保存
↓
署名付きCookieとしてブラウザに保存
↓
次回以降のリクエストでCookieを送信
↓
Flaskがログイン状態を確認
Flaskでは session オブジェクトを使って、次のように情報を保存できます。
from flask import session
# ログイン成功時
session["user_id"] = user["id"]
session["username"] = user["username"]
session["role"] = user["role"]
session["employee_id"] = user.get("employee_id")
session["employee_name"] = user.get("employee_name")
# ログアウト時
session.clear()
# 他のルートで現在のユーザーを取得
user_id = session.get("user_id") # 未ログインなら None
SECRET_KEYとは
Flaskで session を利用するには、SECRET_KEY の設定が必要です。
SECRET_KEY は、FlaskがセッションCookieなどに署名し、データが改ざんされていないことを確認するために使用する秘密鍵です。
SECRET_KEY
↓
セッションCookieに署名
↓
ブラウザへ保存
↓
次回アクセス時に署名を検証
↓
改ざんされていないことを確認
SECRET_KEY が第三者に知られると、セッションの安全性が損なわれる可能性があります。
そのため、ソースコードへ直接記述せず、.env で管理します。
SECRET_KEYを生成する
SECRET_KEY には、自分で考えた単純な文字列ではなく、推測されにくいランダムな値を使用します。
Pythonの secrets モジュールを使って生成できます。
python -c "import secrets; print(secrets.token_hex(32))"
実行すると、次のような64文字のランダムな文字列が表示されます。
a3f8c2d1e9b4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
表示された文字列をコピーして使用します。
※実際に生成される文字列は実行するたびに異なります。
.envにSECRET_KEYを設定する
第1回で作成した .env を開き、生成した文字列を SECRET_KEY に設定します。
# ==============================
# Flask 設定
# ==============================
# セッションCookieなどの署名に使用する秘密鍵
SECRET_KEY=生成したランダムな文字列
.env には秘密情報が含まれるため、GitHubなどへ公開しないようにします。
.gitignore に次の設定が含まれていることも確認してください。
.env
SECRET_KEY に password や secret123 などの推測しやすい文字列を設定しないでください。
また、実際の SECRET_KEY をGitHubなどの公開リポジトリへコミットしないようにしてください。
FlaskからSECRET_KEYを読み込む
.env に設定した SECRET_KEY は、config.py から読み込みます。
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-please-change")
そして app.py では、Config をFlaskへ読み込みます。
app = Flask(__name__)
app.config.from_object(Config)
これにより、
.env
↓
config.py
↓
Flask
↓
session
という流れで SECRET_KEY が利用されます。
ソースコードに秘密鍵を直接記述せず、環境変数から読み込むことがポイントです。
現在のコードにはローカル学習用の既定値がありますが、公開環境では必ず.envに十分長いランダム値を設定してください。
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 functools import wraps
from flask import session, redirect, url_for, flash
from werkzeug.security import generate_password_hash, check_password_hash
from database import get_db
DEFAULT_USERS = (
("admin", "password", "admin"),
("staff", "password", "staff"),
("user", "password", "user"),
("EMP001", "password", "user"),
)
def ensure_default_users() -> None:
"""動作確認用の初期ユーザーを作成する。既存ユーザーは変更しない。"""
conn = get_db()
for username, password, role in DEFAULT_USERS:
exists = conn.execute(
"SELECT id FROM user WHERE username = ?", (username,)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO user (username, password, role) VALUES (?, ?, ?)",
(username, generate_password_hash(password), role),
)
conn.commit()
conn.close()
def authenticate(username: str, password: str) -> dict | None:
conn = get_db()
user = conn.execute(
"""
SELECT u.*, e.id AS employee_id, e.name AS employee_name
FROM user u
LEFT JOIN employee e
ON e.employee_no = u.username AND e.is_active = 1
WHERE u.username = ?
""",
(username,),
).fetchone()
conn.close()
if user and check_password_hash(user["password"], password):
return dict(user)
return None
authenticate()では、ユーザー名とパスワードだけでなく、user.usernameとemployee.employee_noも照合します。一致する有効な社員がいる場合は、employee_idとemployee_nameも認証結果に含めます。
たとえばEMP001でログインすると、社員番号EMP001の「山田 太郎」と関連付けられます。userテーブルに外部キーを追加するのではなく、認証時のLEFT JOINで動的に関連付ける実装です。
check_password_hash は必ずハッシュ値と比較します。
入力されたパスワードを直接DBの値と比較(==)してはいけません。
実装:認証デコレータ
functools.wraps を使ってカスタムデコレータを作ります。
このデコレータを適用したルートは、ログインしていない場合に自動でログイン画面へリダイレクトします。
# services/auth_service.py(続き)
def login_required(view_func):
"""ログイン済みユーザーだけアクセスを許可する。"""
@wraps(view_func)
def wrapper(*args, **kwargs):
if not session.get("user_id"):
flash("ログインしてください", "warning")
return redirect(url_for("login"))
return view_func(*args, **kwargs)
return wrapper
def roles_required(*allowed_roles):
"""指定したロールだけアクセスを許可する。"""
def decorator(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not session.get("user_id"):
flash("ログインしてください", "warning")
return redirect(url_for("login"))
if session.get("role") not in allowed_roles:
flash("この機能を利用する権限がありません", "danger")
return redirect(url_for("index"))
return view_func(*args, **kwargs)
return wrapper
return decorator
権限がない場合は403エラー画面を直接返すのではなく、メッセージを表示してトップ画面へ戻します。社員番号と連携したユーザーの場合、トップ画面から社員専用マイページへ移動します。
実装: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 (
authenticate, ensure_default_users, login_required, roles_required
)
from services.employee_portal_service import get_employee_portal_data
app = Flask(__name__)
app.config.from_object(Config)
init_db()
ensure_default_users()
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = authenticate(
request.form.get("username", ""),
request.form.get("password", ""),
)
if user:
session["user_id"] = user["id"]
session["username"] = user["username"]
session["role"] = user["role"]
session["employee_id"] = user.get("employee_id")
session["employee_name"] = user.get("employee_name")
flash("ログインしました", "success")
if user.get("employee_id"):
return redirect(url_for("employee_portal"))
return redirect(url_for("index"))
flash("ユーザーIDまたはパスワードが違います", "danger")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
flash("ログアウトしました", "info")
return redirect(url_for("login"))
@app.route("/")
@login_required
def index():
if session.get("employee_id"):
return redirect(url_for("employee_portal"))
# 実際のコードでは、staff / admin向けの集計値を作成する
...
@app.route("/my-page")
@login_required
def employee_portal():
"""社員番号でログインした社員専用のマイページ。"""
employee_id = session.get("employee_id")
if not employee_id:
flash("社員専用画面を利用できるアカウントではありません", "warning")
return redirect(url_for("index"))
portal = get_employee_portal_data(int(employee_id))
if not portal:
session.clear()
flash("社員情報が見つかりません。管理者にお問い合わせください", "danger")
return redirect(url_for("login"))
return render_template("employee_portal.html", portal=portal)
ログイン後の遷移先は、認証結果に社員情報が含まれるかどうかで変わります。
admin / staff / user
└── ダッシュボード(/)
EMP001(社員番号と一致するユーザー)
└── 山田 太郎さんのマイページ(/my-page)
実装:ログイン画面テンプレート(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を実行するのではなく、アプリ起動時にensure_default_users()を呼び出します。ユーザーが未登録の場合だけ、ハッシュ化したパスワードで追加されます。
# services/auth_service.py
DEFAULT_USERS = (
("admin", "password", "admin"),
("staff", "password", "staff"),
("user", "password", "user"),
("EMP001", "password", "user"),
)
def ensure_default_users() -> None:
conn = get_db()
for username, password, role in DEFAULT_USERS:
exists = conn.execute(
"SELECT id FROM user WHERE username = ?", (username,)
).fetchone()
if not exists:
conn.execute(
"INSERT INTO user (username, password, role) VALUES (?, ?, ?)",
(username, generate_password_hash(password), role),
)
conn.commit()
conn.close()
これらは学習・動作確認用の初期アカウントです。実際の運用では、初期パスワードをコードへ直書きせず、ユーザー作成画面や安全な初期設定処理を用意してください。
動作確認
ここまでの実装が正しく動くか確認します。
手順
# Flaskアプリを起動
python app.py
# ブラウザで http://localhost:5000/login にアクセス
init_db()とensure_default_users()はアプリ起動時に呼び出されるため、別の初期ユーザー作成コマンドは不要です。
ロール別のログイン確認
本システムでは、user、staff、admin の3種類のロールを使用します。
それぞれのユーザーでログインし、ロールに応じて利用できる機能が制御されていることを確認します。
| 種類 | ユーザー名 | パスワード | 確認ポイント |
|---|---|---|---|
| 一般ユーザー | user | password | 日報・文書検索・FAQのみ利用可能 |
| スタッフ | staff | password | 社員・研修・評価・Excelも利用可能。ただし社員削除不可 |
| 管理者 | admin | password | 全機能利用可能。社員削除も可能 |
| 社員番号ユーザー | EMP001 | password | 山田 太郎さんのマイページへ移動し、本人の情報と日報を利用可能 |
ログイン後は、ダッシュボードに表示されるメニューがロールによって異なることも確認します。
また、画面上にメニューが表示されないだけでなく、権限のないURLへ直接アクセスした場合にもアクセスが拒否されることを確認します。
確認ポイント
| 確認内容 | 期待する動作 |
|---|---|
未ログインで / にアクセス |
ログイン画面へリダイレクトされる |
| 誤ったパスワードでログイン | 「ユーザーIDまたはパスワードが違います」が表示される |
user でログイン |
日報・文書検索・FAQのみ利用できる |
staff でログイン |
社員・研修・評価・Excelを利用できるが、社員削除はできない |
admin でログイン |
すべての機能を利用でき、社員削除もできる |
EMP001でログイン |
山田 太郎さん専用の/my-pageへ移動する |
EMP001で日報画面を開く |
社員選択は固定され、本人の日報だけが表示される |
| 権限のないURLへ直接アクセス | 警告メッセージを表示し、トップ画面へ戻る |
| ログアウトボタンを押す | ログイン画面へ戻り、ログアウトメッセージが表示される |
ログアウト後に / にアクセス |
ログイン画面へリダイレクトされる |
権限管理の使い方
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チャット | ✅ | ✅ | ✅ |
EMP001のロール自体はuserです。ロールとは別に、ログインユーザー名と社員番号が一致した場合だけemployee_idがセッションへ保存され、マイページと本人限定の日報表示が有効になります。
テンプレートでの現在ユーザー表示
ナビゲーションバーにログイン中のユーザー名・ロールを表示します。
session はJinja2テンプレートから直接参照できます。
<!-- templates/base.html(抜粋) -->
<nav class="sidebar-menu" aria-label="メインメニュー">
{% if session.get('employee_id') %}
<a href="{{ url_for('employee_portal') }}">🏠 マイページ</a>
{% else %}
<a href="{{ url_for('index') }}">📊 ダッシュボード</a>
{% endif %}
{% if session.get('role') in ['staff', 'admin'] %}
<a href="{{ url_for('employee_list') }}">👤 社員管理</a>
<a href="{{ url_for('training_list') }}">📚 研修管理</a>
<a href="{{ url_for('evaluation_list') }}">⭐ 評価管理</a>
<a href="{{ url_for('import_excel') }}">📥 Excel取込</a>
{% endif %}
<a href="{{ url_for('report_list') }}">📝 日報管理</a>
<a href="{{ url_for('search') }}">🔍 文書検索</a>
<a href="{{ url_for('chat') }}">💬 FAQチャット</a>
</nav>
<div class="user-pill">
<span>👤 {{ session.get('employee_name') or session.get('username', 'ユーザー') }}</span>
<span class="role-badge">{{ session.get('role', 'user') }}</span>
<a href="{{ url_for('logout') }}">ログアウト</a>
</div>
社員情報と連携している場合は、ユーザー名ではなく社員名を表示します。EMP001でログインした場合、画面右上には「山田 太郎」と表示されます。
セキュリティ上の注意点まとめ
本回で実装した認証機能のセキュリティポイントを整理します。
| 項目 | 実装内容 | 理由 |
|---|---|---|
| パスワードハッシュ化 |
generate_password_hash で保存 |
平文保存はDB漏洩時に全パスワードが露出する |
| パスワード比較 |
check_password_hash を使用 |
文字列直接比較はタイミング攻撃に脆弱 |
| SECRET_KEY |
.env から読み込む |
セッションCookieの署名・改ざん検知に使用する |
| セッションクリア |
session.clear() でログアウト |
古いセッション情報を完全に削除する |
| ログイン済み確認 |
login_required デコレータ |
未認証アクセスをルート単位でブロックする |
| 権限チェック |
roles_required デコレータ |
権限外の操作を警告し、安全な画面へ戻す |
| 社員情報の照合 | 有効な社員番号とユーザー名を照合 | 無効な社員や別社員のマイページ表示を防ぐ |
SECRET_KEY は推測されにくいランダムな値を使用し、.env で管理します。
.env はGitにコミットしないでください。
おわりに
第4回では、Flaskのログイン機能を実装しました。
ポイントを振り返ります。
-
パスワードハッシュ化:
werkzeug.securityで安全に保存・検証する -
セッション管理:
sessionオブジェクトでログイン状態を保持する - ログイン・ログアウト:フォーム入力 → 認証 → セッション保存 → リダイレクトの流れを実装した
-
認証デコレータ:
@login_requiredを1行追加するだけで全ルートを保護できる -
権限管理:
@roles_requiredでロールごとのアクセス制御を実現した -
社員番号ログイン:
EMP001を山田 太郎さんと照合し、専用マイページへ遷移する - 本人限定表示:社員番号ユーザーの日報を本人分だけに制限する
次回は「社員管理CRUD編」として、社員情報の一覧・登録・更新・削除と入力バリデーションを実装します。
今回作った @login_required と @roles_required をフル活用します。
次回 : Python × Dify × RAGで学ぶ業務システム開発入門【第5回 社員管理CRUD編】
本連載について
本連載の各記事で実装していく内容は以下のとおりです。
| 回 | タイトル | 対応画面 | 主な内容 |
|---|---|---|---|
| 第1回 | 業務システム全体設計編 | ― | システム概要・技術選定・アーキテクチャ |
| 第2回 | 要求定義・要件定義編 | ― | 業務分析・機能要件・非機能要件 |
| 第3回 | ER図・画面設計編 | ― | テーブル設計・画面遷移図・ワイヤーフレーム |
| 第4回 | Flaskログイン機能編 | 🔐 ログイン | セッション・ハッシュ・ロール認可・社員番号ログイン |
| 第5回 | 社員管理CRUD編 | 👤 社員管理 | 一覧・検索・登録・更新・論理削除・入力制御 |
| 第6回 | 研修管理編 | 📚 研修管理 | 研修登録・LEFT JOIN・履歴一括入力・UPSERT |
| 第7回 | Excel業務自動化編 | 📥 取込 / 📤 出力 | pandas取込・openpyxl出力 |
| 第8回 | Dify API連携編 | 📊 評価管理 | API・フォールバック・生成中UI・二重送信防止 |
| 第9回 | RAG構築編 | 🔍 文書検索 | Dify Chat API・質問送信・回答表示・エラー処理 |
| 第10回 | FAQチャットボット編 | 💬 FAQチャット | 会話ID・Fetch API・生成中表示・二重送信防止 |
| 公開編 | Ubuntu VPS・Docker公開編 | 🌐 サービス公開 | Ubuntu VPS・Docker・Nginxを使った本番環境構築とWebアプリケーション公開 |


