A+B 複合 — 目的別モデリング × 単一責任 × Userクラスの落とし穴(会員(Member)管理 Bad→Good Ch14)× member-auth-api Cloud Armor クレデンシャルスタッフィング対策(LB+Serverless NEG統一 × rate_based_ban × Adaptive Protection × 脅威インテリジェンス × OWASP CRS段階導入 × アカウント列挙対策 × ログマスキング)

2026-08-08 (Day 123) 土曜複合問題 ★★★★☆ Python 3.12 / dataclass / StrEnum / Protocol / 目的別モデリング GCP Cloud Armor / External HTTPS LB / Serverless NEG

概要

🧩

目的別モデリングは「1つのクラスの複数の変更理由」を解消する

認証・ランク判定・表示整形・LTV集計・ポイント管理・ステータス管理が1つのMemberクラスに同居していた。目的ごとに専用クラスへ分割し、それぞれが単一の理由でのみ変更される状態にする(Ch14)。

🕳️

「Userクラスの落とし穴」は不要な機密情報を運んでしまうことに現れる

表示専用のrender_profile_pagepassword_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 — アプローチ
  • AuthCredentialmember_id/password_hash/verify_password)を認証専用の値オブジェクトとして独立させる
  • MemberRankStrEnum にし、ランク判定は resolve_rank(total_purchase_amount_yen) という純粋関数に切り出す
  • PointBalance をエンティティとして用意し、_amount を非公開にして add()/use()(残高不足は例外)経由でのみ変更可能にする
  • MemberProfileemail のみ)で表示専用のマスキング処理を担い、render_profile_page には Member 全体ではなく profile/rank/ltv だけを渡す
  • LTV集計は OrderRepository プロトコルを注入された MemberLifetimeValueCalculator に担わせ、Member からDB接続を排除する
  • statusMembershipStatusStrEnum にする
ヒント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

#問題点分類改善方法
16つの関心事が1クラスに同居目的別モデリングの欠如 Ch14AuthCredential/MemberProfile等へ分割
2Member自身がSQLを発行インフラ依存 Ch14/Ch6-7MemberLifetimeValueCalculatorへ委譲
3pointsが直接書き換え可能カプセル化の欠如 Ch3PointBalance.add()/use()経由に限定
4statusがマジックストリング設計の悪魔 Ch10MembershipStatus StrEnum
5表示関数がMember全体を受領Userクラスの落とし穴 Ch14profile/rank/ltvのみ受領
6ランク閾値がマジックナンバー設計の悪魔 Ch10名前付き定数化
7DTO/エンティティ/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 等の機密フィールドがそのまま出力されうる設計

要件

#要件
1member-auth-api の前段に External HTTPS LB + Serverless NEG を配置し、Cloud Armor セキュリティポリシーを適用すること
2/api/v1/loginrate-based ban(同一IPあたり60秒20回超で一定時間ban)を設定すること
3Adaptive Protection(L7 DDoS defense) を有効化すること
4脅威インテリジェンスの既知悪性IPフィードで拒否ルールを設定すること
5OWASP 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_policyadaptive_protection_config.layer_7_ddos_defense_config.enable = true を設定
  • rate_based_ban アクションのルールで match.expr.expressionrequest.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統一 × アカウント列挙対策

攻撃者/ボットネット クレデンシャルスタッフィング SQLi/XSS/ブルートフォース 正規会員 通常のログイン試行 (60秒20回以内) Cloud Armor Security Policy (member-auth-waf-policy) 修正3: Adaptive Protection (L7 DDoS defense) 異常トラフィックパターンを自動検知・緩和 修正4: priority=1000 悪性IP脅威インテリジェンス sourceiplist-known-malicious-ips → deny(403) 修正5: priority=2000 OWASP CRS (SQLi/XSS) preview=true で段階導入 → 誤検知確認後 block化 修正2: priority=3000 rate_based_ban (/api/v1/login) 60秒間20回まで許容(conform) → 超過は10分間ban(429) enforce_on_key = IP priority=2147483647 デフォルト allow 上記いずれにも該当しない正規トラフィックを許可 External HTTPS LB 修正1: 直接URL公開を廃止 Serverless NEG 経由に統一 member-auth-api 問題AのAuthCredentialを利用 GKE Autopilot アプリケーション層の対策 修正6: アカウント列挙対策 Before: メール未登録/PW不一致で メッセージが異なる After: 常に同一メッセージ 修正7: ログマスキング Before: raw_password等が平文で 構造化ログに出力されうる After: PasswordMaskingFilter適用 ✓ 直接URL公開廃止 ✓ Adaptive Protection ✓ 悪性IPフィード拒否 ✓ OWASP CRS段階導入 ✓ rate_based_ban ✓ アカウント列挙対策 ✓ ログマスキング 攻撃トラフィック 正規トラフィック

模範解答 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)
WAFSQLi/XSS対策なしOWASP CRSルール(previewblock段階導入)
アカウント列挙未登録メール/パスワード不一致でエラーメッセージが異なる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_pageMember 全体を受け取ると、表示ロジックが password_hash に触れられる状態になってしまう。必要な値(profile/rank/ltv)だけを渡すことで、依存範囲が最小化される。
3 モデルからインフラ依存(DB接続)を排除すると単体テストが書きやすくなる(Ch6-7)
MemberLifetimeValueCalculatorOrderRepository プロトコルを注入される形にすることで、テスト時はフェイクリポジトリに差し替えられ、実DBなしで Member 関連ロジックを検証できる。

カテゴリ B

4 多層防御は「1つのルールに頼らない」ことが本質
レートベースban・Adaptive Protection・脅威インテリジェンス・WAF(OWASP CRS)はそれぞれ異なる攻撃パターンに効く。レート制限だけでは分散した多数のIPからの低頻度アクセスを防ぎきれないため、悪性IPフィードとの組み合わせが必要になる。
5 WAFルールはpreviewからblockへ段階導入するのが実務上の定石
正規表現ベースのOWASP CRSルールは誤検知(正規リクエストの誤ブロック)を起こしうるため、まずログのみで様子を見て、誤検知率を確認してから実際にブロックする運用に切り替える。
6 アカウント列挙対策とログのマスキングは「攻撃者に情報を与えない」という同じ思想の異なる実装箇所
前者はレスポンス(攻撃者から見える情報)、後者はログ(内部の運用者が見る情報だが漏洩リスクがある)という異なる経路だが、どちらも「本来渡す必要のない情報を渡さない」という設計判断で防げる。

実務への応用

  • 目的別モデリングは、MOpsの他の集約(注文・商品・クーポン等)にも同じ形で横展開できる: 「このクラスは何種類の理由で変更されうるか」をレビュー観点に加えると、Orderクラスが決済・配送・返品ロジックを兼務していないか等のUserクラスの落とし穴を早期に発見できる
  • PointBalanceのようなカプセル化パターンは、在庫数・クーポン残高・キャンペーン予算消化額など「直接の増減で不整合を起こしやすい数値」全般に適用できる: フィールドを公開せずメソッド経由の変更に限定するだけで、不正な状態遷移を即座に検知できる
  • Cloud Armorのレートベースban + Adaptive Protectionの組み合わせは、ログインAPIに限らずクーポンコード検証API・レビュー投稿API・パスワードリセットAPIなど、総当たり攻撃の対象になりうる全エンドポイントに横展開できる標準構成
  • アカウント列挙対策(統一エラーメッセージ)は、ログインだけでなくパスワードリセット・会員登録の「既存メールアドレスです」のようなエラー文言にも同じ観点で見直しが必要: 個別に実装されがちな箇所なので、セキュリティレビューのチェックリスト化が有効

今日のまとめ

カテゴリAでは、認証・ランク判定・表示整形・LTV集計・ポイント管理・ステータス管理という6つの異なる関心事を抱え込んでいた Member クラスを、AuthCredential/MemberProfile/PointBalance/MemberLifetimeValueCalculator という目的別のクラス・関数に分割し、「Userクラスの落とし穴」(表示処理が不要な認証情報まで運んでしまう問題)を解消した(Ch14、Ch3/Ch6-7/Ch10)。

カテゴリBでは、その認証情報を扱う member-auth-api のログインエンドポイントを、Cloud ArmorのレートベースbanAdaptive 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

自己評価(あとで記入)

自分の回答

気づき・メモ