はじめに
ログアウトボタンを押すと、ログイン画面に戻る。
私は、それで以前のログイン情報も使えなくなると思っていました。
ところが、自作Flaskアプリ「Bakery Hub」のセキュリティを調べたところ、管理者がログアウトした後でも、以前の認証情報を再利用できる問題が見つかりました。
前回の記事では、サイバー攻撃のニュースをきっかけに安全性を調査し、管理者セッションの問題や修正方針、初期の検証結果を紹介しました。
今回はその続編として、問題の原因、修正した仕組み、PostgreSQLや複数workerでの検証をもう少し詳しく記録します。
前回の記事はこちら。
サイバー攻撃に悪用されたAI「ARTEX」を調べ、Python独学半年の私が自作Flaskアプリの安全性を検証してみた
この記事で得られること
- Cookie・Session・DBの関係
- ログアウト後に古い認証が使えた原因
- サーバー側で認証を失効させる仕組み
- pytestとPostgreSQLで確認した内容
- バックアップ復元時にも認証の安全性を考える理由
専門用語は、できるだけ身近な言葉に置き換えながら説明します。
※ 自分が管理するBakery Hubを対象とし、検証には架空の認証情報と隔離環境を使用しています。
1. ログアウトしたのに、なぜ古い認証が使えたのか
Bakery Hubでは、管理者のログインに Flask-Login を使用しています。
従来のログアウト処理でも、logout_user() を呼び出していました。
そのため、通常のブラウザ操作ではログイン画面に戻り、ログアウトできたように見えます。
では、何が問題だったのでしょうか。
CookieとSessionの違い
まず、2つの言葉を整理してみます。
| 用語 | 私なりの理解 |
|---|---|
| Cookie | ブラウザが保存し、次のリクエストでサーバーへ送る情報 |
| Session | ログイン状態など、利用者に関する情報をリクエスト間で引き継ぐ仕組み |
Flaskの標準Sessionでは、署名付きCookieが使われます。
署名によって、Cookieの内容が勝手に変更されていないかを確認できます。
しかし、ここに落とし穴がありました。
署名が正しいことと、そのログインが現在も有効であることは別です。
今回のBakery Hubでは、古いCookieが有効期限内で、ほかの認証条件にも合っていれば、再び管理者として認証される状態でした。
ログアウトによってブラウザ側のCookieを削除しても、サーバー側には「このログインは終了した」という記録がなかったのです。
| 画面で確認できたこと | それだけでは分からないこと |
|---|---|
| ログイン画面へ戻った | 古い認証が無効になったか |
| Cookieがブラウザから削除された | 以前のCookieを再利用できないか |
| 通常のログアウトが成功した | 別のリクエストでも認証を拒否できるか |
ここで一つ、考え方を改めました。
「ログイン画面に戻った」と「以前の認証が使えなくなった」は同じではない。
今回の問題は、Flaskそのものに脆弱性があるという意味ではありません。
Bakery Hub側の認証設計で、ログアウト済みのセッションをサーバー側から失効させる仕組みが不足していました。
2. 解決策:ログイン記録をDBで管理する
古い認証を確実に拒否するには、サーバー側で現在のログイン状態を確認できる必要があります。
そこで今回、管理者がログインするたびに、DBへ認証記録を作成する方式を採用しました。
新しく追加したのが admin_auth_sessions というテーブルです。
| カラム | 役割 |
|---|---|
token_hash |
ログイン識別子のハッシュ |
issued_at |
発行日時 |
expires_at |
有効期限 |
revoked_at |
失効日時 |
ハッシュとは?
ハッシュは、データを一定の規則で別の値に変換する仕組みです。
今回使用しているSHA-256では、ログイン識別子から固定長の値を計算します。
Bakery Hubではランダムに生成した識別子そのものをDBへ保存せず、そのハッシュを記録します。
つまり、DBにはログイン用の生の識別子を直接残さない設計です。
ログインからログアウトまでの流れ
文章だけでは少し分かりにくいので、Mermaidで処理の流れを整理してみました。
今回の変更で重要なのは、Cookieだけでなく、DB上のログイン記録も確認する部分です。
ログアウトすると、該当する認証記録に失効日時が保存されます。
そのため、以前のCookieが残っていても、DB上で失効済みと判定できます。
3. Pythonでは、どうやって確認するのか
実際の admin_sessions.py では、次のような処理を実装しました。
def active_admin_session(token):
digest = token_hash(token)
if digest is None:
return False
now = utc_now()
return db.session.execute(
select(AdminAuthSession.token_hash).where(
AdminAuthSession.token_hash == digest,
AdminAuthSession.revoked_at.is_(None),
AdminAuthSession.issued_at <= now,
AdminAuthSession.expires_at > now,
)
).first() is not None
※ 実装の一部を抜粋しています。必要なimportやDB障害時の処理は呼び出し側にもあります。
初めて見ると少し難しそうですが、順番に読むと理解しやすくなります。
まず識別子を確認する
digest = token_hash(token)
if digest is None:
return False
受け取ったログイン識別子が正しい形式かを確認し、SHA-256のハッシュへ変換します。
不正な形式なら、その時点で認証を拒否します。
次にDBの記録を調べる
select() は、SQLAlchemyを使ってDBから情報を取得するための処理です。
今回の条件を一つずつ読むと、次のようになります。
| コード | 確認していること |
|---|---|
token_hash == digest |
同じログイン識別子の記録があるか |
revoked_at.is_(None) |
まだ失効していないか |
issued_at <= now |
発行日時が未来ではないか |
expires_at > now |
現在時刻が有効期限より前か |
この4条件をすべて満たす記録がある場合だけ、有効なログインと判断します。
なぜ is_(None) なのか
Pythonの None は、値がないことを表します。
今回の revoked_at は、失効していない間は NULL です。
そのため、SQLAlchemyでは is_(None) を使い、SQLの IS NULL に対応する条件を作っています。
また、最後の
.first() is not None
は、条件に合う記録が見つかったかを確認しています。
見つかれば True、見つからなければ False です。
12時間の絶対期限
今回の設計では、管理者セッションを発行から12時間で期限切れにしました。
操作するたびに期限が延長される方式ではありません。
例えば、朝6時に発行された認証なら、原則として夕方18時がDB上の有効期限です。
ログアウトしていなくても、期限に到達すれば認証を拒否します。
このように、画面上の状態だけでなく、DBの記録を使って現在のログイン状態を判断できるようにしました。
4. DBで確認できないときは、どうする?
ここでも一つ判断が必要でした。
もしPostgreSQLに問題が起きて、管理者のログイン記録を確認できなかったら、どうするべきでしょうか。
「以前ログインできていたのだから、今回も許可する」
という方法も考えられます。
しかし、それでは失効済みの認証を誤って許可する可能性があります。
そこでBakery Hubでは、認証の有効性を確認できない場合は、管理者操作を許可しない方針を採用しました。
DB照会でエラーが発生した場合は、transactionをrollbackし、HTTP 503を返します。
Fail Closed(フェイルクローズド)
安全性を確認できない場合に、処理を許可せず拒否する考え方です。
今回の場合、DB障害でログイン記録を確認できなければ、管理者としてのアクセスを許可しません。
ただし、認証を拒否するだけでは、店舗運営上の問題が残る場合もあります。
Bakery Hubのレジには、通信が切れたときに会計記録を端末内へ保持する仕組みがあります。
認証が確認できない場合でも、その会計記録を勝手に削除してはいけません。
認証を守ることと、業務データを守ること。
この2つは分けて考える必要があります。
5. ログアウト処理を一つのtransactionにまとめる
Bakery Hubには、通信が切れている間もレジを利用するためのオフライン許可があります。
今回の修正では、管理者がログアウトするとき、必要に応じて次の2つをまとめて処理するようにしました。
- 現在の管理者ログインを失効させる
- 店舗Datasetに属するオフライン許可を失効させる
両方のDB更新を一つのtransactionで扱い、最後に commit() します。
途中で失敗した場合は rollback() します。
transaction・commit・rollbackとは?
| 用語 | 意味 |
|---|---|
| transaction | 複数のDB操作を一つのまとまりとして扱う仕組み |
| commit | 処理した変更を確定する |
| rollback | 確定前の変更を取り消す |
例えば、管理者セッションだけ失効したのに、オフライン許可の更新に失敗した場合。
あるいは、その逆の場合。
関連する更新をまとめることで、片方だけ変更された状態を避けます。
ただし、失効の範囲は異なります。
| 対象 | 失効する範囲 |
|---|---|
| 管理者セッション | ログアウトしたログイン記録 |
| オフライン許可 | 対象の店舗Datasetに属する許可 |
そのため、ある端末からログアウトしても、別端末の独立した管理者ログインまで一律に終了させるわけではありません。
一緒に更新すべきものと、独立させるべきものを区別する。
これも、今回の設計で学んだことです。
6. 「直ったはず」ではなくpytestで確かめる
コードを修正しただけでは、本当に問題が解決したとは言えません。
そこで、以前の問題が再発しないかを確認する回帰テストを追加しました。
| 検証したこと | 期待する結果 |
|---|---|
| ログアウト後の古いCookie | 管理者ページへのアクセスを拒否 |
| 別端末の独立したログイン | 一方のログアウト後も維持 |
| 12時間の期限境界 | 期限到達時点から拒否 |
| DB照会障害 | 認証を許可せず503を返す |
| ログアウト中のDB更新失敗 | 関連更新をまとめてrollback |
| CSRFトークンなしのログアウトPOST | 拒否し、認証記録を維持 |
| migrationの前後 | 既存の業務データを保持 |
ここで意識したのは、pytestの成功件数を増やすことよりも、どんな事故を防げるテストなのかを明確にすることです。
SQLiteで成功しても、それだけでは終わらない
Bakery Hubでは、ローカルテストでSQLiteを使うことがあります。
しかし、本番DBはPostgreSQLです。
そこで、隔離されたPostgreSQL環境でも検証を行いました。
特に確認したのは、実際の COMMIT が失敗した場合の動作です。
Python側で疑似的に例外を発生させるだけでなく、DBで更新の確定に失敗した場合に、関連データが中途半端な状態にならないことを確認しました。
複数workerでも確認する
続いて、Gunicornの複数workerを使った検証も行いました。
workerとは、Webアプリへのリクエストを処理する実行プロセスのことです。
workerが複数ある場合、あるリクエストをworker Aが処理し、次のリクエストをworker Bが処理することもあります。
そこで、次のような条件を確認しました。
- worker Aでログアウトした後、worker Bでも古い認証を拒否できるか
- workerを再起動しても、失効状態が維持されるか
- 12時間の期限を過ぎた認証を拒否できるか
- DB障害時に拒否し、復旧後は有効な認証を正常に確認できるか
一つのPython関数が正しく動くことと、実際のDBや複数プロセスでも正しく動くことは別。
今回、この違いを実感しました。
7. 検証結果と本番への反映
修正をまとめたPR #150では、最終GitHub Actionsで次の結果を確認しました。
| 検証 | 結果 |
|---|---|
| Python pytest | 1,492 passed / 60 skipped |
| JavaScript | 60 passed |
| Chromium E2E | 69 passed |
| PostgreSQLのAdmin migration・HTTP回帰 | 6 passed / 1 skipped |
さらに、後続のPR #152では、PostgreSQL 16.15と18.6で、それぞれ 12 passed / 1 skipped の結果を記録しました。
テスト結果は、それぞれ別の実行です。
件数を合算したり、skipped を成功件数に含めたりしていません。
また、これらの結果は対象環境と検証条件におけるものであり、あらゆる障害への安全性を保証するものではありません。
修正コードと追加検証は、その後mainへマージされました。
コードが完成しても、すぐ本番へ反映しない
新しい admin_auth_sessions テーブルを使うには、先にAlembic migrationを適用する必要がありました。
今回のrevisionは d7a9c2e4f601 です。
2026年10月30日の復旧作業では、本番PostgreSQL 18.6へのmigration適用と対応コードのデプロイを実施し、管理者の再ログインなどを実機で確認しました。
一方、Admin正式レジの本番切替や、その後に開発したv3.2 Guest機能の公開は別工程です。
認証の修正を本番へ反映できたことと、すべての機能が本番公開可能になったことは同じではありません。
8. 今回の修正で分かったこと
今回の検証を通じて、ログアウトは単にログイン画面へ戻すための処理ではないと分かりました。
利用者がログアウトしたとき、本当に以前の認証が使えなくなっていること。
DB障害が起きても、確認できない認証を安易に許可しないこと。
そして、セキュリティを強化するために、別の大切な業務データを壊さないこと。
これらを一緒に考える必要がありました。
私は2026年5月からPythonを独学し、AIの支援を受けながらBakery Hubを開発しています。
AIはコードの調査や修正、テスト設計を手伝ってくれます。
でも、何を危険と判断し、どこまで確認できれば次へ進めるのかは、自分でも理解して判断したいと思っています。
物流の仕事でも、車が止まったように見えることと、安全な状態で停止できていることは同じではありません。
Webアプリも似ているのかもしれません。
画面上では正常に見えていても、内部の状態が正しいとは限らない。
今回のログアウト問題は、そのことを改めて考えるきっかけになりました。
9. そして、もう一つの疑問が生まれた
管理者セッションの失効をDBで管理することで、ログアウト後の古い認証を拒否できるようになりました。
しかし、PostgreSQLでの運用検証を進める中で、新たな疑問が出てきました。
「DBをバックアップから復元したら、失効した認証はどうなるのだろう?」
例えば、ある時点でバックアップを取得したとします。
その後、管理者がログアウトし、認証記録が失効しました。
もし障害によって、バックアップを取得した時点のDBへ戻したら……。
バックアップを復元すると、DBの記録はバックアップ取得時点へ戻ります。
つまり、バックアップ取得後に失効させたという情報も、復元によって失われる可能性があるのです。
実際にPR #152の隔離環境では、バックアップ後に失効した認証が、過去のDBを復元すると再び有効な記録として現れることを確認しました。
もちろん、実際に認証が再利用できるかどうかにはCookieの有効期限など、ほかの条件も関係します。
それでも、DB上の失効状態が過去に戻ることは、無視できない問題でした。
検証では、復元した環境で有効な管理者セッションを失効させ直す処理も確認しています。
DBの復元が成功しても、認証状態まで安全とは限りません。
商品や売上などの業務データだけでなく、復元された認証記録にも注意が必要です。
古いDBを復元する場合は、その時点の認証状態をそのまま運用へ戻してよいか、別に判断する必要があります。
ここで私は、もう一つ大切なことに気づきました。
そもそも、バックアップファイルを持っていても、本当に元のデータへ戻せるのでしょうか。
ファイルが存在するだけでは、復元できることの証明にはなりません。
復元できたとしても、必要なテーブルやデータが正しく残っているとは限りません。
そこで次は、Bakery HubのPostgreSQLを実際にバックアップし、13年前のLenovo G580を使って隔離環境へ復元する検証へ進みました。
次回の記事
🗄️ バックアップは取れた。でも本当に戻せる? 13年前のPCでPostgreSQL 18を隔離復元した記録
2026年11月2日にG580で実施した検証をもとに、次の内容を整理する予定です。
- 暗号化バックアップを取得する
- SHA-256でファイルの整合性を確認する
- 復号して、独立したPostgreSQLへ実際に復元する
- Datasetの参照関係やDB制約を確認する
- 「バックアップできた」と「復元できた」の違いを考える
今回が認証状態を安全に管理する話なら、次回は保存したデータを本当に復旧できるのかを確かめる話です。
コードが正しく動くことだけでなく、障害が起きても安全に運用を再開できること。
そこまで考えられるように、今後も一つずつ学んでいきたいと思います。
参考資料・開発記録
- Flask公式ドキュメント:Sessions
- OWASP:Session Management Cheat Sheet
- GitHub PR #150:Adminセッションの失効管理とPostgreSQL回帰テスト
- GitHub PR #152:Adminセッションの運用検証
- Bakery Hub ソースコード
この記事について
本記事は、2026年11月2日までに確認できた開発・検証記録をもとにしています。
- Bakery Hubが実際にサイバー攻撃を受けたという記録ではありません。
- 検証には架空の認証情報と隔離環境を使用しました。
- 実装コードの一部は、学習しやすいよう抜粋しています。
- 本番反映と未公開機能の公開判断は区別しています。
- 今回の対策によって、あらゆる攻撃や障害への安全性が保証されるわけではありません。
