New login hardening

This commit is contained in:
Guillem Hernandez Sola
2026-04-18 10:58:25 +02:00
parent bdeec32a25
commit 08cb7e18e3

View File

@@ -11,6 +11,7 @@ import time
import os
import subprocess
import uuid
import random
from urllib.parse import urlparse
from dotenv import load_dotenv
from atproto import Client, client_utils, models
@@ -49,6 +50,12 @@ BSKY_SEND_POST_MAX_RETRIES = 3
BSKY_SEND_POST_BASE_DELAY = 5
BSKY_SEND_POST_MAX_DELAY = 60
# --- Login hardening (NEW) ---
BSKY_LOGIN_MAX_RETRIES = 4
BSKY_LOGIN_BASE_DELAY = 10
BSKY_LOGIN_MAX_DELAY = 600
BSKY_LOGIN_JITTER_MAX = 1.5
MEDIA_DOWNLOAD_TIMEOUT = 30
LINK_METADATA_TIMEOUT = 10
URL_RESOLVE_TIMEOUT = 12
@@ -1336,30 +1343,113 @@ def build_text_media_key(normalized_text, media_fingerprint):
).hexdigest()
# --- Login hardening helpers (NEW) ---
def is_rate_limited_error(error_obj):
text = repr(error_obj).lower()
return (
"429" in text
or "ratelimitexceeded" in text
or "too many requests" in text
or "rate limit" in text
)
def is_auth_error(error_obj):
text = repr(error_obj).lower()
return (
"401" in text
or "403" in text
or "invalid identifier or password" in text
or "authenticationrequired" in text
or "invalidtoken" in text
)
def is_network_error(error_obj):
text = repr(error_obj)
signals = [
"ConnectError",
"RemoteProtocolError",
"ReadTimeout",
"WriteTimeout",
"TimeoutException",
"503",
"502",
"504",
"ConnectionResetError",
]
return any(sig in text for sig in signals)
def create_bsky_client(base_url, handle, password):
normalized_base_url = (base_url or DEFAULT_BSKY_BASE_URL).strip().rstrip("/")
logging.info(f"🔐 Connecting Bluesky client via base URL: {normalized_base_url}")
client = Client(base_url=normalized_base_url)
max_retries = 3
for attempt in range(1, max_retries + 1):
max_attempts = BSKY_LOGIN_MAX_RETRIES
base_delay = BSKY_LOGIN_BASE_DELAY
max_delay = BSKY_LOGIN_MAX_DELAY
jitter_max = max(BSKY_LOGIN_JITTER_MAX, 0.0)
for attempt in range(1, max_attempts + 1):
try:
logging.info(f"🔐 Bluesky login attempt {attempt}/{max_attempts} for {handle}")
client.login(handle, password)
logging.info("✅ Bluesky login successful.")
return client
except Exception as e:
msg = str(e)
is_rate = ("429" in msg) or ("RateLimitExceeded" in msg)
if is_rate and attempt < max_retries:
wait = get_rate_limit_wait_seconds(e, default_delay=60)
logging.exception("❌ Bluesky login exception")
# Fail fast on invalid credentials
if is_auth_error(e):
logging.error("❌ Bluesky auth failed (invalid handle/app password).")
raise
# Respect explicit rate-limit timing
if is_rate_limited_error(e):
if attempt < max_attempts:
wait = get_rate_limit_wait_seconds(e, default_delay=base_delay)
wait = wait + random.uniform(0, jitter_max)
logging.warning(
f"Login rate-limited (attempt {attempt}/{max_retries}). "
f"Sleeping {wait}s before retry."
f"Bluesky login rate-limited (attempt {attempt}/{max_attempts}). "
f"Retrying in {wait:.1f}s."
)
time.sleep(wait)
continue
logging.error("❌ Exhausted Bluesky login retries due to rate limiting.")
raise
# Retry transient/network problems
if is_network_error(e) or is_transient_error(e):
if attempt < max_attempts:
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max)
logging.warning(
f"⏳ Transient Bluesky login failure (attempt {attempt}/{max_attempts}). "
f"Retrying in {wait:.1f}s."
)
time.sleep(wait)
continue
logging.error("❌ Exhausted Bluesky login retries after transient/network errors.")
raise
# Unknown errors: bounded retry anyway
if attempt < max_attempts:
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max)
logging.warning(
f"⏳ Bluesky login retry for unexpected error "
f"(attempt {attempt}/{max_attempts}) in {wait:.1f}s."
)
time.sleep(wait)
continue
raise
raise RuntimeError("Bluesky login failed after all retries.")
# --- State Management ---
def default_state():
@@ -1575,20 +1665,70 @@ def get_recent_bsky_posts(client, handle, limit=30):
# --- Upload / Retry Helpers ---
def get_rate_limit_wait_seconds(error_obj, default_delay):
"""
Parse common rate-limit headers and return a bounded wait time in seconds.
Supports:
- retry-after
- x-ratelimit-after
- ratelimit-reset (unix timestamp)
"""
try:
headers = getattr(error_obj, "headers", None)
if headers:
reset_value = headers.get("ratelimit-reset") or headers.get(
"RateLimit-Reset"
)
if reset_value:
now_ts = int(time.time())
reset_ts = int(reset_value)
wait_seconds = max(reset_ts - now_ts + 1, default_delay)
return min(wait_seconds, BSKY_BLOB_UPLOAD_MAX_DELAY)
# Direct headers on exception
headers = getattr(error_obj, "headers", None) or {}
retry_after = headers.get("retry-after") or headers.get("Retry-After")
if retry_after:
return min(max(int(retry_after), 1), BSKY_LOGIN_MAX_DELAY)
x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After")
if x_after:
return min(max(int(x_after), 1), BSKY_LOGIN_MAX_DELAY)
reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset")
if reset_value:
wait_seconds = max(int(reset_value) - now_ts + 1, default_delay)
return min(wait_seconds, BSKY_LOGIN_MAX_DELAY)
except Exception:
pass
try:
# Nested response headers
response = getattr(error_obj, "response", None)
headers = getattr(response, "headers", None) or {}
now_ts = int(time.time())
retry_after = headers.get("retry-after") or headers.get("Retry-After")
if retry_after:
return min(max(int(retry_after), 1), BSKY_LOGIN_MAX_DELAY)
x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After")
if x_after:
return min(max(int(x_after), 1), BSKY_LOGIN_MAX_DELAY)
reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset")
if reset_value:
wait_seconds = max(int(reset_value) - now_ts + 1, default_delay)
return min(wait_seconds, BSKY_LOGIN_MAX_DELAY)
except Exception:
pass
# repr fallback parsing
text = repr(error_obj)
m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE)
if m:
return min(max(int(m.group(1)), 1), BSKY_LOGIN_MAX_DELAY)
m = re.search(r"'x-ratelimit-after': '(\d+)'", text, re.IGNORECASE)
if m:
return min(max(int(m.group(1)), 1), BSKY_LOGIN_MAX_DELAY)
m = re.search(r"'ratelimit-reset': '(\d+)'", text, re.IGNORECASE)
if m:
now_ts = int(time.time())
wait_seconds = max(int(m.group(1)) - now_ts + 1, default_delay)
return min(wait_seconds, BSKY_LOGIN_MAX_DELAY)
return default_delay