Сайт ведёт сессию на куках#
Вход на обычный сайт занимает несколько шагов. Страница входа ставит одни куки, форма входа другие, переход на почту третьи, и каждый следующий запрос должен нести то, что поставили предыдущие. Браузер делает это сам. Перечислять куки в операциях и носить их от ответа к запросу руками не нужно.
Объявите 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, а не клиенту и не соединению.
Ситуация |
Что происходит |
|---|---|
два |
куки не смешиваются |
один |
куки общие |
роутеры одного корня |
делят куки одного пользователя |
|
пользователь начинает с готового набора, например из браузера |
Каждая кука хранится со своей областью действия: хост или домен с поддоменами, путь, срок,
признак Secure. Кука без атрибута Domain возвращается только на тот хост, который её
поставил. Кука без срока хранится наравне с остальными.
Между запусками#
Снимок кук сохраняется вместе с сессией в том же SessionStore, под той же ревизией. Новый
процесс читает его из хранилища и продолжает без входа.
Библиотека не очищает куки сама: ни перед входом, ни после отказа. Кука исчезает, когда её
удалил сервер или истёк срок. Так сайт узнаёт устройство при повторном входе и реже просит
второй фактор. Чтобы начать с чистого листа, удалите запись пользователя вызовом
store.invalidate(key) или заведите новый Identity.
Куки, которые выбирает вызывающий#
Правило одно. Куку, которую поставил сайт, несёт библиотека. Куку, значение которой выбирает вызывающий, объявляют полем операции.
Чьё значение |
Как объявить |
|---|---|
сайта, из |
|
вызывающего: язык, согласие, параметр из документации |
поле |
Поле Cookie[str] перекрывает одноимённую куку сайта только в этом запросе и не сохраняется.
Http.get(..., cookies=False) выводит операцию из общего порядка: она не получает кук и не
сохраняет их.
Значение куки как данные читает маркер FromCookie("act") в модели ответа. Он нужен, когда
значение идёт в тело запроса, например защитный токен формы.
Что проверяется при объявлении#
Объявление |
Причина отказа |
|---|---|
|
сайт либо ведёт сессию сам, либо получает ключ от вызывающего |
заголовок |
заголовок строит библиотека |
|
отказываться не от чего |
|
куки некуда положить |
Вход, после которого сайт не поставил куку из required, заканчивается ошибкой проверки
сессии: обмен завершился, а сессии нет.