feat(rpa_helpers): add auth_flows.py with 9 ensure_logged_in_* and dispatcher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 11:56:32 +08:00
parent c23240d0ea
commit 104aa91364

View File

@@ -0,0 +1,449 @@
# -*- coding: utf-8 -*-
"""Auth strategy helpers: ensure_logged_in_* + dispatcher (no DB / no CLI)."""
from __future__ import annotations
import hashlib
import json
import re
import socket
import sys
import time
import uuid
from dataclasses import dataclass
from urllib.parse import urlparse
from .human import human_click, human_type
def _poll_until(
predicate,
timeout_sec: int,
interval_sec: float = 2.0,
) -> bool:
"""轮询 predicate 直到返回 True 或超时。"""
deadline = time.time() + timeout_sec
while time.time() < deadline:
try:
if predicate():
return True
except Exception:
pass
time.sleep(interval_sec)
return False
def _stderr_prompt(msg: str) -> None:
sys.stderr.write(f"[auth_flows] {msg}\n")
sys.stderr.flush()
@dataclass
class LoginResult:
ok: bool
needs_human: bool
message: str
class AuthStrategyError(Exception):
"""auth_strategy 相关错误(不可自动化 / 不支持 / fingerprint 不匹配等)。"""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
def _account_extra(account: dict) -> dict:
ex = account.get("extra_json")
if ex is None:
return {}
if isinstance(ex, dict):
return ex
if isinstance(ex, str):
try:
return json.loads(ex)
except json.JSONDecodeError:
return {}
return {}
def _is_already_logged_in(page, account: dict) -> bool:
"""粗略判断是否已登录(可被 extra_json.logged_in_url_pattern 覆盖)。"""
try:
cur = page.url or ""
except Exception:
return False
extra = _account_extra(account)
pattern = extra.get("logged_in_url_pattern")
if isinstance(pattern, str) and pattern:
try:
return bool(re.search(pattern, cur))
except re.error:
pass
lc = cur.lower()
if any(k in lc for k in ("login", "signin", "sign-in", "auth", "/sso/")):
return False
try:
login_btn = page.locator(
"button:has-text('登录'), button:has-text('Sign in'), "
"a:has-text('登录'), a:has-text('Sign in')"
)
if login_btn.count() > 0:
try:
if login_btn.first.is_visible(timeout=500):
return False
except Exception:
pass
except Exception:
pass
return True
def _current_machine_fingerprint() -> str:
"""hostname + MAC 的 sha256 前 16 位。"""
h = hashlib.sha256()
h.update(socket.gethostname().encode("utf-8"))
h.update(str(uuid.getnode()).encode("utf-8"))
return h.hexdigest()[:16]
def _login_selectors(account: dict) -> tuple[str, str, str]:
extra = _account_extra(account)
selectors = extra.get("login_selectors") or {}
login_id_sel = (
selectors.get("login_id")
or "input[name*='user'], input[name*='login'], input[name*='account'], input[type='email']"
)
password_sel = selectors.get("password") or "input[type='password']"
submit_sel = (
selectors.get("submit")
or "button[type='submit'], button:has-text('登录'), button:has-text('Sign in')"
)
return str(login_id_sel), str(password_sel), str(submit_sel)
def _require_login_id(account: dict) -> str | None:
lid = account.get("login_id")
if lid is None:
return None
s = str(lid).strip()
return s or None
def ensure_logged_in_password_auto(page, account: dict, password: str, **_kwargs) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(
ok=True,
needs_human=False,
message="already logged in (profile reused)",
)
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
human_click(page.locator(sub_sel).first)
ok = _poll_until(lambda: _is_already_logged_in(page, account), 30, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=False,
message="login succeeded" if ok else "login timeout",
)
def ensure_logged_in_password_with_captcha(
page,
account: dict,
password: str,
*,
captcha_value: str | None = None,
wait_human_sec: int = 60,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
extra = _account_extra(account)
selectors = extra.get("login_selectors") or {}
captcha_sel = (
selectors.get("captcha") or "input[name*='captcha'], input[name*='verify_code']"
)
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
if captcha_value is not None:
human_type(page.locator(captcha_sel).first, str(captcha_value))
human_click(page.locator(sub_sel).first)
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=False,
message="login succeeded" if ok else "captcha flow timeout",
)
_stderr_prompt("请输入图形验证码后回车继续CTRL+C 取消)")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
return LoginResult(
ok=ok,
needs_human=True,
message="captcha human assist completed" if ok else "captcha human assist timeout",
)
def _is_2fa_page(page) -> bool:
try:
u = (page.url or "").lower()
if any(x in u for x in ("2fa", "verify", "mfa")):
return True
otp = page.locator("input[name*='code'], input[name*='otp']")
if otp.count() > 0:
return True
except Exception:
pass
return False
def ensure_logged_in_password_plus_2fa(
page,
account: dict,
password: str,
*,
wait_human_sec: int = 120,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
login_id = _require_login_id(account)
if not login_id:
return LoginResult(ok=False, needs_human=False, message="login_id missing in account")
lid_sel, pwd_sel, sub_sel = _login_selectors(account)
human_type(page.locator(lid_sel).first, login_id)
human_type(page.locator(pwd_sel).first, password)
human_click(page.locator(sub_sel).first)
_poll_until(lambda: _is_2fa_page(page) or _is_already_logged_in(page, account), 30, interval_sec=1.0)
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="login succeeded without 2FA")
_stderr_prompt("请输入 2FA 验证码并提交")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=1.0)
msg = "2FA completed" if ok else "2FA timeout ERR_LOGIN_2FA_TIMEOUT"
return LoginResult(ok=ok, needs_human=True, message=msg)
def _qr_locator(page):
return page.locator("canvas, img[src*='qr'], img[alt*='二维码']")
def ensure_logged_in_qr_code_manual(page, account: dict, *, wait_human_sec: int = 300, **_kwargs) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
qr = _qr_locator(page)
try:
qr.first.wait_for(state="visible", timeout=30000)
except Exception:
return LoginResult(ok=False, needs_human=True, message="qr code not found")
_stderr_prompt("请用手机扫描浏览器中的二维码")
def _qr_done() -> bool:
if _is_already_logged_in(page, account):
return True
try:
av = page.locator("img[alt*='avatar']")
if av.count() > 0 and av.first.is_visible(timeout=300):
return True
except Exception:
pass
return False
ok = _poll_until(_qr_done, wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="qr scan completed" if ok else "qr scan timeout",
)
def ensure_logged_in_per_session_manual(
page,
account: dict,
*,
wait_human_sec: int = 600,
**_kwargs,
) -> LoginResult:
url = account.get("url") or ""
if url:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
_stderr_prompt(f"请完整登录后等待 RPA 继续(最长 {wait_human_sec} 秒)")
ok = _poll_until(lambda: _is_already_logged_in(page, account), wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="per-session manual completed" if ok else "per-session manual timeout",
)
def ensure_logged_in_device_bound(
page,
account: dict,
password: str | None = None,
*,
wait_human_sec: int = 120,
**_kwargs,
) -> LoginResult:
expected_fp = account.get("device_fingerprint")
if expected_fp:
cur = _current_machine_fingerprint()
if cur != str(expected_fp):
raise AuthStrategyError(
"ERR_DEVICE_FINGERPRINT_MISMATCH",
f"current machine fingerprint {cur!r} does not match account's {expected_fp!r}",
)
if password:
return ensure_logged_in_password_auto(page, account, password)
return ensure_logged_in_per_session_manual(page, account, wait_human_sec=wait_human_sec)
def ensure_logged_in_client_certificate(page, account: dict, **_kwargs) -> LoginResult:
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_AUTOMATABLE",
"client_certificate strategy requires manual cert + PIN; not automatable by rpa_helpers",
)
def ensure_logged_in_api_token(page, account: dict, **_kwargs) -> LoginResult:
return LoginResult(ok=True, needs_human=False, message="api_token: no browser login required")
def _default_sso_hosts(account: dict) -> list[str]:
ex = _account_extra(account)
hosts = ex.get("sso_redirect_hosts")
if isinstance(hosts, list) and hosts:
return [str(h) for h in hosts if h]
return ["accounts.google.com", "login.microsoftonline.com", "okta.com"]
def _url_has_host_fragment(url: str, hosts: list[str]) -> bool:
u = (url or "").lower()
return any(h.lower() in u for h in hosts)
def ensure_logged_in_sso_redirect(
page,
account: dict,
*,
wait_human_sec: int = 300,
**_kwargs,
) -> LoginResult:
if _is_already_logged_in(page, account):
return LoginResult(ok=True, needs_human=False, message="already logged in (profile reused)")
url = account.get("url") or ""
if not url:
return LoginResult(ok=False, needs_human=True, message="account url missing for SSO flow")
target_netloc = (urlparse(url).netloc or "").lower()
page.goto(url, wait_until="domcontentloaded", timeout=60000)
hosts = _default_sso_hosts(account)
seen_sso = _poll_until(lambda: _url_has_host_fragment(page.url or "", hosts), min(60, wait_human_sec), 2.0)
if not seen_sso:
return LoginResult(ok=False, needs_human=True, message="sso redirect not detected")
_stderr_prompt("请在弹出的 SSO 页面完成登录")
def _back_home() -> bool:
try:
cur = (urlparse(page.url).netloc or "").lower()
if target_netloc and cur == target_netloc:
return True
except Exception:
pass
return _is_already_logged_in(page, account)
ok = _poll_until(_back_home, wait_human_sec, interval_sec=2.0)
return LoginResult(
ok=ok,
needs_human=True,
message="sso completed" if ok else "sso timeout",
)
def ensure_logged_in(
page,
account: dict,
*,
credentials: dict | None = None,
**kwargs,
) -> LoginResult:
"""根据 account['auth_strategy'] 分派到对应 helper。"""
strat = (account.get("auth_strategy") or "").strip()
if not strat:
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
"account has no auth_strategy set",
)
def _need_password() -> str:
if not credentials or not credentials.get("plaintext"):
raise AuthStrategyError(
"ERR_CREDENTIAL_MISSING",
f"auth_strategy={strat!r} requires credentials.plaintext",
)
return str(credentials["plaintext"])
if strat == "password_auto":
return ensure_logged_in_password_auto(page, account, _need_password(), **kwargs)
if strat == "password_with_captcha":
return ensure_logged_in_password_with_captcha(page, account, _need_password(), **kwargs)
if strat == "password_plus_2fa":
return ensure_logged_in_password_plus_2fa(page, account, _need_password(), **kwargs)
if strat == "qr_code_manual":
return ensure_logged_in_qr_code_manual(page, account, **kwargs)
if strat == "per_session_manual":
return ensure_logged_in_per_session_manual(page, account, **kwargs)
if strat == "device_bound":
password = None
if credentials and credentials.get("plaintext"):
password = str(credentials["plaintext"])
return ensure_logged_in_device_bound(page, account, password, **kwargs)
if strat == "client_certificate":
return ensure_logged_in_client_certificate(page, account)
if strat == "api_token":
return ensure_logged_in_api_token(page, account)
if strat == "sso_redirect":
return ensure_logged_in_sso_redirect(page, account, **kwargs)
raise AuthStrategyError(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"unknown auth_strategy: {strat!r}",
)