概要
目的別モデリングは「1つのクラスの複数の変更理由」を解消する
認証・ランク判定・表示整形・LTV集計・ポイント管理・ステータス管理が1つのMemberクラスに同居していた。目的ごとに専用クラスへ分割し、それぞれが単一の理由でのみ変更される状態にする(Ch14)。
「Userクラスの落とし穴」は不要な機密情報を運んでしまうことに現れる
表示専用のrender_profile_pageがpassword_hashまで持つMember全体を受け取っていた。必要な値(profile/rank/ltv)だけを渡す設計に変える。
ポイント残高は「加算/減算」の専用メソッド経由でのみ変更可能にする
pointsへの直接代入を許すと不整合な状態を作り込める。PointBalanceで非公開フィールド化し、残高不足は例外で表現する(Ch3)。
クレデンシャルスタッフィング対策は多層防御で考える
レートベースban・Adaptive Protection・脅威インテリジェンス・WAF(OWASP CRS)・アカウント列挙対策・ログマスキングを組み合わせ、1つのルールに頼らない。
問題 A: コーディング — 目的別モデリング × 単一責任 × Userクラスの落とし穴(会員(Member)管理 Bad→Good)
以下の「悪いコード」は、ECサイト MOps チームが運用する会員(Member)管理モジュールです。認証・会員ランク判定・LTV集計・ポイント管理・表示整形・退会処理という異なる関心事がすべて1つの Member クラスに同居しています。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第14章(モデリング: 目的別モデリング・単一責任・Userクラスの落とし穴)を使って Bad→Good にリファクタリングしてください。
制約・前提条件
- Python 3.12+
- 認証(パスワード検証)・会員ランク判定・LTV集計・ポイント管理・表示整形・ステータス管理を、それぞれ目的別の専用クラス/関数に分離すること(目的別モデリング)
- 表示専用の関数・クラスには認証情報(
password_hash)を持つオブジェクト全体を渡さないこと(Userクラスの落とし穴対策) - ポイント残高は外部から直接書き換え不可とし、加算・減算は残高チェック付きのメソッド経由でのみ行うこと(完全コンストラクタ・カプセル化)
- LTV集計はモデル自身がSQLを発行するのではなく、リポジトリを注入されたユースケースクラスが担うこと(関心の分離)
- 会員ステータス(
active/withdrawn等)はマジックストリングではなく型(Enum)で表現すること - ランク判定の閾値は名前付き定数化すること
- Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章
悪いコード (Before) — カテゴリ A
このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_member.py — 認証×ランク判定×表示×LTV集計×ポイント管理×ステータス管理が1クラスに同居
class Member:
def __init__(self, member_id, email, password_hash, total_purchase_amount, points, status, db_conn):
self.member_id = member_id
self.email = email
self.password_hash = password_hash # 問題1: 認証情報
self.total_purchase_amount = total_purchase_amount
self.points = points # 問題3: 誰でも直接書き換え可能
self.status = status # 問題4: "active"/"withdrawn" マジックストリング
self._db_conn = db_conn # 問題2: モデルがDB接続を保持
def verify_password(self, raw_password):
return hashlib.sha256(raw_password.encode()).hexdigest() == self.password_hash
def calculate_rank(self):
if self.total_purchase_amount >= 500000: # 問題6: マジックナンバー
return "platinum"
elif self.total_purchase_amount >= 100000:
return "gold"
elif self.total_purchase_amount >= 30000:
return "silver"
return "bronze"
def to_display_name(self):
local, domain = self.email.split("@")
return f"{local[0]}***@{domain}"
def total_lifetime_value(self):
# 問題2(続き): モデル自身がSQLを発行してLTVを集計している
cursor = self._db_conn.execute(
"SELECT SUM(amount) FROM orders WHERE member_id = %s", (self.member_id,)
)
return cursor.fetchone()[0] or 0
def add_points(self, amount):
self.points += amount # 問題3(続き): バリデーションなしの直接加算
def use_points(self, amount):
self.points -= amount # 問題3(続き): 残高不足チェックなし、マイナスも許容
def deactivate(self):
self.status = "withdrawn" # 問題4(続き): マジックストリングでの状態管理
def render_profile_page(member):
rank = member.calculate_rank()
ltv = member.total_lifetime_value()
return f"{member.to_display_name()} ({rank}) LTV: {ltv}" # 問題5: password_hash持つMember全体を受領
問題点サマリー(7点)
1認証・ランク判定・表示・LTV集計・ポイント管理・ステータス管理が1クラスに同居(目的別モデリングの欠如, Ch14) — ある目的の変更が他の目的に予期せず影響する
2Memberが自身でDB接続を保持しSQLを発行(Ch14/Ch6-7) — モデルの単体テストにDB接続が必要になる
3pointsが直接書き換え可能な公開属性(カプセル化の欠如, Ch3) — 加算/減算に残高不足チェックもなくマイナス残高を許容
4statusがマジックストリングで管理(Ch10) — タイプミスを実行時まで検知できない
5表示専用関数がpassword_hashを持つMember全体を受領(Userクラスの落とし穴, Ch14) — 表示ロジックが不要な機密情報に触れられる
6ランク閾値がマジックナンバー(Ch10) — 500000/100000/30000が名前付けされていない
7DTO・エンティティ・DB直接操作の3責務を1クラスが兼務(単一責任原則違反, Ch14)
ヒント A(段階的開示)
ヒント1 — 方向性
Member クラスは「認証」「ランク判定」「表示整形」「LTV集計」「ポイント管理」「ステータス管理」という、本来は別々の文脈(コンテキスト)に属する関心事を1つのクラスに抱え込んでいる。これは第14章で指摘される「Userクラスの落とし穴」の典型例で、「1つのクラスがあらゆる目的に使い回されると、ある目的のための変更が他の目的に予期せず影響する」という問題を引き起こす。対処法は「目的ごとに専用のクラス・関数を用意する」こと。特に、表示専用の処理が認証情報を持つオブジェクト全体を受け取る必要はない、という点に注目する。
ヒント2 — アプローチ
AuthCredential(member_id/password_hash/verify_password)を認証専用の値オブジェクトとして独立させるMemberRankをStrEnumにし、ランク判定はresolve_rank(total_purchase_amount_yen)という純粋関数に切り出すPointBalanceをエンティティとして用意し、_amountを非公開にしてadd()/use()(残高不足は例外)経由でのみ変更可能にするMemberProfile(emailのみ)で表示専用のマスキング処理を担い、render_profile_pageにはMember全体ではなくprofile/rank/ltvだけを渡す- LTV集計は
OrderRepositoryプロトコルを注入されたMemberLifetimeValueCalculatorに担わせ、MemberからDB接続を排除する statusはMembershipStatusのStrEnumにする
ヒント3 — コードの骨格
@dataclass(frozen=True, slots=True)
class AuthCredential:
member_id: str
password_hash: str
def verify_password(self, raw_password: str) -> bool: ... # hmac.compare_digest
def resolve_rank(total_purchase_amount_yen: int) -> MemberRank: ... # 純粋関数
@dataclass(slots=True)
class PointBalance:
_amount: int = 0
@property
def amount(self) -> int: ...
def add(self, amount: int) -> None: ...
def use(self, amount: int) -> None: ... # 残高不足はInsufficientPointsError
@dataclass(frozen=True, slots=True)
class MemberProfile:
email: str
@property
def masked_display_name(self) -> str: ...
class MemberLifetimeValueCalculator:
def __init__(self, order_repository: OrderRepository) -> None: ...
def calculate(self, member_id: str) -> int: ...
def render_profile_page(profile: MemberProfile, rank: MemberRank, ltv: int) -> str: ...
問題点分析 — カテゴリ A
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 6つの関心事が1クラスに同居 | 目的別モデリングの欠如 Ch14 | AuthCredential/MemberProfile等へ分割 |
| 2 | Member自身がSQLを発行 | インフラ依存 Ch14/Ch6-7 | MemberLifetimeValueCalculatorへ委譲 |
| 3 | pointsが直接書き換え可能 | カプセル化の欠如 Ch3 | PointBalance.add()/use()経由に限定 |
| 4 | statusがマジックストリング | 設計の悪魔 Ch10 | MembershipStatus StrEnum |
| 5 | 表示関数がMember全体を受領 | Userクラスの落とし穴 Ch14 | profile/rank/ltvのみ受領 |
| 6 | ランク閾値がマジックナンバー | 設計の悪魔 Ch10 | 名前付き定数化 |
| 7 | DTO/エンティティ/DB操作の3責務兼務 | 単一責任原則違反 Ch14 | 目的別クラスへ分割 |
模範解答 A
Before — 6つの関心事が1クラスに同居・直接SQL発行・可変公開属性
class Member:
def __init__(self, member_id, email, password_hash,
total_purchase_amount, points, status, db_conn):
self.password_hash = password_hash # 認証情報
self.points = points # 直接書き換え可能
self.status = status # マジックストリング
self._db_conn = db_conn # モデルがDB接続を保持
def calculate_rank(self):
if self.total_purchase_amount >= 500000: # マジックナンバー
return "platinum"
...
def total_lifetime_value(self):
cursor = self._db_conn.execute( # モデルが直接SQL発行
"SELECT SUM(amount) FROM orders WHERE member_id = %s",
(self.member_id,))
return cursor.fetchone()[0] or 0
def add_points(self, amount):
self.points += amount # バリデーションなし
def render_profile_page(member): # password_hash持つMember全体を受領
...
After — AuthCredential/MemberProfile/PointBalance/Calculatorへ目的別分割
"""member.py — 目的別モデリング × 単一責任 × Userクラスの落とし穴の解消(Ch14/Ch3/Ch6-7/Ch10)"""
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from enum import StrEnum
from typing import Final, Protocol
class MembershipStatus(StrEnum):
"""会員ステータス(マジックストリングの排除, Ch10)。"""
ACTIVE = "active"
WITHDRAWN = "withdrawn"
SUSPENDED = "suspended"
class InsufficientPointsError(ValueError):
"""ポイント残高が不足している場合の例外。"""
@dataclass(frozen=True, slots=True)
class AuthCredential:
"""認証専用の値オブジェクト(会員の表示・注文情報とは独立させる, Ch14)。"""
member_id: str
password_hash: str
def verify_password(self, raw_password: str) -> bool:
"""定数時間比較でパスワードを検証する(タイミング攻撃対策)。"""
candidate_hash = hashlib.sha256(raw_password.encode()).hexdigest()
return hmac.compare_digest(candidate_hash, self.password_hash)
class MemberRank(StrEnum):
BRONZE = "bronze"; SILVER = "silver"; GOLD = "gold"; PLATINUM = "platinum"
# 名前付き定数: ランク判定の累計購入額しきい値(円)
_PLATINUM_THRESHOLD_YEN: Final[int] = 500_000
_GOLD_THRESHOLD_YEN: Final[int] = 100_000
_SILVER_THRESHOLD_YEN: Final[int] = 30_000
def resolve_rank(total_purchase_amount_yen: int) -> MemberRank:
"""累計購入額から会員ランクを算出する(Memberから独立した純粋関数, Ch14)。"""
if total_purchase_amount_yen >= _PLATINUM_THRESHOLD_YEN:
return MemberRank.PLATINUM
if total_purchase_amount_yen >= _GOLD_THRESHOLD_YEN:
return MemberRank.GOLD
if total_purchase_amount_yen >= _SILVER_THRESHOLD_YEN:
return MemberRank.SILVER
return MemberRank.BRONZE
@dataclass(slots=True)
class PointBalance:
"""ポイント残高エンティティ(直接フィールド操作を許さず、加算/減算はメソッド経由のみ, Ch3)。"""
_amount: int = 0
@property
def amount(self) -> int:
return self._amount
def add(self, amount: int) -> None:
if amount <= 0:
raise ValueError(f"加算するポイントは正の値である必要があります: {amount}")
self._amount += amount
def use(self, amount: int) -> None:
if amount <= 0:
raise ValueError(f"使用するポイントは正の値である必要があります: {amount}")
if amount > self._amount:
raise InsufficientPointsError(
f"ポイント残高が不足しています: 残高{self._amount}, 要求{amount}")
self._amount -= amount
@dataclass(frozen=True, slots=True)
class MemberProfile:
"""表示用プロフィール値オブジェクト(認証情報を持たない, Userクラスの落とし穴対策 Ch14)。"""
email: str
@property
def masked_display_name(self) -> str:
local, domain = self.email.split("@")
return f"{local[0]}***@{domain}"
class OrderRepository(Protocol):
"""注文集計に必要なリポジトリのプロトコル(モデルがDB接続を持たない, Ch6-7)。"""
def sum_order_amount(self, member_id: str) -> int: ...
class MemberLifetimeValueCalculator:
"""LTV集計を担うユースケースクラス(Memberエンティティから分離, Ch14)。"""
def __init__(self, order_repository: OrderRepository) -> None:
self._order_repository = order_repository
def calculate(self, member_id: str) -> int:
return self._order_repository.sum_order_amount(member_id)
@dataclass(slots=True)
class Member:
"""会員を表す集約(認証・表示・ポイント・ステータスを専用オブジェクトに委譲, Ch14)。"""
credential: AuthCredential
profile: MemberProfile
points: PointBalance
status: MembershipStatus
total_purchase_amount_yen: int
@property
def rank(self) -> MemberRank:
return resolve_rank(self.total_purchase_amount_yen)
def withdraw(self) -> None:
self.status = MembershipStatus.WITHDRAWN
def render_profile_page(profile: MemberProfile, rank: MemberRank, ltv: int) -> str:
"""必要な値のみを受け取る(認証情報を持つMember全体は渡さない, Ch14)。"""
return f"{profile.masked_display_name} ({rank.value}) LTV: {ltv}"
credential = AuthCredential(
member_id="M001",
password_hash=hashlib.sha256(b"correct-password").hexdigest(),
)
member = Member(
credential=credential,
profile=MemberProfile(email="taro.yamada@example.com"),
points=PointBalance(),
status=MembershipStatus.ACTIVE,
total_purchase_amount_yen=120_000,
)
member.points.add(500)
print(member.points.amount) # → 500
print(member.rank) # → MemberRank.GOLD
class FakeOrderRepository:
def sum_order_amount(self, member_id: str) -> int:
return 120_000
calculator = MemberLifetimeValueCalculator(order_repository=FakeOrderRepository())
ltv = calculator.calculate(member.credential.member_id)
print(render_profile_page(member.profile, member.rank, ltv))
# → t***@example.com (gold) LTV: 120000
member.points.use(10_000)
# → InsufficientPointsError: ポイント残高が不足しています: 残高500, 要求10000
print(member.credential.verify_password("correct-password")) # → True
print(member.credential.verify_password("wrong-password")) # → False
| ポイント | 適用した設計原則/パターン | 書籍対応章 |
|---|---|---|
| 認証・表示・ランク・LTV・ポイント・ステータスを専用クラスに分割 | 目的別モデリング | Ch14 |
| render_profile_pageがMember全体ではなく必要な値のみ受け取る | Userクラスの落とし穴対策 | Ch14 |
| PointBalanceで加算/減算をメソッド経由に限定、直接代入禁止 | カプセル化・完全コンストラクタ | Ch3 |
| MemberLifetimeValueCalculatorへDB問い合わせを分離 | 関心の分離 | Ch6-7 |
| statusをMembershipStatus StrEnumに置換 | マジックストリングの排除 | Ch10 |
| ランク閾値を名前付き定数化 | マジックナンバーの排除 | Ch10 |
# tests/test_member.py
import pytest
import hashlib
from member import (
AuthCredential, MemberProfile, PointBalance, MembershipStatus,
Member, MemberRank, resolve_rank, InsufficientPointsError,
MemberLifetimeValueCalculator,
)
class TestResolveRank:
def test_gold_threshold(self):
assert resolve_rank(120_000) == MemberRank.GOLD
def test_bronze_below_all_thresholds(self):
assert resolve_rank(1_000) == MemberRank.BRONZE
class TestPointBalance:
def test_add_then_use(self):
balance = PointBalance()
balance.add(500)
balance.use(200)
assert balance.amount == 300
def test_use_more_than_balance_raises(self):
balance = PointBalance()
balance.add(500)
with pytest.raises(InsufficientPointsError):
balance.use(10_000)
class TestAuthCredential:
def test_verify_password_success(self):
credential = AuthCredential(
member_id="M001",
password_hash=hashlib.sha256(b"secret").hexdigest(),
)
assert credential.verify_password("secret") is True
assert credential.verify_password("wrong") is False
class TestMemberLifetimeValueCalculator:
def test_calculate_delegates_to_repository(self):
class FakeRepo:
def sum_order_amount(self, member_id: str) -> int:
return 42_000
calculator = MemberLifetimeValueCalculator(order_repository=FakeRepo())
assert calculator.calculate("M001") == 42_000
問題 B: インフラ — 会員ログインAPI(member-auth-api)の Cloud Armor によるクレデンシャルスタッフィング対策
問題Aの Member(特に AuthCredential)は member-auth-api として GKE Autopilot 上で稼働し、外部公開されています。現状の構成には以下の課題があります。
現状の課題:
/api/v1/loginエンドポイントがCloud Run直接URLでインターネットに公開されており、External HTTPS Load Balancer や Cloud Armor を経由していない- ログインエンドポインにレート制限が一切なく、同一IPから秒間数百回のログイン試行(クレデンシャルスタッフィング・ブルートフォース)が可能
- L7 DDoS対策(Adaptive Protection)が有効化されておらず、大量リクエストを検知・自動緩和する仕組みがない
- 既知の悪性IP(ボットネット・攻撃インフラ)を遮断する脅威インテリジェンスフィードを利用していない
- OWASP CRS(SQLi/XSS対策のプリコンフィグルール)が導入されておらず、WAFレベルの入力攻撃対策がゼロ
- 認証失敗時に「メールアドレスが存在しない」場合と「パスワードが不一致」の場合でレスポンスメッセージが異なり、アカウントの存在有無を判別できる(アカウント列挙)
- ログイン試行の構造化ログに
raw_password等の機密フィールドがそのまま出力されうる設計
要件
| # | 要件 |
|---|---|
| 1 | member-auth-api の前段に External HTTPS LB + Serverless NEG を配置し、Cloud Armor セキュリティポリシーを適用すること |
| 2 | /api/v1/login にrate-based ban(同一IPあたり60秒20回超で一定時間ban)を設定すること |
| 3 | Adaptive Protection(L7 DDoS defense) を有効化すること |
| 4 | 脅威インテリジェンスの既知悪性IPフィードで拒否ルールを設定すること |
| 5 | OWASP CRS(SQLi/XSS)ルールをpreviewで導入し誤検知確認後にblock化する段階導入とすること |
| 6 | 認証失敗時のレスポンスを理由によらず統一し、アカウント列挙を防ぐこと |
| 7 | ログイン試行のログ出力から機密フィールドをマスキングするフィルタを実装すること |
期待する回答形式: Terraform(Cloud Armor セキュリティポリシー + LB/NEGアタッチ)+ Python(統一エラーレスポンス + ログマスキングフィルタ)+ Bad vs Good 比較表 + 確認コマンド
ヒント B(段階的開示)
ヒント1 — 方向性
クレデンシャルスタッフィング対策は「入口を絞る(LB経由に統一しCloud Armorを必ず通す)」「頻度を制限する(レートベースルール)」「既知の悪意あるアクセス元を弾く(脅威インテリジェンス)」「攻撃者に情報を与えない(アカウント列挙対策・ログのマスキング)」という4つの層で考える。1つのルールだけでは不十分で、これらは多層防御として組み合わせる。
ヒント2 — アプローチ
google_compute_security_policyにadaptive_protection_config.layer_7_ddos_defense_config.enable = trueを設定rate_based_banアクションのルールでmatch.expr.expressionにrequest.path.matches('/api/v1/login')を指定し、rate_limit_optionsで閾値とban_duration_secを設定evaluatePreconfiguredExpr('sourceiplist-known-malicious-ips')をdeny(403)ルールとして追加evaluatePreconfiguredExpr('sqli-v33-stable')/xss-v33-stableは最初preview = trueで導入- Cloud Run(Serverless NEG)を
google_compute_backend_serviceにアタッチし、そのsecurity_policyに上記ポリシーを指定 - アプリ側は認証失敗の分岐にかかわらず同一の例外・同一メッセージを返し、ログには
logging.Filterでパスワード系フィールドをマスキング
ヒント3 — リソースの骨格
Cloud Armorポリシーの骨格
resource "google_compute_security_policy" "member_auth_waf" {
adaptive_protection_config {
layer_7_ddos_defense_config { enable = true }
}
rule {
action = "rate_based_ban"
priority = 3000
match { expr {
expression = "request.path.matches('/api/v1/login')"
} }
rate_limit_options {
conform_action = "allow"
exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold { count = 20, interval_sec = 60 }
ban_duration_sec = 600
}
}
}
アカウント列挙対策(アプリ側)
_GENERIC_AUTH_FAILURE_MESSAGE = "メールアドレスまたはパスワードが正しくありません"
def authenticate(email, raw_password, credential_repo):
credential = credential_repo.find_by_email(email)
if credential is None:
raise AuthenticationFailedError(_GENERIC_AUTH_FAILURE_MESSAGE)
if not credential.verify_password(raw_password):
raise AuthenticationFailedError(_GENERIC_AUTH_FAILURE_MESSAGE)
return credential
アーキテクチャ図 — Cloud Armor 多層防御 × LB/NEG統一 × アカウント列挙対策
模範解答 B
# security_policy.tf — member-auth-api を保護するCloud Armorポリシー
resource "google_compute_security_policy" "member_auth_waf" {
name = "member-auth-waf-policy"
# 修正3: Adaptive Protection(L7 DDoS defense)を有効化
adaptive_protection_config {
layer_7_ddos_defense_config {
enable = true
}
}
# 修正4: 既知の悪性IP脅威インテリジェンスフィードを拒否
rule {
action = "deny(403)"
priority = 1000
match {
expr {
expression = "evaluatePreconfiguredExpr('sourceiplist-known-malicious-ips')"
}
}
description = "既知の悪性IP(脅威インテリジェンス)を拒否"
}
# 修正5: OWASP CRS SQLi/XSS対策。まずpreviewで誤検知を確認してからblock化する段階導入
rule {
action = "deny(403)"
priority = 2000
preview = true # 導入初期はログのみ。誤検知率を確認後にfalseへ変更してblock化
match {
expr {
expression = "evaluatePreconfiguredExpr('sqli-v33-stable') || evaluatePreconfiguredExpr('xss-v33-stable')"
}
}
description = "OWASP CRS: SQLi/XSS(段階導入中)"
}
# 修正2: ログインエンドポイント専用のレートベースルール(rate-based ban)
rule {
action = "rate_based_ban"
priority = 3000
match {
expr {
expression = "request.path.matches('/api/v1/login')"
}
}
rate_limit_options {
conform_action = "allow"
exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold {
count = 20 # 60秒間に20回まで許容
interval_sec = 60
}
ban_duration_sec = 600 # 超過IPは10分間ban
}
description = "ログイン試行: 1IPあたり60秒20回超で10分間ban"
}
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
description = "デフォルト許可(above以外)"
}
}
# 修正1: member-auth-apiをExternal HTTPS LB + Serverless NEG経由に統一
resource "google_compute_region_network_endpoint_group" "member_auth_neg" {
name = "member-auth-neg"
network_endpoint_type = "SERVERLESS"
region = "asia-northeast1"
cloud_run {
service = "member-auth-api"
}
}
resource "google_compute_backend_service" "member_auth_backend" {
name = "member-auth-backend"
protocol = "HTTPS"
load_balancing_scheme = "EXTERNAL_MANAGED"
security_policy = google_compute_security_policy.member_auth_waf.id # 修正1: Cloud Armorを必ず経由
backend {
group = google_compute_region_network_endpoint_group.member_auth_neg.id
}
}
"""login_response.py — アカウント列挙防止(修正6) × ログイン試行ログのマスキング(修正7)"""
from __future__ import annotations
import hashlib
import logging
from typing import Protocol
logger = logging.getLogger(__name__)
# 修正6: 理由によらず同一メッセージ(存在しないメール/パスワード不一致を区別させない)
_GENERIC_AUTH_FAILURE_MESSAGE = "メールアドレスまたはパスワードが正しくありません"
class AuthenticationFailedError(Exception):
"""認証失敗(理由の詳細はログにのみ残し、レスポンスには含めない)。"""
class CredentialRepository(Protocol):
def find_by_email(self, email: str) -> "AuthCredential | None": ...
def _hash_email(email: str) -> str:
"""メールアドレスをハッシュ化してログに残す(生のPIIをログに出さない)。"""
return hashlib.sha256(email.encode()).hexdigest()[:12]
def authenticate(email: str, raw_password: str, credential_repo: CredentialRepository) -> "AuthCredential":
"""メールアドレス・パスワードで認証する。
Args:
email: 入力されたメールアドレス。
raw_password: 入力された平文パスワード。
credential_repo: 認証情報リポジトリ。
Returns:
認証に成功した AuthCredential。
Raises:
AuthenticationFailedError: メール未登録・パスワード不一致のいずれの場合も
同一メッセージで送出する(アカウント列挙対策)。
"""
credential = credential_repo.find_by_email(email)
if credential is None:
logger.info("login failed: email not found", extra={"email_hash": _hash_email(email)})
raise AuthenticationFailedError(_GENERIC_AUTH_FAILURE_MESSAGE) # 修正6: メール未登録も同一メッセージ
if not credential.verify_password(raw_password):
logger.info("login failed: password mismatch", extra={"email_hash": _hash_email(email)})
raise AuthenticationFailedError(_GENERIC_AUTH_FAILURE_MESSAGE) # 修正6: 不一致も同一メッセージ・同一例外型
return credential
class PasswordMaskingFilter(logging.Filter):
"""修正7: ログレコードからraw_password等の機密フィールドをマスキングするフィルタ。"""
_SENSITIVE_KEYS = frozenset({"raw_password", "password", "password_hash"})
def filter(self, record: logging.LogRecord) -> bool:
for key in self._SENSITIVE_KEYS:
if hasattr(record, key):
setattr(record, key, "***MASKED***")
return True
Bad vs Good 設計比較
| 観点 | Bad(現状) | Good(改善後) |
|---|---|---|
| 公開経路 | Cloud Run直接URL、LB/Cloud Armor未経由 | External HTTPS LB + Serverless NEG経由でCloud Armor必須適用 |
| レート制限 | なし、秒間数百回のログイン試行が可能 | rate_based_ban: 60秒20回超で10分間ban |
| L7 DDoS対策 | なし | Adaptive Protection有効化 |
| 悪性IP | フィルタなし | 脅威インテリジェンスフィードでdeny(403) |
| WAF | SQLi/XSS対策なし | OWASP CRSルール(preview→block段階導入) |
| アカウント列挙 | 未登録メール/パスワード不一致でエラーメッセージが異なる | AuthenticationFailedErrorで理由によらず統一メッセージ |
| ログ | raw_password等が構造化ログにそのまま出力されうる | PasswordMaskingFilterでマスキング、メールもハッシュ化して出力 |
確認コマンド
# 1. Adaptive Protectionが有効化されていることを確認
gcloud compute security-policies describe member-auth-waf-policy --format="yaml(adaptiveProtectionConfig)"
# Expected: layer7DdosDefenseConfig.enable: true
# 2. レートベースルールが/api/v1/loginに適用されていることを確認
gcloud compute security-policies rules list --security-policy member-auth-waf-policy \
--format="table(priority,action,description)"
# Expected: priority=3000, action=rate_based_ban が一覧に含まれる
# 3. レートベースbanの動作確認(同一IPから25回連続ログイン試行)
for i in $(seq 1 25); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://member-auth.example.com/api/v1/login \
-d '{"email":"a@example.com","password":"x"}'
done
# Expected: 20回目付近までは401、それ以降は429(ban)が返る
# 4. アカウント列挙対策の確認(未登録メール・パスワード不一致でメッセージが一致すること)
curl -s -X POST https://member-auth.example.com/api/v1/login \
-d '{"email":"not-exist@example.com","password":"x"}' | jq .message
curl -s -X POST https://member-auth.example.com/api/v1/login \
-d '{"email":"real@example.com","password":"wrong"}' | jq .message
# Expected: 両方とも "メールアドレスまたはパスワードが正しくありません" で一致
# 5. OWASP CRSがpreviewモード(まだblockしていない)であることを確認
gcloud compute security-policies rules describe 2000 --security-policy member-auth-waf-policy \
--format="value(preview)"
# Expected: True(段階導入中)
ポイント解説
カテゴリ A
1
目的別モデリングは「1つのクラスが複数の変更理由を持つ」状態を解消する(Ch14)
Member は認証・表示・LTV・ポイントという別々の理由で変更されうるクラスだった。AuthCredential/MemberProfile/PointBalance のように目的ごとに専用クラスへ分割することで、それぞれが単一の理由でのみ変更される状態になる。
2
「Userクラスの落とし穴」は不要な機密情報まで運んでしまうことに現れる(Ch14)
render_profile_page が Member 全体を受け取ると、表示ロジックが password_hash に触れられる状態になってしまう。必要な値(profile/rank/ltv)だけを渡すことで、依存範囲が最小化される。
3
モデルからインフラ依存(DB接続)を排除すると単体テストが書きやすくなる(Ch6-7)
MemberLifetimeValueCalculator が OrderRepository プロトコルを注入される形にすることで、テスト時はフェイクリポジトリに差し替えられ、実DBなしで Member 関連ロジックを検証できる。
カテゴリ B
4
多層防御は「1つのルールに頼らない」ことが本質
レートベースban・Adaptive Protection・脅威インテリジェンス・WAF(OWASP CRS)はそれぞれ異なる攻撃パターンに効く。レート制限だけでは分散した多数のIPからの低頻度アクセスを防ぎきれないため、悪性IPフィードとの組み合わせが必要になる。
レートベースban・Adaptive Protection・脅威インテリジェンス・WAF(OWASP CRS)はそれぞれ異なる攻撃パターンに効く。レート制限だけでは分散した多数のIPからの低頻度アクセスを防ぎきれないため、悪性IPフィードとの組み合わせが必要になる。
5
WAFルールは
正規表現ベースのOWASP CRSルールは誤検知(正規リクエストの誤ブロック)を起こしうるため、まずログのみで様子を見て、誤検知率を確認してから実際にブロックする運用に切り替える。
previewからblockへ段階導入するのが実務上の定石正規表現ベースのOWASP CRSルールは誤検知(正規リクエストの誤ブロック)を起こしうるため、まずログのみで様子を見て、誤検知率を確認してから実際にブロックする運用に切り替える。
6
アカウント列挙対策とログのマスキングは「攻撃者に情報を与えない」という同じ思想の異なる実装箇所
前者はレスポンス(攻撃者から見える情報)、後者はログ(内部の運用者が見る情報だが漏洩リスクがある)という異なる経路だが、どちらも「本来渡す必要のない情報を渡さない」という設計判断で防げる。
前者はレスポンス(攻撃者から見える情報)、後者はログ(内部の運用者が見る情報だが漏洩リスクがある)という異なる経路だが、どちらも「本来渡す必要のない情報を渡さない」という設計判断で防げる。
実務への応用
- 目的別モデリングは、MOpsの他の集約(注文・商品・クーポン等)にも同じ形で横展開できる: 「このクラスは何種類の理由で変更されうるか」をレビュー観点に加えると、
Orderクラスが決済・配送・返品ロジックを兼務していないか等のUserクラスの落とし穴を早期に発見できる - PointBalanceのようなカプセル化パターンは、在庫数・クーポン残高・キャンペーン予算消化額など「直接の増減で不整合を起こしやすい数値」全般に適用できる: フィールドを公開せずメソッド経由の変更に限定するだけで、不正な状態遷移を即座に検知できる
- Cloud Armorのレートベースban + Adaptive Protectionの組み合わせは、ログインAPIに限らずクーポンコード検証API・レビュー投稿API・パスワードリセットAPIなど、総当たり攻撃の対象になりうる全エンドポイントに横展開できる標準構成
- アカウント列挙対策(統一エラーメッセージ)は、ログインだけでなくパスワードリセット・会員登録の「既存メールアドレスです」のようなエラー文言にも同じ観点で見直しが必要: 個別に実装されがちな箇所なので、セキュリティレビューのチェックリスト化が有効
今日のまとめ
カテゴリAでは、認証・ランク判定・表示整形・LTV集計・ポイント管理・ステータス管理という6つの異なる関心事を抱え込んでいた
カテゴリBでは、その認証情報を扱う
Member クラスを、AuthCredential/MemberProfile/PointBalance/MemberLifetimeValueCalculator という目的別のクラス・関数に分割し、「Userクラスの落とし穴」(表示処理が不要な認証情報まで運んでしまう問題)を解消した(Ch14、Ch3/Ch6-7/Ch10)。カテゴリBでは、その認証情報を扱う
member-auth-api のログインエンドポイントを、Cloud Armorのレートベースban・Adaptive Protection・脅威インテリジェンス・OWASP CRSという多層防御で保護し、アプリ側でもアカウント列挙対策とログのマスキングを実装した。どちらも共通するのは「本来渡す必要のない情報(認証情報・パスワード・アカウント存在有無)を、渡さなくてよい設計に変える」という思想である。
次のステップ
- 発展問題:
Memberに「複数デバイスからの同時ログイン検知」機能を追加する際、AuthCredentialに新しい関心事を足さずにLoginSessionという別クラスとして表現する方法を検討する(目的別モデリングの実践応用, Ch14) - 発展問題:
member-auth-apiのログイン失敗が閾値を超えたIPを Cloud Armor の拒否リストに自動追加する Cloud Function(rate_based_banのログをトリガーに動的ブロックリストを更新)を設計する - 参考: 「良いコード・悪いコードで学ぶ設計入門」Ch3(カプセル化)/ Ch6-7(関心の分離)/ Ch10(設計の悪魔)/ Ch14(モデリング)、Cloud Armor Adaptive Protection、OWASP Core Rule Set (CRS)、OWASP Credential Stuffing Prevention Cheat Sheet