diff --git a/README.md b/README.md index 8cf44c8..1a9e91f 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,18 @@ There are two authentication methods: ### Web login (default) -Web login is the newer method that uses the same login method as [app.traderepublic.com](https://app.traderepublic.com/), -meaning you receive a four-digit code in the TradeRepublic app or via SMS. This will keep you logged in your primary -device, but means that you may need to repeat entering a new four-digit code ever so often when runnnig `pytr`. +Web login uses the public web-login endpoints at `api.traderepublic.com`. Two variants are available: + +- **v1 (default)**: `pytr login`. You receive a four-digit code in the TradeRepublic app (or via SMS as a + fallback) and enter it in the terminal. This is the original behavior; nothing changes for existing users. +- **v2 push approval (opt-in)**: `pytr login --v2`. Mirrors what + [app.traderepublic.com](https://app.traderepublic.com/) currently does: no numeric code, you approve the login from + a push notification in the Trade Republic mobile app. Useful if your account no longer issues a code via the app or + SMS. The `--v2` flag is available on every subcommand that performs a login, e.g. `pytr portfolio --v2`, + `pytr dl_docs --v2`. + +Both variants keep you logged in on your primary device, but you may need to re-authenticate every so often when +running `pytr`. ### App login diff --git a/pytr/account.py b/pytr/account.py index 5f68854..e996a68 100644 --- a/pytr/account.py +++ b/pytr/account.py @@ -18,7 +18,7 @@ def get_settings(tr): return formatted_json -def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright"): +def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright", v2=False): """ Handle credentials parameters and store to credentials file if requested. If no parameters are set but are needed then ask for input @@ -52,7 +52,13 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh else: save_cookies = False - tr = TradeRepublicApi(phone_no=phone_no, pin=pin, save_cookies=save_cookies, waf_token=waf_token) + tr = TradeRepublicApi( + phone_no=phone_no, + pin=pin, + save_cookies=save_cookies, + waf_token=waf_token, + use_v2_login=v2, + ) # Use same login as app.traderepublic.com if not tr.resume_websession(): @@ -61,23 +67,28 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh except ValueError as e: log.fatal(str(e)) sys.exit(1) - request_time = time.time() - print("Enter the code you received to your mobile app as a notification.") - print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})") - code = input("Code: ") - if code == "": - countdown = countdown - (time.time() - request_time) - for remaining in range(int(countdown)): - print( - f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...", - end="\r", - ) - time.sleep(1) - print() - tr.resend_weblogin() - code = input("SMS requested. Enter the confirmation code:") - tr.complete_weblogin(code) - log.info("Logged in.") + if v2: + # v2 push-approve flow: initiate_weblogin() already polled and got approval. + tr.complete_weblogin("") + log.info("Logged in.") + else: + request_time = time.time() + print("Enter the code you received to your mobile app as a notification.") + print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})") + code = input("Code: ") + if code == "": + countdown = countdown - (time.time() - request_time) + for remaining in range(int(countdown)): + print( + f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...", + end="\r", + ) + time.sleep(1) + print() + tr.resend_weblogin() + code = input("SMS requested. Enter the confirmation code:") + tr.complete_weblogin(code) + log.info("Logged in.") log.debug(get_settings(tr)) return tr diff --git a/pytr/api.py b/pytr/api.py index 82af501..694109b 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -21,7 +21,9 @@ # SOFTWARE. import asyncio +import base64 import json +import os import pathlib import re import ssl @@ -45,6 +47,19 @@ BASE_DIR = home / ".pytr" CREDENTIALS_FILE = BASE_DIR / "credentials" COOKIES_FILE = BASE_DIR / "cookies.txt" +DEVICE_ID_FILE = BASE_DIR / "device_id" + +# v2 web-login values captured from app.traderepublic.com on 2026-05-29. +# Only used when the caller opts into the v2 push-approval flow. +# Overridable via env vars in case Trade Republic bumps them. +TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0") +TR_WEB_USER_AGENT_V2 = os.environ.get( + "PYTR_TR_USER_AGENT", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", +) +TR_V1_LOGIN_PATH = "/api/v1/auth/web/login" +TR_V2_LOGIN_PATH = "/api/v2/auth/web/login" class TradeRepublicApi: @@ -75,11 +90,13 @@ def __init__( credentials_file=None, cookies_file=None, waf_token="playwright", + use_v2_login=False, ): self.log = get_logger(__name__) self._locale = locale self._save_cookies = save_cookies self._waf_token = waf_token + self._use_v2_login = use_v2_login self._credentials_file = pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE @@ -183,6 +200,49 @@ def _fetch_waf_token_playwright(self, timeout_ms: int = 30000): self.log.warning("AWS WAF token not acquired. Value is None.") return token + def _get_device_id(self) -> str: + """Return a stable per-install device UUID, creating it on first use.""" + try: + return DEVICE_ID_FILE.read_text().strip() + except FileNotFoundError: + BASE_DIR.mkdir(parents=True, exist_ok=True) + device_id = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars, matches web app + DEVICE_ID_FILE.write_text(device_id) + return device_id + + def _build_device_info_header(self) -> str: + """Build the base64(JSON) x-tr-device-info payload the web app sends.""" + payload = { + "stableDeviceId": self._get_device_id(), + "model": "Apple Macintosh", + "browser": "Chrome", + "browserVersion": "148.0.0.0", + "os": "Mac OS", + "osVersion": "10.15.7", + "timezone": "Europe/Amsterdam", + "timezoneOffset": -120, + "screen": "1800x1169x30", + "preferredLanguages": ["en", "en-US"], + "numberOfCores": 12, + "deviceMemory": 16, + } + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + def _auth_headers(self) -> Dict[str, str]: + """Headers required by /api/v2/auth/web/login (v2 push-approval flow).""" + headers = { + "User-Agent": TR_WEB_USER_AGENT_V2, + "Origin": "https://app.traderepublic.com", + "Referer": "https://app.traderepublic.com/", + "x-tr-platform": "web", + "x-tr-app-version": TR_WEB_APP_VERSION, + "x-tr-device-info": self._build_device_info_header(), + } + if self._waf_token: + headers["x-aws-waf-token"] = self._waf_token + return headers + def _set_waf_cookie(self, token: str): """Set the aws-waf-token cookie on the web session.""" cookie = Cookie( @@ -221,9 +281,13 @@ def initiate_weblogin(self): else: self.log.warning("No WAF token available.") + login_path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + extra_headers = self._auth_headers() if self._use_v2_login else None + r = self._websession.post( - f"{self._host}/api/v1/auth/web/login", + f"{self._host}{login_path}", json={"phoneNumber": self.phone_no, "pin": self.pin}, + headers=extra_headers, ) self.log.debug(f"Web login returned: {r.status_code}") r.raise_for_status() @@ -237,12 +301,58 @@ def initiate_weblogin(self): raise ValueError(str(err)) else: raise ValueError("processId not in reponse") - return int(j["countdownInSeconds"]) + 1 + + if not self._use_v2_login: + return int(j["countdownInSeconds"]) + 1 + + # v2 web login uses push-to-approve in the TR mobile app: no SMS/code. + # Poll the process endpoint until the push is approved (or rejected/expired). + self.await_web_login_approval() + return 0 + + def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0): + """Block until the TR mobile-app push for this login process is approved.""" + if not self._process_id: + raise ValueError("Initiate web login first.") + url = f"{self._host}{TR_V2_LOGIN_PATH}/processes/{self._process_id}" + deadline = time.time() + timeout_s + print( + f"Approve the login in your Trade Republic mobile app (waiting up to {timeout_s}s)...", + flush=True, + ) + while time.time() < deadline: + r = self._websession.get(url, headers=self._auth_headers()) + if r.status_code == 200: + try: + j = r.json() + except ValueError: + j = {} + state = str(j.get("state") or j.get("status") or "").upper() + if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"): + self.save_websession() + self.log.info("Push approved.") + return + if state in ("REJECTED", "DECLINED", "FAILED", "EXPIRED"): + raise ValueError(f"Login process {state.lower()}: {j}") + # State unknown but session cookie present? Treat as approved. + for c in self._websession.cookies: + if c.name in ("tr_session",) and c.value: + self.save_websession() + self.log.info("Session cookie detected, login complete.") + return + elif r.status_code in (401, 403, 404, 410): + raise ValueError(f"Login process rejected or expired ({r.status_code}): {r.text[:200]}") + else: + self.log.warning("Login poll %s: %s", r.status_code, r.text[:200]) + time.sleep(interval_s) + raise TimeoutError("Push approval not received within timeout.") def resend_weblogin(self): + path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + headers = self._auth_headers() if self._use_v2_login else self._default_headers r = self._websession.post( - f"{self._host}/api/v1/auth/web/login/{self._process_id}/resend", - headers=self._default_headers, + f"{self._host}{path}/{self._process_id}/resend", + headers=headers, ) r.raise_for_status() @@ -250,7 +360,18 @@ def complete_weblogin(self, verify_code): if not self._process_id and not self._websession: raise ValueError("Initiate web login first.") - r = self._websession.post(f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}") + # v2 push flow: approval already happened during initiate_weblogin(). + # An empty verify_code signals nothing left to do. + if self._use_v2_login and not verify_code: + self.save_websession() + return + + path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + headers = self._auth_headers() if self._use_v2_login else None + r = self._websession.post( + f"{self._host}{path}/{self._process_id}/{verify_code}", + headers=headers, + ) r.raise_for_status() self.save_websession() diff --git a/pytr/main.py b/pytr/main.py index d277ed4..fe04c4e 100644 --- a/pytr/main.py +++ b/pytr/main.py @@ -89,6 +89,15 @@ def formatter(prog): action="store_true", default=False, ) + parser_login_args.add_argument( + "--v2", + help=( + "Use the v2 web-login flow (push approval via the Trade Republic " + "mobile app). Default is the legacy v1 flow with an SMS/notification code." + ), + action="store_true", + default=False, + ) # parent subparser for lang option parser_lang = argparse.ArgumentParser(add_help=False) @@ -463,6 +472,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ) elif args.command == "portfolio": Portfolio( @@ -471,6 +481,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.include_watchlist, lang=args.lang, @@ -486,6 +497,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.isin, ).get() @@ -496,6 +508,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.output, args.format, @@ -525,6 +538,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.outputdir, not_before, @@ -559,6 +573,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.input, args.outputfile, @@ -574,6 +589,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.input, args.inputfile, @@ -589,6 +605,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.outputfile, decimal_localization=args.decimal_localization,