mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-23 06:50:19 +08:00
* 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
132 lines
4.7 KiB
Python
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"
|