feat: session login + authed client

Task 1.4 — 会话层. Adds the authenticated HTTP client that every yunke
scraper will use to call the CRM API.

- app/session/login.py: get_secure_key + pc_login wrappers, LoginResult
  dataclass, and a clearly-marked Phase-0 swappable encrypt_password
  placeholder (real RSA per getSecureKey is a TODO; drop-in contract
  documented in the docstring).
- app/session/yunke_client.py: YunkeClient wrapping httpx.AsyncClient.
  Constructor takes either (username+password) for auto-login or
  (userid+company[+cookies]) for an existing session. Injects the 5
  observed auth headers (userid/company/source=yunkepc/channel=4/
  content-type=application/json) on every request, auto-logs-in on first
  request, and on HTTP 401 (or body code 401) re-logs-in once and
  retries — a persistent 401 raises after the single retry so there is
  no infinite loop.
- Tests (respx): login populates LoginResult + httpx cookie jar; every
  request carries all 5 auth headers + cookie; 401 triggers exactly one
  re-login + retry and returns 200; persistent 401 raises after one
  retry; first request with no session auto-logs-in.

Session is held in-memory only for now; persistence to encrypted
YkAccount.session_blob_enc is left as a later concern (TODO in
yunke_client.py).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 11:22:47 +08:00
parent 9cf1c9ab11
commit 8243e9c6d2
4 changed files with 612 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""会话层 (session layer) for the yunke CRM API.
Public surface:
* :class:`app.session.login.LoginResult` — parsed login outcome (userid,
company, cookies snapshot, raw payload).
* :func:`app.session.login.get_secure_key` — ``GET /newuc/login/getSecureKey``.
* :func:`app.session.login.pc_login` — ``POST /usercenter/login/pcLogin``.
* :func:`app.session.login.encrypt_password` — Phase-0 swappable password
encryption (see file for the open TODO).
* :class:`app.session.yunke_client.YunkeClient` — the authenticated HTTP
client every scraper uses.
"""
from app.session.login import (
LoginResult,
encrypt_password,
get_secure_key,
pc_login,
)
from app.session.yunke_client import YunkeClient
__all__ = [
"LoginResult",
"YunkeClient",
"encrypt_password",
"get_secure_key",
"pc_login",
]
+167
View File
@@ -0,0 +1,167 @@
"""yunke CRM login flow: ``getSecureKey`` -> ``pcLogin``.
Real-API facts (from inspection — see design doc §6.1)
------------------------------------------------------
* ``GET /newuc/login/getSecureKey`` kicks off the login chain.
* ``POST /usercenter/login/pcLogin`` exchanges credentials for a session.
* After login every data request to ``crm2.yunkecn.com`` carries the headers
``userid`` / ``company`` / ``source=yunkepc`` / ``channel=4`` /
``content-type=application/json`` plus the login session cookie (handled
by the httpx client's cookie jar, not by this module).
Phase-0 unknowns (clearly marked)
---------------------------------
The exact ``pcLogin`` request body, the password encryption scheme (almost
certainly RSA using the modulus/exponent from ``getSecureKey``), and the
precise response JSON shape were NOT captured. To keep the session layer
end-to-end runnable today:
* :func:`encrypt_password` is a deterministic, documented placeholder. The
real RSA implementation drops in here later without touching call-sites.
* :func:`pc_login` parses ``userid`` / ``company`` out of a ``data`` object
with several reasonable key-name variants. When the real shape is captured
only this parser needs to change.
Tests mock both endpoints, so the placeholders are exercised end-to-end.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass, field
from typing import Any
import httpx
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class LoginResult:
"""Outcome of a successful yunke login.
Fields:
userid: 32-hex user id; sent as the ``userid`` header on every
subsequent request.
company: company code (e.g. ``"8m0atd"``); sent as the ``company``
header.
cookies: Snapshot of the session cookies set by ``pcLogin``. The
live cookies live on the httpx client's cookie jar; this
snapshot is kept so the session can later be persisted
(encrypted) into ``YkAccount.session_blob_enc``.
raw: The full ``pcLogin`` JSON body, kept for diagnostics and
for future field additions without re-rolling the parser.
Phase-0 assumption: ``userid`` and ``company`` come from the response
body's ``data`` object. Key-name variants tried in :func:`pc_login`:
``userid`` / ``userId`` / ``user_id`` and ``company`` / ``companyCode`` /
``company_code``.
"""
userid: str
company: str
cookies: dict[str, str] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Password encryption — Phase-0 placeholder
# ---------------------------------------------------------------------------
def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str:
"""Encrypt ``plain`` using the key material in ``secure_key``.
Phase-0 TODO: real RSA encryption per ``getSecureKey``.
The browser-side crypto was not captured. Based on the endpoint name
(``getSecureKey``) and the typical Chinese-CRM login idiom, the real
implementation almost certainly RSA-encrypts the password using a
modulus/exponent (or PEM public key) returned in ``secure_key`` and
then base64-encodes the ciphertext. Until that is confirmed by a real
capture, this function returns base64(UTF-8(plain)) so the login flow
is end-to-end runnable and deterministic for tests.
Drop-in contract for the real implementation:
def encrypt_password(plain: str, secure_key: dict[str, Any]) -> str:
# 1. Pull modulus/exponent (or PEM) out of secure_key.
# 2. RSA-OAEP (or PKCS#1 v1.5) encrypt UTF-8(plain).
# 3. Return base64(ciphertext)."
"""
# Phase 0 TODO: real RSA encryption per getSecureKey.
return base64.b64encode(plain.encode("utf-8")).decode("ascii")
# ---------------------------------------------------------------------------
# Endpoint wrappers
# ---------------------------------------------------------------------------
async def get_secure_key(client: httpx.AsyncClient) -> dict[str, Any]:
"""Call ``GET {base}/newuc/login/getSecureKey`` and return the JSON body.
The exact shape of the secure-key payload is a Phase-0 unknown; this
function returns the whole decoded JSON so :func:`encrypt_password` (and
eventually the real RSA step) can pick whatever fields it needs.
"""
resp = await client.get("/newuc/login/getSecureKey")
resp.raise_for_status()
return resp.json()
async def pc_login(
client: httpx.AsyncClient,
username: str,
password: str,
secure_key: dict[str, Any],
) -> LoginResult:
"""Call ``POST {base}/usercenter/login/pcLogin`` and parse a LoginResult.
Phase-0 assumptions about the request body (not captured):
``{"account": <username>, "password": <encrypt_password(...)>}``.
Real key names (``account`` vs ``loginName`` vs ``username``) and any
additional required fields (captcha, deviceId, ...) are to be confirmed;
only this function needs to change when they are.
Phase-0 assumptions about the response (not captured): JSON body with a
``data`` object containing ``userid`` + ``company``. Several reasonable
key-name variants are tried (see :func:`_pick`). The session cookie is
read back from the httpx client's cookie jar — ``pcLogin``'s
``Set-Cookie`` lands there automatically.
"""
body = {
"account": username,
"password": encrypt_password(password, secure_key),
}
resp = await client.post("/usercenter/login/pcLogin", json=body)
resp.raise_for_status()
payload = resp.json()
data = payload.get("data") if isinstance(payload, dict) else None
data = data if isinstance(data, dict) else {}
userid = _pick(data, "userid", "userId", "user_id") or _pick(
payload, "userid", "userId", "user_id"
)
company = _pick(data, "company", "companyCode", "company_code") or _pick(
payload, "company", "companyCode", "company_code"
)
cookies = {c.name: c.value for c in client.cookies.jar}
return LoginResult(
userid=userid or "",
company=company or "",
cookies=cookies,
raw=payload if isinstance(payload, dict) else {"data": payload},
)
def _pick(d: dict[str, Any], *keys: str) -> str | None:
"""Return the first present, truthy value among ``keys`` in ``d``."""
for k in keys:
v = d.get(k)
if v:
return str(v)
return None
+208
View File
@@ -0,0 +1,208 @@
"""Authenticated HTTP client for the yunke CRM API.
:class:`YunkeClient` is the single chokepoint every scraper uses to call the
CRM (see design doc §6.1 会话层). It wraps an :class:`httpx.AsyncClient` and:
* Auto-logs-in (``getSecureKey`` -> ``pcLogin``) on the first request when no
session is present, or when constructed with credentials.
* Injects the 5 known auth headers (``userid`` / ``company`` / ``source`` /
``channel`` / ``content-type``) on every request.
* Detects HTTP 401 (or a body-level session-expired marker), re-logs-in
*once*, and retries. A persistent 401 raises after the single retry — no
infinite loop.
Phase-0 note: the session is held in-memory only. Future work will persist
the encrypted session blob to ``YkAccount.session_blob_enc`` (see
``app.db.models.YkAccount``) so scraper processes can resume an existing
session instead of always logging in fresh.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import httpx
from app.session.login import (
LoginResult,
get_secure_key,
pc_login,
)
# The 4 constant auth headers. ``userid`` + ``company`` come from the
# LoginResult and are merged in separately.
_STATIC_AUTH_HEADERS: dict[str, str] = {
"source": "yunkepc",
"channel": "4",
"content-type": "application/json",
}
class YunkeClient:
"""Authenticated async HTTP client for the yunke CRM API."""
def __init__(
self,
base_url: str,
*,
username: str | None = None,
password: str | None = None,
userid: str | None = None,
company: str | None = None,
cookies: Mapping[str, str] | None = None,
client: httpx.AsyncClient | None = None,
) -> None:
"""Construct a client either from credentials or an existing session.
Credentials mode (auto-login capable):
Pass ``username`` + ``password``; ``login()`` works and 401s will
trigger automatic re-login.
Existing-session mode (no re-login possible):
Pass ``userid`` + ``company`` (optionally ``cookies``). ``login()``
raises if called — the caller has elected to skip credentials, so
nothing can re-login on a 401.
``client`` may be supplied to inject a pre-configured
:class:`httpx.AsyncClient` (e.g. for custom transports). When omitted
a new client bound to ``base_url`` is created and owned by this
instance (closed by :meth:`aclose`).
"""
has_creds = username is not None and password is not None
has_session = userid is not None and company is not None
if not has_creds and not has_session:
raise ValueError(
"YunkeClient requires either (username + password) for"
" auto-login or (userid + company) for an existing session."
)
self.base_url = base_url
self.username = username
self.password = password
self.login_result: LoginResult | None = None
self._owns_client = client is None
self._client = client or httpx.AsyncClient(base_url=base_url)
if has_session:
assert userid is not None and company is not None # for mypy
self.login_result = LoginResult(
userid=userid, company=company, cookies=dict(cookies or {})
)
for name, value in (cookies or {}).items():
self._client.cookies.set(name, value)
# -- properties ---------------------------------------------------------
@property
def client(self) -> httpx.AsyncClient:
"""The underlying httpx client (exposed for cookie inspection/tests)."""
return self._client
@property
def logged_in(self) -> bool:
return self.login_result is not None
# -- login --------------------------------------------------------------
async def login(self) -> LoginResult:
"""Run ``getSecureKey`` -> ``pcLogin`` and store the LoginResult.
Raises ``RuntimeError`` if this client was constructed without
credentials (existing-session mode) — there is nothing to log in
with.
"""
if self.username is None or self.password is None:
raise RuntimeError(
"YunkeClient.login() requires username + password; this"
" client was constructed from an existing session."
)
secure_key = await get_secure_key(self._client)
result = await pc_login(
self._client, self.username, self.password, secure_key
)
self.login_result = result
return result
# -- authed request -----------------------------------------------------
async def request(
self,
method: str,
path: str,
*,
headers: Mapping[str, str] | None = None,
_retried: bool = False,
**kwargs: Any,
) -> httpx.Response:
"""Send an authed request, auto-logging-in and re-logging-in on 401.
Per-request ``headers`` override the auth defaults for this call
only. ``_retried`` is an internal guard against infinite re-login
loops and must not be set by callers.
"""
if self.login_result is None:
await self.login()
user_headers = dict(headers or {})
merged = {**self._auth_headers(), **user_headers}
resp = await self._client.request(method, path, headers=merged, **kwargs)
if _is_session_expired(resp):
if _retried:
# Already retried once after a re-login — give up so a
# persistently dead session cannot loop forever.
raise httpx.HTTPStatusError(
"yunke session expired after re-login retry",
request=resp.request,
response=resp,
)
await self.login()
return await self.request(
method, path, headers=user_headers, _retried=True, **kwargs
)
return resp
async def get(self, path: str, **kwargs: Any) -> httpx.Response:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs: Any) -> httpx.Response:
return await self.request("POST", path, **kwargs)
# -- teardown -----------------------------------------------------------
async def aclose(self) -> None:
"""Close the underlying httpx client if this instance owns it."""
if self._owns_client:
await self._client.aclose()
# -- internals ----------------------------------------------------------
def _auth_headers(self) -> dict[str, str]:
if self.login_result is None:
raise RuntimeError("Not logged in") # pragma: no cover
return {
"userid": self.login_result.userid,
"company": self.login_result.company,
**_STATIC_AUTH_HEADERS,
}
def _is_session_expired(resp: httpx.Response) -> bool:
"""Heuristic: does this response indicate the session is dead?
* HTTP 401 -> yes.
* HTTP 200 with a body-level ``code`` of ``401`` (a common Chinese-CRM
idiom) -> yes. We deliberately stay narrow here — only the explicit
auth-failed code, not generic non-zero codes, to avoid re-login storms
on ordinary business errors.
"""
if resp.status_code == 401:
return True
try:
body = resp.json()
except (ValueError, httpx.DecodingError):
return False
return isinstance(body, dict) and body.get("code") in (401, "401")