Files
Octop/tests/unit/auth/test_captcha_store.py
HUANG Cheng dcb680b421 feat(auth): optional login captcha (slider + tencent/turnstile/hcaptcha/recaptcha) (#679)
* feat(auth): add optional login captcha (slider, turnstile/hcaptcha/recaptcha, tencent)

Password login can now be guarded by a captcha. Default slider stays
client-only; strong providers are verified server-side with the vendor
(Cloudflare Turnstile, hCaptcha, reCAPTCHA v2/v3, Tencent Cloud Captcha).

- infra/auth/captcha: env snapshot + settings blob resolution, provider
  registry with per-vendor verify calls, siteverify with 10s timeout
- GET /api/auth/captcha (public widget config); POST /api/auth/login
  accepts captcha_token; tencent token carries ticket:randstr and is
  checked via GET with the client IP
- admin GET/PUT /api/settings/captcha behind the new captcha permission;
  OCTOP_CAPTCHA_SECRET redacted in envs API; boot validation for strong
  env providers
- dashboard: CaptchaField with slider/checkbox/invisible/popup modes,
  login page wiring, advanced-settings captcha panel
- docs + i18n (en/zh) + unit/integration/frontend tests

* fix(captcha): verify Tencent tickets via DescribeCaptchaResult (TC3)

The legacy ssl.captcha.qq.com/ticket/verify endpoint rejects current
tickets with response=15 "decrypt fail" even for correct
CaptchaAppId/AppSecretKey pairs. Switch to the official ticket-check API:

- POST captcha.tencentcloudapi.com DescribeCaptchaResult (2019-07-22),
  TC3-HMAC-SHA256 signed with CAM API keys; AppSecretKey stays in the body
- captcha settings gain cam_secret_id/cam_secret (encrypted at rest,
  env fallback OCTOP_CAPTCHA_CAM_SECRET_ID/KEY); settings view exposes
  cam_secret_id + has_cam_secret; dashboard shows the two fields for the
  tencent provider only
- interpret maps CaptchaCode (1 OK; 7/8/9/15/16/21/100 fail) and
  EvilLevel=100 (malicious) instead of the legacy response=="1"
- TC3 signer moved voice/tencent_sign.py -> utils/tencent_sign.py (shared)
- drop the redundant Host header from signed requests: httpx sets Host
  from the URL (same value the signature covers), and an explicit Host
  let host-routing system proxies intercept localhost mock calls
- secrets no longer appear in httpx URL logs (payload is in the body)
- move the Login Captcha settings tab next to HTTPS

* polish(dashboard): show reCAPTCHA v3 min score only when that provider is active

* fix(dashboard): captcha widgets follow the site locale, not the browser

- tencent popup: userLanguage from i18n.language (was navigator.language)
- turnstile: pass language render option
- hcaptcha/recaptcha/recaptcha-v3: hl script param from UI locale
- slider already renders site-provided labels

* feat(captcha): unlist reCAPTCHA v2 from the settings catalog

No verified deployment key for v2; keep the provider registered so
existing configs still verify, but stop offering it in the dropdown
(list_providers now filters on a per-provider listed flag).

* feat(cli): octop captcha reset — offline lockout escape hatch

Clears the stored captcha settings blob so login falls back to the
built-in slider when a misconfigured provider blocks all logins
(settings UI requires login, so it cannot fix itself). Boot-env
captcha is untouched; a running server needs a restart.
CHANGELOG: cover reset command, TC3 switch, widget language, v2 unlist.

* feat(cli): register octop captcha reset command and add changelog

Without the registry entry the command module from c5a7649d was
unreachable; CHANGELOG covers the login captcha feature, the TC3
ticket-check switch, widget locale, v2 unlisting, and captcha reset.

* fix(captcha): close pre-merge gaps + document the provider architecture

- envs API: redact OCTOP_CAPTCHA_CAM_SECRET_KEY like OCTOP_CAPTCHA_SECRET
  (was exposed in plaintext by GET /api/envs)
- validate_boot: env-only tencent provider now requires the CAM keys,
  failing fast at boot instead of at first login
- docs/configuration.md: document the two CAM env vars; note recaptcha
  v2 is unlisted but resolvable
- providers.py: architecture docstring (four layers + settled design
  decisions + adding-a-vendor recipe); rename _SuccessProvider to
  _FormPostProvider; drop the register() cast, which exposed a real
  structural bug: frozen-dataclass providers never satisfied the
  Protocol's mutable metadata members — declare them read-only
  properties so the structural check is real
- verify.py: docstring naming it the execution layer
2026-09-16 21:23:32 +08:00

132 lines
4.7 KiB
Python

"""Effective captcha config — settings blob vs env snapshot."""
from __future__ import annotations
import json
from pathlib import Path
from octop.infra.auth.captcha import snapshot_env
from octop.infra.auth.captcha.store import SETTINGS_KEY, load_effective, public_config
from octop.infra.auth.sso.crypto import encrypt_secret
from octop.infra.db.migrate import run_migrations
from octop.infra.db.pool import SqlitePool
from octop.infra.db.repos.secrets import SecretRepo
from octop.infra.db.repos.settings import SettingsRepo
def _repos(tmp_path: Path) -> tuple[SettingsRepo, SecretRepo]:
db = SqlitePool(tmp_path / "octop.db")
run_migrations(db)
return SettingsRepo(db), SecretRepo(db)
def _put_blob(
settings: SettingsRepo,
secrets: SecretRepo,
*,
active: str,
providers: dict[str, dict[str, str]],
) -> None:
encoded: dict[str, dict[str, str]] = {}
for slug, pair in providers.items():
row: dict[str, str] = {}
if "site_key" in pair:
row["site_key"] = pair["site_key"]
if "secret" in pair:
row["secret_enc"] = encrypt_secret(secrets, pair["secret"]).decode("ascii")
encoded[slug] = row
settings.set(SETTINGS_KEY, json.dumps({"active": active, "providers": encoded}))
def test_no_blob_uses_slider_env(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
env = snapshot_env({})
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "slider"}
assert effective.source == "env"
def test_settings_slider_beats_strong_env(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
_put_blob(settings, secrets, active="slider", providers={})
env = snapshot_env(
{
"OCTOP_CAPTCHA_PROVIDER": "turnstile",
"OCTOP_CAPTCHA_SITE_KEY": "0xenv",
"OCTOP_CAPTCHA_SECRET": "env-secret",
}
)
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "slider"}
assert effective.source == "settings"
def test_complete_settings_strong_wins_over_different_env(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
_put_blob(
settings,
secrets,
active="hcaptcha",
providers={"hcaptcha": {"site_key": "hc-site", "secret": "hc-secret"}},
)
env = snapshot_env(
{
"OCTOP_CAPTCHA_PROVIDER": "turnstile",
"OCTOP_CAPTCHA_SITE_KEY": "0xenv",
"OCTOP_CAPTCHA_SECRET": "env-secret",
}
)
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "hcaptcha", "site_key": "hc-site"}
assert effective.secret == "hc-secret"
assert effective.source == "settings"
def test_incomplete_pair_gap_fills_same_provider_env(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
settings.set(SETTINGS_KEY, json.dumps({"active": "turnstile", "providers": {}}))
env = snapshot_env(
{
"OCTOP_CAPTCHA_PROVIDER": "turnstile",
"OCTOP_CAPTCHA_SITE_KEY": "0xenv",
"OCTOP_CAPTCHA_SECRET": "env-secret",
}
)
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "turnstile", "site_key": "0xenv"}
assert effective.secret == "env-secret"
assert effective.source == "settings"
def test_incomplete_pair_without_env_fill_degrades_to_slider(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
settings.set(SETTINGS_KEY, json.dumps({"active": "hcaptcha", "providers": {}}))
env = snapshot_env({})
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "slider"}
assert effective.source == "settings"
def test_unknown_stored_active_degrades_to_slider(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
settings.set(SETTINGS_KEY, json.dumps({"active": "not-registered", "providers": {}}))
env = snapshot_env({})
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "slider"}
assert effective.stored_active == "not-registered"
def test_unreadable_blob_falls_through_to_env(tmp_path: Path) -> None:
settings, secrets = _repos(tmp_path)
settings.set(SETTINGS_KEY, "{not-json")
env = snapshot_env(
{
"OCTOP_CAPTCHA_PROVIDER": "turnstile",
"OCTOP_CAPTCHA_SITE_KEY": "0xenv",
"OCTOP_CAPTCHA_SECRET": "env-secret",
}
)
effective = load_effective(settings, secrets, env)
assert public_config(effective) == {"provider": "turnstile", "site_key": "0xenv"}
assert effective.source == "env"