Капча на входе#
Аккаунт captcha@mail.example принимает пароль, но до второго фактора требует проверку
mail-login. Эта капча придумана для учебного сайта. Решатель меняет состояние текущего
клиента, после чего шаг пароля продолжается.
Линия |
Что делает решатель |
Что продолжает работу |
|---|---|---|
HTTP |
кладёт |
тот же POST повторяется по политике replay |
Браузер |
нажимает кнопку виджета |
тот же вызов операции дожидается страницы кода |
→ Request · POST /login/password
{
"login_id": "login:captcha@mail.example",
"password": "correct"
}
← Response · 403 Forbidden
{"kind": "captcha", "site_key": "mail-login"}
LoginCaptchaGuard распознаёт ответ до разбора обычных ошибок. Политика
rejected_before_execution(max_replays=1) разрешает ровно один повтор: сервер отклонил запрос
до выполнения входа, поэтому повтор не дублирует результат.
# examples/mail/http/captcha_probe.py
@dataclass(frozen=True, slots=True)
class LoginCaptcha:
site_key: str
class LoginCaptchaGuard(Guard[LoginCaptcha]):
"""Apply a teaching clearance cookie, then repeat the rejected login step."""
scope = host("mail.example")
replay = rejected_before_execution(max_replays=1)
def __init__(self) -> None:
self.solved: list[str] = []
def detect(self, response: ResponseContext[object]) -> LoginCaptcha | None:
if response.response.status_code != 403:
return None
document = response.json.value
if not isinstance(document, dict) or document.get("kind") != "captcha":
return None
site_key = document.get("site_key")
return LoginCaptcha(site_key) if isinstance(site_key, str) else None
def solve(self, challenge: LoginCaptcha, context: SolveContext) -> GuardSolution:
self.solved.append(challenge.site_key)
return self.solution(cookies={"login_clearance": "solved"})
После решения cookie попадает в тот же HTTP-клиент, а координатор заново готовит запрос пароля.
# examples/mail/http/captcha.py
def login_after_captcha() -> tuple[str, int, str | None, str]:
site = MailSite()
guard = LoginCaptchaGuard()
config = ClientConfig(security=Security.of(guard))
with configured_mail_client(site, config) as client:
login = LoginApi(client)
password = login.identify(email="captcha@mail.example")
otp = login.password(login_id=password.login_id, password=VALID_INPUT)
return (
guard.solved[0],
len(site.state.password_cookies),
site.state.password_cookies[1],
type(otp).__name__,
)
Файлы примера целиком
"""Solve a captcha on the password step and replay that step once."""
from __future__ import annotations
from dataclasses import dataclass
from eazy_sdk.protection import (
Guard,
GuardSolution,
SolveContext,
host,
rejected_before_execution,
)
from eazy_sdk.response import ResponseContext
# region docs: http-captcha-guard
# examples/mail/http/captcha_probe.py
@dataclass(frozen=True, slots=True)
class LoginCaptcha:
site_key: str
class LoginCaptchaGuard(Guard[LoginCaptcha]):
"""Apply a teaching clearance cookie, then repeat the rejected login step."""
scope = host("mail.example")
replay = rejected_before_execution(max_replays=1)
def __init__(self) -> None:
self.solved: list[str] = []
def detect(self, response: ResponseContext[object]) -> LoginCaptcha | None:
if response.response.status_code != 403:
return None
document = response.json.value
if not isinstance(document, dict) or document.get("kind") != "captcha":
return None
site_key = document.get("site_key")
return LoginCaptcha(site_key) if isinstance(site_key, str) else None
def solve(self, challenge: LoginCaptcha, context: SolveContext) -> GuardSolution:
self.solved.append(challenge.site_key)
return self.solution(cookies={"login_clearance": "solved"})
# endregion docs: http-captcha-guard
"""Solve the HTTP captcha and replay the rejected password request."""
from __future__ import annotations
from eazy_sdk import ClientConfig, Security
from examples.mail.http._client import configured_mail_client
from examples.mail.http.captcha_probe import LoginCaptchaGuard
from examples.mail.http.login_probe import VALID_INPUT, LoginApi
from examples.mail.site import MailSite
# region docs: http-captcha-flow
# examples/mail/http/captcha.py
def login_after_captcha() -> tuple[str, int, str | None, str]:
site = MailSite()
guard = LoginCaptchaGuard()
config = ClientConfig(security=Security.of(guard))
with configured_mail_client(site, config) as client:
login = LoginApi(client)
password = login.identify(email="captcha@mail.example")
otp = login.password(login_id=password.login_id, password=VALID_INPUT)
return (
guard.solved[0],
len(site.state.password_cookies),
site.state.password_cookies[1],
type(otp).__name__,
)
# endregion docs: http-captcha-flow
def main() -> None:
challenge, requests, cookie, next_step = login_after_captcha()
print(f"challenge solved: {challenge}")
print(f"password requests: {requests}")
print(f"replay cookie: {cookie}")
print(f"next step: {next_step}")
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.http.captcha.
challenge solved: mail-login
password requests: 2
replay cookie: login_clearance=solved
next step: OtpStep
После отправки пароля MailSite возвращает этот фрагмент:
<main data-page="captcha" data-login-id="login:captcha@mail.example"><div class="captcha" data-site-key="mail-login">Подтвердите вход</div><form method="post" action="/login/captcha"><input name="login_id" type="hidden" value="login:captcha@mail.example"><button data-action="solve" type="submit">Я не робот</button></form></main>
Handle проверяется раньше Failure. Он нажимает кнопку решения, страница переходит к форме
кода, и исход to=OtpStep завершает исходный вызов password(...).
# examples/mail/browser/captcha.py
CAPTCHA = Handle(
when=visible(css('[data-page="captcha"]')),
do=click(css('[data-action="solve"]')),
)
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitPasswordWithCaptcha(BrowserOperation[PasswordPage, OtpStep]):
__browser__ = Browser.act(
PasswordPage,
at=css('input[name="password"]'),
handlers=(CAPTCHA,),
errors=PASSWORD_FAILURES,
outcomes=outcomes(
when(visible(css('input[name="code"]')), to=OtpStep),
otherwise=_password_stalled,
),
)
password: str
async def act(self, content: PasswordPage) -> None:
await content.password.fill(self.password)
await content.submit.click()
class CaptchaLoginPortal(AsyncBrowserApi):
errors = ACCOUNT_FAILURES
identify = op(Identify)
password = op(SubmitPasswordWithCaptcha)
otp = op(SubmitOtp)
# examples/mail/browser/captcha.py
async def login_after_captcha() -> tuple[str, int, str]:
async with playwright_mail() as runtime:
portal = CaptchaLoginPortal(runtime.client)
password = await portal.identify(email="captcha@mail.example", options=OPTIONS)
otp = await password.api.password(password=VALID_LOGIN_INPUT, options=OPTIONS)
return (
runtime.site.state.captcha_solves[0],
len(runtime.site.state.password_cookies),
type(otp).__name__,
)
Файл примера целиком
"""Solve the browser captcha without leaving the password operation."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import NoReturn
from eazy_sdk import op
from eazy_sdk_browser import (
AsyncBrowserApi,
Browser,
BrowserCallOptions,
BrowserOperation,
Handle,
click,
css,
outcomes,
visible,
when,
)
from examples.mail.browser.login_probe import (
ACCOUNT_FAILURES,
PASSWORD_FAILURES,
VALID_LOGIN_INPUT,
Identify,
OtpStep,
PasswordPage,
SubmitOtp,
)
from examples.mail.site._playwright import playwright_mail
OPTIONS = BrowserCallOptions(timeout=5.0)
async def _password_stalled(content: PasswordPage) -> NoReturn:
_ = content
raise TimeoutError("OTP step did not appear")
# region docs: browser-captcha-operation
# examples/mail/browser/captcha.py
CAPTCHA = Handle(
when=visible(css('[data-page="captcha"]')),
do=click(css('[data-action="solve"]')),
)
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitPasswordWithCaptcha(BrowserOperation[PasswordPage, OtpStep]):
__browser__ = Browser.act(
PasswordPage,
at=css('input[name="password"]'),
handlers=(CAPTCHA,),
errors=PASSWORD_FAILURES,
outcomes=outcomes(
when(visible(css('input[name="code"]')), to=OtpStep),
otherwise=_password_stalled,
),
)
password: str
async def act(self, content: PasswordPage) -> None:
await content.password.fill(self.password)
await content.submit.click()
class CaptchaLoginPortal(AsyncBrowserApi):
errors = ACCOUNT_FAILURES
identify = op(Identify)
password = op(SubmitPasswordWithCaptcha)
otp = op(SubmitOtp)
# endregion docs: browser-captcha-operation
# region docs: browser-captcha-flow
# examples/mail/browser/captcha.py
async def login_after_captcha() -> tuple[str, int, str]:
async with playwright_mail() as runtime:
portal = CaptchaLoginPortal(runtime.client)
password = await portal.identify(email="captcha@mail.example", options=OPTIONS)
otp = await password.api.password(password=VALID_LOGIN_INPUT, options=OPTIONS)
return (
runtime.site.state.captcha_solves[0],
len(runtime.site.state.password_cookies),
type(otp).__name__,
)
# endregion docs: browser-captcha-flow
async def run() -> None:
challenge, requests, next_step = await login_after_captcha()
print(f"challenge solved: {challenge}")
print(f"password submits: {requests}")
print(f"same operation: {next_step}")
def main() -> None:
asyncio.run(run())
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.browser.captcha.
challenge solved: mail-login
password submits: 1
same operation: OtpStep
Примечание
Повтор HTTP ограничен политикой replay. Браузер не отправляет пароль второй раз: обработчик виджета остаётся внутри уже начатой операции и ждёт её обычный исход.