Сайт ведёт сессию на куках#

Вход на обычный сайт занимает несколько шагов. Страница входа ставит одни куки, форма входа другие, переход на почту третьи, и каждый следующий запрос должен нести то, что поставили предыдущие. Браузер делает это сам. Перечислять куки в операциях и носить их от ответа к запросу руками не нужно.

Объявите Cookies на корне SDK. С этого момента каждая кука, которую поставил сайт, возвращается туда, куда положено: в следующую операцию, в следующий шаг перехода, на другой хост того же сайта.

class MailSdk(AsyncRoot):
    cookies = Cookies(required=("sid",))

    login = api_group(LoginApi)
    mail = api_group(MailApi)

required=("sid",) называет куку, без которой сессии нет. Это единственное место, где SDK упоминает куку сайта по имени.

Шаги входа#

Шаги входа остаются обычными операциями. Первый читает исход из адреса перехода, второй открывает страницу почты и достаёт токен из встроенного скрипта. Ни одна операция не объявляет поле для куки.

# examples/docs/auth_redirect_login.py
@dataclass(frozen=True, slots=True)
class SignedIn:
    url: Annotated[str, Location(path="/inbox*")]


@dataclass(frozen=True, slots=True)
class Banned:
    errno: Annotated[str, Location.query("errno"), Const("25")]


@dataclass(frozen=True, slots=True)
class Rejected:
    fail: Annotated[str, Location.query("fail")]


class AccountBanned(ApiError[Banned]):
    pass


class InvalidCredentials(ApiError[Rejected]):
    pass


class MailPage(BaseModel):
    title: Annotated[str, CSS("title::text"), Const("Mail")]
    token: Annotated[str, Regex(r'"account":\{"token":"([^"]+)"')]
    email: Annotated[str, Regex(r'"account":\{[^}]*"email":"([^"]+)"')]


@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitCredentials(HttpOperation[SignedIn]):
    __http__ = Http.post(
        "/cgi-bin/auth",
        security=None,
        success={302: SignedIn},
        errors={302: [AccountBanned, InvalidCredentials]},
    )

    username: Form[str]
    password: Form[str]


@dataclass(frozen=True, slots=True, kw_only=True)
class OpenInbox(HttpOperation[MailPage]):
    __http__ = Http.get("/mailbox/", security=None)


class LoginApi(AsyncApi):
    submit = op(SubmitCredentials)
    inbox = op(OpenInbox)


Сервис входа вызывает шаги по порядку. Кука sid из ответа на первый шаг уходит во второй запрос без его участия.

class MailSession(BaseModel):
    token: Annotated[SecretStr, Placed.query("token")]
    email: Annotated[str, Placed.query("email", secret=False)]


MAIL_SESSION = session_scheme(MailSession, name="mail-session")


class MailLoginService:
    async def acquire(self, credentials: MailLogin, context: AuthContext[MailSdk]) -> MailSession:
        await context.sdk.login.submit(
            username=credentials.username,
            password=credentials.password.get_secret_value(),
        )
        page = await context.sdk.login.inbox()
        return MailSession(token=SecretStr(page.token), email=page.email)


Модель сессии держит только то, что SDK кладёт в запрос сам: токен и адрес ящика. Кук в ней нет. На сайте, где вся сессия живёт в куках, модель может не размещать ничего.

Запускаемый файл целиком
"""Sign in to a site that keeps its session in cookies and answers the login with a redirect."""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from typing import Annotated, Self

import httpx
from eazy_sdk_html import CSS, Regex
from pydantic import BaseModel, SecretStr

from eazy_sdk import (
    AsyncApi,
    AsyncRoot,
    ClientConfig,
    Http,
    HttpOperation,
    Identity,
    Resilience,
    api_group,
    op,
)
from eazy_sdk.auth import AuthContext, Cookies, Placed, session_scheme
from eazy_sdk.handlers.httpx import AsyncHttpxHandler
from eazy_sdk.request import Form
from eazy_sdk.response import ApiError, Const, Location
from examples.docs.redirect_site import BASE_URL, MAILBOX, PASSWORD, RedirectSite


class MailLogin(BaseModel):
    username: str
    password: SecretStr


class Folders(BaseModel):
    folders: list[str]


# region docs: redirect-login-steps
# examples/docs/auth_redirect_login.py
@dataclass(frozen=True, slots=True)
class SignedIn:
    url: Annotated[str, Location(path="/inbox*")]


@dataclass(frozen=True, slots=True)
class Banned:
    errno: Annotated[str, Location.query("errno"), Const("25")]


@dataclass(frozen=True, slots=True)
class Rejected:
    fail: Annotated[str, Location.query("fail")]


class AccountBanned(ApiError[Banned]):
    pass


class InvalidCredentials(ApiError[Rejected]):
    pass


class MailPage(BaseModel):
    title: Annotated[str, CSS("title::text"), Const("Mail")]
    token: Annotated[str, Regex(r'"account":\{"token":"([^"]+)"')]
    email: Annotated[str, Regex(r'"account":\{[^}]*"email":"([^"]+)"')]


@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitCredentials(HttpOperation[SignedIn]):
    __http__ = Http.post(
        "/cgi-bin/auth",
        security=None,
        success={302: SignedIn},
        errors={302: [AccountBanned, InvalidCredentials]},
    )

    username: Form[str]
    password: Form[str]


@dataclass(frozen=True, slots=True, kw_only=True)
class OpenInbox(HttpOperation[MailPage]):
    __http__ = Http.get("/mailbox/", security=None)


class LoginApi(AsyncApi):
    submit = op(SubmitCredentials)
    inbox = op(OpenInbox)


# endregion docs: redirect-login-steps


# region docs: redirect-login-session
class MailSession(BaseModel):
    token: Annotated[SecretStr, Placed.query("token")]
    email: Annotated[str, Placed.query("email", secret=False)]


MAIL_SESSION = session_scheme(MailSession, name="mail-session")


class MailLoginService:
    async def acquire(self, credentials: MailLogin, context: AuthContext[MailSdk]) -> MailSession:
        await context.sdk.login.submit(
            username=credentials.username,
            password=credentials.password.get_secret_value(),
        )
        page = await context.sdk.login.inbox()
        return MailSession(token=SecretStr(page.token), email=page.email)


# endregion docs: redirect-login-session


@dataclass(frozen=True, slots=True, kw_only=True)
class ListFolders(HttpOperation[Folders]):
    __http__ = Http.get("/api/folders")


class MailApi(AsyncApi):
    security = MAIL_SESSION

    folders = op(ListFolders)


# region docs: redirect-login-root
class MailSdk(AsyncRoot):
    cookies = Cookies(required=("sid",))

    login = api_group(LoginApi)
    mail = api_group(MailApi)
    # endregion docs: redirect-login-root

    @classmethod
    def open(cls, site: RedirectSite, credentials: MailLogin) -> Self:
        auth = MAIL_SESSION.configure(credentials=credentials, service=MailLoginService())
        raw = httpx.AsyncClient(transport=httpx.MockTransport(site), headers={}, cookies={})
        return cls.from_handler(
            handler=AsyncHttpxHandler(raw, owns_client=True),
            base_url=BASE_URL,
            config=ClientConfig(resilience=Resilience(max_redirects=3, client_redirects=True)),
            identity=Identity(auth=(auth,)),
        )


# region docs: redirect-login-run
async def main() -> None:
    site = RedirectSite()
    async with MailSdk.open(site, MailLogin(username=MAILBOX, password=SecretStr(PASSWORD))) as sdk:
        folders = await sdk.mail.folders()
        await sdk.mail.folders()
    print(f"folders: {', '.join(folders.folders)}")
    print(*site.calls, sep="\n")

    wrong = MailLogin(username=MAILBOX, password=SecretStr("wrong-password"))
    async with MailSdk.open(RedirectSite(), wrong) as sdk:
        try:
            await sdk.mail.folders()
        except InvalidCredentials as error:
            print(f"wrong password: fail {error.error.fail}")


# endregion docs: redirect-login-run


if __name__ == "__main__":
    asyncio.run(main())
folders: Inbox, Sent
POST /cgi-bin/auth
GET /mailbox/
GET /sdc
GET /mailbox/
GET /api/folders
GET /api/folders
wrong password: fail 1

Вход выполнен один раз: второй вызов folders() использует ту же сессию. Отказ входа доходит до вызывающего тем типом, каким его объявила операция.

Переход, записанный в странице#

Между вторым и четвёртым запросом видно, чего SDK не объявлял. Учебный сайт отвечает на первое открытие почты страницей-заглушкой с <meta http-equiv="refresh">, которая отправляет на второй хост. Тот ставит ещё одну куку и возвращает обратно обычным 302. Почта открывается только после этого.

Такой переход клиент выполняет сам, если его включить: Resilience(max_redirects=3, client_redirects=True). Он расходует тот же запас, что и 302. Куки, поставленные по дороге, доходят до следующего шага.

Страница считается заглушкой, только если её не приняла ни одна модель операции. Страницу, которую операция описала, клиент не трогает.

Чьи это куки#

Куки принадлежат пользователю, то есть объекту Identity, а не клиенту и не соединению.

Ситуация

Что происходит

два Identity на одном клиенте

куки не смешиваются

один Identity на двух клиентах

куки общие

роутеры одного корня

делят куки одного пользователя

Identity(cookies=...)

пользователь начинает с готового набора, например из браузера

Каждая кука хранится со своей областью действия: хост или домен с поддоменами, путь, срок, признак Secure. Кука без атрибута Domain возвращается только на тот хост, который её поставил. Кука без срока хранится наравне с остальными.

Между запусками#

Снимок кук сохраняется вместе с сессией в том же SessionStore, под той же ревизией. Новый процесс читает его из хранилища и продолжает без входа.

Библиотека не очищает куки сама: ни перед входом, ни после отказа. Кука исчезает, когда её удалил сервер или истёк срок. Так сайт узнаёт устройство при повторном входе и реже просит второй фактор. Чтобы начать с чистого листа, удалите запись пользователя вызовом store.invalidate(key) или заведите новый Identity.

Куки, которые выбирает вызывающий#

Правило одно. Куку, которую поставил сайт, несёт библиотека. Куку, значение которой выбирает вызывающий, объявляют полем операции.

Чьё значение

Как объявить

сайта, из Set-Cookie

Cookies(...) на корне, имя писать не нужно

вызывающего: язык, согласие, параметр из документации

поле Cookie[str] у операции

Поле Cookie[str] перекрывает одноимённую куку сайта только в этом запросе и не сохраняется. Http.get(..., cookies=False) выводит операцию из общего порядка: она не получает кук и не сохраняет их.

Значение куки как данные читает маркер FromCookie("act") в модели ответа. Он нужен, когда значение идёт в тело запроса, например защитный токен формы.

Что проверяется при объявлении#

Объявление

Причина отказа

CookieScheme в security вместе с Cookies

сайт либо ведёт сессию сам, либо получает ключ от вызывающего

заголовок Cookie полем или Inject

заголовок строит библиотека

cookies=False на сервисе без Cookies

отказываться не от чего

Identity(cookies=...) для сервиса без Cookies

куки некуда положить

Вход, после которого сайт не поставил куку из required, заканчивается ошибкой проверки сессии: обмен завершился, а сессии нет.

Дальше#