コンテンツへスキップ

Basic 認証の裏にある WordPress サイトを保守する — Playwright × urllib × 暗号化保存

ステージング環境や社内テスト用の WordPress サイトに、念のため HTTP Basic 認証をかけているケースは結構あります。本番公開前のサイト・社員しか触らないテストインスタンス・特定 IP からだけ参照したい環境 — どれもログイン画面より外側に「もう 1 段の壁」を置く運用です。

保守ツールの目線でこれを扱おうとすると、独特の「半分動く・半分動かない」非対称な状態に陥ります。SSH/WP-CLI 経由のメンテナンス本体は普通に通る。一方で、HTTP ベースの確認系(ビジュアルチェック・サムネ取得・ブラウザ補完更新)が全部 401 で死ぬ。今回はこの非対称を解いた話を整理しておきます。

何が起きていたか — 2 系統が同時に詰まる

Basic 認証で保護されたサイトに対して、保守ツールが触る経路は実は 2 系統あります。

  • Playwright 経路: ビジュアルチェック・サムネ取得・SSH 不可環境でのブラウザ補完更新。browser.new_context() → ページ移動 → スクリーンショット
  • urllib 経路: HTTP ステータスチェック(更新前後の 200/5xx/4xx 監視・ロールバック判定)

両方とも、認証情報なしで Basic 認証保護サイトにアクセスすると 401 Unauthorized が返ってくる

Playwright 側の症状は分かりやすくて、撮影されるスクリーンショットがブラウザ標準の「認証が必要です」ダイアログ画像になります。サムネ一覧に黒っぽい認証画面のサムネが並んで「これ本当に動いてる?」感が出ます。

urllib 側はもっと悪質で、ロールバック判定が壊れる。更新前ベースラインで 401・更新後も 401 だと、「変化なし = 健全」と誤判定されて、本来異常が起きていてもロールバックが発動しない経路があります。

設計 — 認証情報の取り出しを 1 つのヘルパーに集約

両経路に同じ認証情報を渡す必要がある時、各箇所で個別に site dict から取り出すと、書き漏らしや形式の不一致が起こりやすい。最初に core/basic_auth_utils.py という小さなヘルパーモジュールを 1 つ作って、そこに全形式の取り出し関数を集めることにしました。

# core/basic_auth_utils.py
def get_basic_auth_tuple(site):
    """site から (user, password) のタプルを返す。未設定なら None。"""
    if not isinstance(site, dict):
        return None
    user = (site.get('basic_auth_user') or '').strip()
    pw = site.get('basic_auth_password') or ''
    if not user:
        return None  # user が空なら認証なしと判定
    return (user, pw)

def get_playwright_http_credentials(site):
    """Playwright の new_context(http_credentials=...) 用 dict。"""
    auth = get_basic_auth_tuple(site)
    if auth is None:
        return None
    return {'username': auth[0], 'password': auth[1]}

def get_basic_auth_header(site):
    """urllib などの HTTP ヘッダー用 {'Authorization': 'Basic <base64>'}。"""
    auth = get_basic_auth_tuple(site)
    if auth is None:
        return {}
    raw = f"{auth[0]}:{auth[1]}".encode('utf-8')
    encoded = base64.b64encode(raw).decode('ascii')
    return {'Authorization': f'Basic {encoded}'}

ポイントは 同じ get_basic_auth_tuple() を起点に、Playwright 形式・urllib 形式の両方を派生させていること。2 系統で (user, password) の解釈がズレる事故を構造的に防げます。

後方互換 — 既存サイトには新フィールドが無い

ここで地味に大事だったのが、既存のサイト設定 JSON には basic_auth_user / basic_auth_password キーが存在しないこと。何も考えずに site['basic_auth_user'] のように書くと、既存サイトを開いた瞬間に KeyError でクラッシュする経路ができてしまいます。

採用したのは site.get('basic_auth_user') or '' の空文字フォールバックパターン。「キー無し / 空文字 / None」のいずれでも「認証なし」と判定されて、既存サイトは何の影響も受けません。Basic 認証を実際に設定したサイトでだけ user が真値になって、認証経路に乗ります。

加えて、Basic 認証のパスワードは WordPress 管理者パスワードと同じく Fernet 暗号化で保存する設計にしました。ENCRYPTED_SITE_KEYS 定数に 'basic_auth_password' を追加するだけで、保存・読み込み時に自動で暗号化・復号されます。

両経路への配線

ヘルパーが揃ったところで、各呼び出し箇所を配線します。

Playwright 経路 には _new_context_with_auth(browser, site) という共通ヘルパーを 1 つ作って、new_context() を呼んでいた 3 箇所(visual check / thumbnail 取得 / browser residual update)を一括置換しました。

def _new_context_with_auth(browser, site):
    http_credentials = get_playwright_http_credentials(site)
    if http_credentials:
        return browser.new_context(http_credentials=http_credentials)
    return browser.new_context()

urllib 経路_http_status_check(url, basic_auth=None) の関数シグネチャに basic_auth 引数を追加し、内部で Authorization ヘッダーを送信。これにより「ベースライン 401 → 更新後 401」の偽陰性が消えて、本来の HTTP ステータス(認証突破後の 200/5xx)で正しくロールバック判定できるようになりました。

メンテナンス本体の run_ssh_maintenance 内では、site から _basic_auth一度だけ取り出して、5 箇所の _http_status_check_stable() 呼び出しに引き継ぐ形にしています。毎回 site dict から取り出すと「ある箇所だけ取り出し忘れる」事故が起きるので、ローカル変数化が安全策。

UI — 一般サイトを圧迫しない折りたたみ

ユーザーから見ると、Basic 認証保護サイトは少数派です。すべてのサイト追加モーダルに常時露出した入力欄を 2 つ並べると、99% のサイトでは無関係なフィールドが場所を取ることになります。

そこで、サイト追加/編集モーダルの WordPress 情報セクション末尾に <details> 要素で デフォルト折り畳まれた「🔐 Basic 認証(任意)」セクション を追加しました。

<details>
  <summary>🔐 Basic 認証(任意)</summary>
  <input name="basic_auth_user" placeholder="認証ユーザー名">
  <input type="password" name="basic_auth_password"
         placeholder="認証パスワード">
</details>

ベーシック認証保護サイトのみ展開して入力する設計で、一般サイトの UI は何も変わりません。保存時にパスワードは ENC: プレフィックス付きで Fernet 暗号化されます。

まとめ — 「同じ認証情報を別経路に渡す」設計の引き出し

このラウンドから取り出せる原則は次の 3 つでした。

  1. 複数経路に同じ認証情報を渡す時はヘルパーに集約する — Playwright 形式・urllib 形式・bool 判定など、利用者ごとに必要な形が違っても、(user, password) という共通の起点関数からすべて派生させる設計にすると、形式ズレの事故が起こらない
  2. 既存データに新フィールドを足す時は空フォールバックパターンsite.get(key) or '' で「キー無し = 認証なし」と読み替えれば、既存サイトの挙動を 1 mm も変えずに新機能を上乗せできる。マイグレーション不要
  3. 少数派の機能は折り畳みで隠す — 99% の利用者に関係ないフィールドを常時露出させると UI が圧迫される。<details> 折り畳み + 控えめなアイコンで「任意機能」を表現すると、必要なユーザーだけ展開する流れを作れる

ステージング環境を Basic 認証で守っている運用は WordPress 界隈では珍しくありません。自動化ツール側で「半分動く」状態が続いていると、結局そのサイトだけは手動運用に逃げざるを得なくなる。全機能を一気通貫で動かすために、認証経路の対応を 1 度きちんと設計しておく価値はあると思います。