Капча на входе#

Аккаунт captcha@mail.example принимает пароль, но до второго фактора требует проверку mail-login. Эта капча придумана для учебного сайта. Решатель меняет состояние текущего клиента, после чего шаг пароля продолжается.

Линия

Что делает решатель

Что продолжает работу

HTTP

кладёт login_clearance=solved в cookie

тот же 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. Браузер не отправляет пароль второй раз: обработчик виджета остаётся внутри уже начатой операции и ждёт её обычный исход.

Дальше#