Parcourir la source

Rewrite session scripts for new login flow

Fixes #1421
Zed il y a 3 semaines
Parent
commit
607765c463

+ 326 - 99
tools/create_session_browser.py

@@ -23,118 +23,353 @@ Output:
 import asyncio
 import json
 import os
+import shutil
 import sys
+import tempfile
 
-import nodriver as uc
+import zendriver as zd
+from zendriver import cdp
 import pyotp
 
 
-async def login_and_get_cookies(username, password, totp_seed=None, headless=False):
-    """Authenticate with X.com and extract session cookies"""
-    # Note: headless mode may increase detection risk from bot-detection systems
-    browser = await uc.start(headless=headless)
-    tab = await browser.get("https://x.com/i/flow/login")
+# Disable password manager to prevent the "Save password?" bubble from
+# stealing focus during automated login.
+_SEED_PREFS = {
+    "credentials_enable_service": False,
+    "profile": {"password_manager_enabled": False},
+}
+_BROWSER_ARGS = [
+    "--password-store=basic",
+    "--no-first-run",
+    "--no-default-browser-check",
+    "--disable-notifications",
+]
 
+
+def _log(*a):
+    print(*a, file=sys.stderr, flush=True)
+
+
+def _make_profile():
+    """Create a temp Chrome profile with password manager disabled."""
+    profile = tempfile.mkdtemp(prefix="xsess_")
+    default = os.path.join(profile, "Default")
+    os.makedirs(default)
+    with open(os.path.join(default, "Preferences"), "w") as f:
+        json.dump(_SEED_PREFS, f)
+    return profile
+
+
+def _extract_user_id(cookies_dict):
+    """Extract numeric user ID from the twid cookie."""
+    twid = cookies_dict.get("twid", "").strip('"')
+    for prefix in ("u%3D", "u="):
+        if prefix in twid:
+            return twid.split(prefix)[1].split("&")[0].strip('"')
+    return None
+
+
+async def _check_login_error(tab):
+    """Check if the login flow is showing an error (wrong password, etc.)."""
     try:
-        # Enter username
-        print(f"[*] Entering username {username}...", file=sys.stderr)
-
-        retry = 0
-        while retry < 5:
-            username_input = await tab.find(
-                'input[autocomplete="username"]', timeout=10
-            )
-
-            pos = await username_input.get_position()
-            await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
-            await asyncio.sleep(0.1)
-
-            await username_input.click()
-            await asyncio.sleep(0.5)
-            await username_input.send_keys(username)
-            await asyncio.sleep(0.2)
-            await username_input.send_keys("\n")
-            await asyncio.sleep(2)
+        return await tab.evaluate('''(() => {
+            // Check role="alert" elements (X's standard error display)
+            const alert = document.querySelector('[role="alert"]');
+            if (alert) {
+                const t = alert.textContent.trim();
+                if (t.length > 0 && t.length < 200) return t;
+            }
+            // Check for common error strings in visible text
+            for (const el of document.querySelectorAll('p, span, div')) {
+                const t = el.textContent.trim();
+                if (t.length > 5 && t.length < 150
+                    && (t.includes('Wrong password')
+                        || t.includes('incorrect')
+                        || t.includes('Could not log you in')
+                        || t.includes("can\\'t find")
+                        || t.includes('cannot find')
+                        || t.includes('suspended')
+                        || t.includes('locked')
+                        || t.includes('unusual login'))) {
+                    return t;
+                }
+            }
+            return '';
+        })()''')
+    except Exception:
+        return ''
+
+
+async def _click_continue(tab):
+    """Click the 'Continue' / 'Log in' button in the jf onboarding flow.
+
+    The button is a nested <div> containing <p>Continue</p> (or <p>Log in</p>),
+    not a standard <button type="submit">.
+    """
+    try:
+        return await tab.evaluate('''(() => {
+            for (const p of document.querySelectorAll('p.jf-element')) {
+                const t = p.textContent.trim();
+                if (t === 'Continue' || t === 'Log in' || t === 'Next') {
+                    p.parentElement.parentElement.parentElement.click();
+                    return true;
+                }
+            }
+            return false;
+        })()''')
+    except Exception:
+        return False
+
+
+async def _find_visible_input(tab, name, timeout=15):
+    """Wait for a visible input[name=...] to appear and return it."""
+    for _ in range(timeout * 2):
+        try:
+            found = await tab.evaluate(f'''(() => {{
+                for (const inp of document.querySelectorAll('input[name="{name}"]')) {{
+                    const r = inp.getBoundingClientRect();
+                    if (r.width > 0 && r.height > 0) return true;
+                }}
+                return false;
+            }})()''')
+            if found:
+                return await tab.select(f'input[name="{name}"]')
+        except Exception:
+            pass
+        await asyncio.sleep(0.5)
+    return None
+
+
+async def _clear_otp(tab):
+    """Clear the 6-box OTP field so a fresh code can be entered.
+
+    Selects all content in the focused input and deletes it, then re-focuses
+    the first OTP box.
+    """
+    try:
+        await tab.evaluate('''(() => {
+            const inputs = document.querySelectorAll('input[autocomplete="one-time-code"]');
+            if (inputs.length) {
+                inputs.forEach(inp => { inp.value = ''; });
+                inputs[0].focus();
+                return true;
+            }
+            // Fallback: clear any focused input
+            const el = document.activeElement;
+            if (el && el.tagName === 'INPUT') {
+                el.value = '';
+                el.dispatchEvent(new Event('input', { bubbles: true }));
+            }
+            return false;
+        })()''')
+    except Exception:
+        pass
+
+
+async def _type_otp(tab, code):
+    """Type a 2FA code via CDP Input.insertText into the auto-focused OTP field.
+
+    The jf onboarding 2FA screen shows 6 individual boxes that auto-focus the
+    first one. insertText commits all digits at once; the field auto-submits
+    when all 6 are filled. This avoids DOM/Runtime methods that can hang on
+    this SPA screen.
+    """
+    try:
+        await asyncio.wait_for(
+            tab.send(cdp.input_.insert_text(code)), timeout=8
+        )
+        return True
+    except Exception:
+        return False
 
-            page_content = await tab.get_content()
-            if "Could not log you in" in page_content:
-                retry += 1
-                wait = retry * 10
-                print(f"Retrying in {wait} seconds...")
-                await asyncio.sleep(wait)
-            else:
-                break
 
-        # Enter password
-        print("[*] Entering password...", file=sys.stderr)
-        pretry = 0
-        while pretry < 5:
-            password_input = await tab.find(
-                'input[autocomplete="current-password"]', timeout=15
-            )
-            await password_input.click()
-            await asyncio.sleep(0.5)
-            await password_input.send_keys(password)
-            await asyncio.sleep(0.2)
-            await password_input.send_keys("\n")
-            await asyncio.sleep(2)
+async def _otp_error(tab):
+    """Check if the 2FA screen shows an error message like 'Incorrect'."""
+    try:
+        return await tab.evaluate('''(() => {
+            const el = document.querySelector('[role="alert"]');
+            if (el) return el.textContent.trim().substring(0, 80);
+            for (const el of document.querySelectorAll('p, span')) {
+                const t = el.textContent.trim();
+                if (t.length < 100
+                    && (t.includes('Incorrect') || t.includes('try again')
+                        || t.includes('invalid') || t.includes('expired'))) {
+                    return t;
+                }
+            }
+            return '';
+        })()''')
+    except Exception:
+        return ''
+
+
+def _fresh_totp(totp_seed, min_remaining=5):
+    """Generate a TOTP code with at least min_remaining seconds of validity.
+
+    If the current code is about to expire, waits for the next window.
+    """
+    import time
+    totp = pyotp.TOTP(totp_seed)
+    code = totp.now()
+    # Check remaining validity: TOTP period is 30s
+    elapsed = time.time() % 30
+    remaining = 30 - elapsed
+    if remaining < min_remaining:
+        time.sleep(remaining + 1)
+        code = totp.now()
+    return code
+
+
+async def _get_cookies(browser):
+    """Read cookies from the browser, returning a name→value dict."""
+    cookies = await browser.cookies.get_all()
+    return {c.name: c.value for c in cookies}
+
+
+async def _check_session(browser, username):
+    """Check if auth cookies are present and build a session dict."""
+    cd = await _get_cookies(browser)
+    if "auth_token" in cd and "ct0" in cd:
+        return {
+            "kind": "cookie",
+            "username": username,
+            "id": _extract_user_id(cd),
+            "auth_token": cd["auth_token"],
+            "ct0": cd["ct0"],
+        }
+    return None
 
-            page_content = await tab.get_content()
-            if "Could not log you in" in page_content:
-                pretry += 1
-                wait = pretry * 10
-                print(f"Retrying in {wait} seconds...")
-                await asyncio.sleep(wait)
-            else:
+
+async def login_and_get_session(username, password, totp_seed=None, headless=False):
+    """Authenticate with X.com and return a session dict, or None on failure.
+
+    Uses the new /i/jf/onboarding flow (as of mid-2026). A fresh Chrome
+    profile is created per login to avoid cookie bleed.
+    """
+    profile = _make_profile()
+    browser = await zd.start(
+        headless=headless,
+        user_data_dir=profile,
+        browser_args=_BROWSER_ARGS,
+    )
+    try:
+        # --- Navigate to login ---
+        _log(f"[*] Logging in {username}...")
+        tab = await browser.get("https://x.com/i/flow/login")
+        await asyncio.sleep(4)
+
+        # --- Username ---
+        _log("[*] Entering username...")
+        uinput = await _find_visible_input(tab, "username_or_email")
+        if not uinput:
+            raise Exception("Username field not found")
+        await uinput.click()
+        await asyncio.sleep(0.3)
+        await uinput.send_keys(username)
+        await asyncio.sleep(0.5)
+
+        if not await _click_continue(tab):
+            await uinput.send_keys("\n")
+        await asyncio.sleep(3)
+
+        err = await _check_login_error(tab)
+        if err:
+            raise Exception(f"Username rejected: {err}")
+
+        # --- Password ---
+        _log("[*] Entering password...")
+        pw = await _find_visible_input(tab, "password")
+        if not pw:
+            raise Exception("Password field not found")
+        await pw.click()
+        await asyncio.sleep(0.3)
+        await pw.send_keys(password)
+        await asyncio.sleep(0.5)
+
+        if not await _click_continue(tab):
+            await pw.send_keys("\n")
+        await asyncio.sleep(3)
+
+        err = await _check_login_error(tab)
+        if err:
+            raise Exception(f"Login failed: {err}")
+
+        # --- Check for immediate auth (no 2FA) ---
+        session = await _check_session(browser, username)
+        if session:
+            _log("[*] Authenticated (no 2FA)")
+            return session
+
+        # --- 2FA ---
+        # Detect 2FA by URL fragment (reliable) or page content
+        for _ in range(10):
+            url = tab.url or ""
+            if "two_factor" in url:
                 break
+            await asyncio.sleep(1)
+        await asyncio.sleep(1)  # let the OTP field mount and auto-focus
 
-        # Handle 2FA if needed
-        page_content = await tab.get_content()
-        if "verification code" in page_content or "Enter code" in page_content:
+        url = tab.url or ""
+        if "two_factor" in url:
             if not totp_seed:
                 raise Exception("2FA required but no TOTP seed provided")
 
-            print("[*] 2FA detected, entering code...", file=sys.stderr)
-            totp_code = pyotp.TOTP(totp_seed).now()
-            code_input = await tab.select('input[type="text"]')
-            await code_input.send_keys(totp_code + "\n")
-            await asyncio.sleep(3)
-
-        # Get cookies
-        print("[*] Retrieving cookies...", file=sys.stderr)
-        for _ in range(20):  # 20 second timeout
-            cookies = await browser.cookies.get_all()
-            cookies_dict = {cookie.name: cookie.value for cookie in cookies}
-
-            if "auth_token" in cookies_dict and "ct0" in cookies_dict:
-                # Extract ID from twid cookie (may be URL-encoded)
-                user_id = None
-                if "twid" in cookies_dict:
-                    twid = cookies_dict["twid"]
-                    # Try to extract the ID from twid (format: u%3D<id> or u=<id>)
-                    if "u%3D" in twid:
-                        user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
-                    elif "u=" in twid:
-                        user_id = twid.split("u=")[1].split("&")[0].strip('"')
-
-                cookies_dict["username"] = username
-                if user_id:
-                    cookies_dict["id"] = user_id
-
-                return cookies_dict
-
-            await asyncio.sleep(1)
+            _log("[*] 2FA detected, entering code...")
+            last_code = None
+            for attempt in range(2):
+                code = _fresh_totp(totp_seed)
+                while code == last_code:
+                    await asyncio.sleep(3)
+                    code = _fresh_totp(totp_seed)
+                last_code = code
+
+                if attempt > 0:
+                    await _clear_otp(tab)
+                    await asyncio.sleep(0.5)
+
+                typed = await _type_otp(tab, code)
+                _log(f"[*] OTP attempt {attempt + 1}: typed={typed}")
+
+                # Check for success or error (fast loop)
+                for _ in range(5):
+                    await asyncio.sleep(2)
+                    session = await _check_session(browser, username)
+                    if session:
+                        _log("[*] Authenticated (2FA)")
+                        return session
+                    err = await _otp_error(tab)
+                    if err:
+                        _log(f"[*] OTP rejected: {err}")
+                        break
+
+            raise Exception("2FA code rejected (account may be suspended or OTP reset)")
+
+        # --- Post-login interstitials (premium signup push, etc.) ---
+        _log("[*] Waiting for post-login redirect...")
+        for i in range(10):
+            session = await _check_session(browser, username)
+            if session:
+                _log("[*] Authenticated")
+                return session
+            await _click_continue(tab)
+            await asyncio.sleep(2)
 
-        raise Exception("Timeout waiting for cookies")
+        raise Exception("Timeout waiting for authentication cookies")
 
     finally:
-        browser.stop()
+        try:
+            await browser.stop()
+        except Exception:
+            pass
+        await asyncio.sleep(1)
+        shutil.rmtree(profile, ignore_errors=True)
 
 
 async def main():
     if len(sys.argv) < 3:
         print(
-            "Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]"
+            "Usage: python3 create_session_browser.py username password"
+            " [totp_seed] [--append file.jsonl] [--headless]"
         )
         sys.exit(1)
 
@@ -151,7 +386,7 @@ async def main():
         if arg == "--append":
             if i + 1 < len(sys.argv):
                 append_file = sys.argv[i + 1]
-                i += 2  # Skip '--append' and filename
+                i += 2
             else:
                 print("[!] Error: --append requires a filename", file=sys.stderr)
                 sys.exit(1)
@@ -163,19 +398,11 @@ async def main():
                 totp_seed = arg
             i += 1
         else:
-            # Unkown args
             print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
             i += 1
 
     try:
-        cookies = await login_and_get_cookies(username, password, totp_seed, headless)
-        session = {
-            "kind": "cookie",
-            "username": cookies["username"],
-            "id": cookies.get("id"),
-            "auth_token": cookies["auth_token"],
-            "ct0": cookies["ct0"],
-        }
+        session = await login_and_get_session(username, password, totp_seed, headless)
         output = json.dumps(session)
 
         if append_file:

+ 6 - 0
tools/create_session_curl.py

@@ -1,5 +1,11 @@
 #!/usr/bin/env python3
 """
+DEPRECATED: X now requires a castle token generated by client-side JavaScript
+during the login flow, which blocks pure-API authentication. Use
+create_session_browser.py instead.
+
+This script is kept for reference but will fail with current X API defenses.
+
 Requirements:
   pip install curl_cffi pyotp
 

+ 42 - 135
tools/create_sessions_browser.py

@@ -13,14 +13,14 @@ Examples:
   # Append to sessions.jsonl
   python3 tools/create_sessions_browser.py <accounts_file> --append sessions.jsonl
 
-  # Add 5 second delay between sessions (default: 1)
+  # Add 5 second delay between sessions (default: 3)
   python3 tools/create_sessions_browser.py <accounts_file> --delay 5
 
   # Headless mode (may increase detection risk)
   python3 tools/create_sessions_browser.py <accounts_file> --headless
 
 Input (accounts_file):
-  [{"username": "user", "password": "pass", "totp": "totp_code"}, {...}, ...]
+  [{"username": "user", "password": "pass", "totp": "totp_secret"}, {...}, ...]
 
 Output:
   {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
@@ -33,127 +33,21 @@ import json
 import sys
 from time import sleep
 
-import nodriver as uc
-import pyotp
-
-
-async def login_and_get_cookies(account, headless=False):
-    """Authenticate with X.com and extract session cookies"""
-    # Note: headless mode may increase detection risk from bot-detection systems
-    browser = await uc.start(headless=headless)
-    tab = await browser.get("https://x.com/i/flow/login")
-
-    username = account["username"]
-    password = account["password"]
-    totp_seed = account["totp"]
-
-    try:
-        # Enter username
-        print(f"[*] Entering username {username}...", file=sys.stderr)
-
-        retry = 0
-        while retry < 5:
-            username_input = await tab.find(
-                'input[autocomplete="username"]', timeout=10
-            )
-
-            pos = await username_input.get_position()
-            await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
-            await asyncio.sleep(0.1)
-
-            await username_input.click()
-            await asyncio.sleep(0.5)
-            await username_input.send_keys(username)
-            await asyncio.sleep(0.2)
-            await username_input.send_keys("\n")
-            await asyncio.sleep(2)
-
-            page_content = await tab.get_content()
-            if "Could not log you in" in page_content:
-                retry += 1
-                wait = retry * 10
-                print(f"Retrying in {wait} seconds...")
-                await asyncio.sleep(wait)
-            else:
-                break
-
-        # Enter password
-        print("[*] Entering password...", file=sys.stderr)
-        pretry = 0
-        while pretry < 5:
-            password_input = await tab.find(
-                'input[autocomplete="current-password"]', timeout=15
-            )
-            await password_input.click()
-            await asyncio.sleep(0.5)
-            await password_input.send_keys(password)
-            await asyncio.sleep(0.2)
-            await password_input.send_keys("\n")
-            await asyncio.sleep(2)
-
-            page_content = await tab.get_content()
-            if "Could not log you in" in page_content:
-                pretry += 1
-                wait = pretry * 10
-                print(f"Retrying in {wait} seconds...")
-                await asyncio.sleep(wait)
-            else:
-                break
-
-        # Handle 2FA if needed
-        page_content = await tab.get_content()
-        if "verification code" in page_content or "Enter code" in page_content:
-            if not totp_seed:
-                raise Exception("2FA required but no TOTP seed provided")
-
-            print("[*] 2FA detected, entering code...", file=sys.stderr)
-            totp_code = pyotp.TOTP(totp_seed).now()
-            code_input = await tab.select('input[type="text"]')
-            await code_input.send_keys(totp_code + "\n")
-            await asyncio.sleep(3)
-
-        # Get cookies
-        print("[*] Retrieving cookies...", file=sys.stderr)
-        for _ in range(20):  # 20 second timeout
-            cookies = await browser.cookies.get_all()
-            cookies_dict = {cookie.name: cookie.value for cookie in cookies}
-
-            if "auth_token" in cookies_dict and "ct0" in cookies_dict:
-                # Extract ID from twid cookie (may be URL-encoded)
-                user_id = None
-                if "twid" in cookies_dict:
-                    twid = cookies_dict["twid"]
-                    # Try to extract the ID from twid (format: u%3D<id> or u=<id>)
-                    if "u%3D" in twid:
-                        user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
-                    elif "u=" in twid:
-                        user_id = twid.split("u=")[1].split("&")[0].strip('"')
-
-                cookies_dict["username"] = username
-                if user_id:
-                    cookies_dict["id"] = user_id
-
-                return cookies_dict
-
-            await asyncio.sleep(1)
-
-        raise Exception("Timeout waiting for cookies")
-
-    finally:
-        browser.stop()
+from create_session_browser import login_and_get_session
 
 
 async def main():
     if len(sys.argv) < 2:
         print(
-            "Usage: python3 create_sessions_browser.py <accounts_file> [--append sessions.jsonl] [--headless]"
+            "Usage: python3 create_sessions_browser.py <accounts_file>"
+            " [--append sessions.jsonl] [--headless] [--delay N]"
         )
         sys.exit(1)
 
-    input = sys.argv[1]
+    input_file = sys.argv[1]
     append_file = None
     headless = False
-    delay = 1
+    delay = 3
 
     # Parse optional arguments
     i = 2
@@ -162,7 +56,7 @@ async def main():
         if arg == "--append":
             if i + 1 < len(sys.argv):
                 append_file = sys.argv[i + 1]
-                i += 2  # Skip '--append' and filename
+                i += 2
             else:
                 print("[!] Error: --append requires a filename", file=sys.stderr)
                 sys.exit(1)
@@ -173,30 +67,28 @@ async def main():
             delay = int(sys.argv[i + 1])
             i += 2
         else:
-            # Unkown args
             print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
             i += 1
 
-    accounts = []
-    with open(input) as f:
+    with open(input_file) as f:
         accounts = json.load(f)
 
-    if len(accounts) == 0:
-        print("no accounts in file")
+    if not accounts:
+        print("No accounts in file")
         sys.exit(0)
 
-    sessions = 0
-    for acc in accounts:
-        sessions += 1
+    ok, fail = [], []
+    for idx, acc in enumerate(accounts, 1):
+        username = acc["username"]
+        print(
+            f"\n[{idx}/{len(accounts)}] {username}...",
+            file=sys.stderr,
+            flush=True,
+        )
         try:
-            cookies = await login_and_get_cookies(acc, headless)
-            session = {
-                "kind": "cookie",
-                "username": cookies["username"],
-                "id": cookies.get("id"),
-                "auth_token": cookies["auth_token"],
-                "ct0": cookies["ct0"],
-            }
+            session = await login_and_get_session(
+                username, acc["password"], acc.get("totp"), headless
+            )
 
             if append_file:
                 with open(append_file, "a") as f:
@@ -204,16 +96,31 @@ async def main():
             else:
                 print(json.dumps(session))
 
-            print(f"Progress: {sessions} / {len(accounts)}")
-            if sessions < len(accounts):
-                print("Waiting", delay, "seconds")
-                sleep(delay)
+            ok.append(username)
+            print(
+                f"  ✓ saved (id={session['id']})",
+                file=sys.stderr,
+                flush=True,
+            )
         except Exception as error:
+            fail.append(username)
             print(
-                f"[!] Error getting session for {acc["username"]}, skipping: {error}",
+                f" {error}",
                 file=sys.stderr,
+                flush=True,
             )
 
+        if idx < len(accounts):
+            sleep(delay)
+
+    print(
+        f"\nDone: {len(ok)} ok, {len(fail)} failed",
+        file=sys.stderr,
+        flush=True,
+    )
+    if fail:
+        print(f"  failed: {fail}", file=sys.stderr, flush=True)
+
 
 if __name__ == "__main__":
     asyncio.run(main())

+ 1 - 1
tools/requirements.txt

@@ -1,3 +1,3 @@
-nodriver>=0.48.0
+zendriver>=0.15.0
 pyotp
 curl_cffi