From 8de0d93eb187ecc990cdeb236f0ae28f8330525a Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Wed, 10 Jul 2024 10:52:08 -0500 Subject: [PATCH] xactfetch: chase: Handle SMS 2-factor auth When logging in to the Chase website with a fresh browser profile, or otherwise without any cookies, the user will be required to "validate the device" using a one-time code delivered via SMS. Previously, I handled this by running the `xactfetch` script with a headed browser, manually entering the verification code when the prompt came up. Then, I would copy the `cookies.json` file, now containing a cookie indicating the device had been verified, to the Kubernetes volume, where it would be used by the production pod. Now that `xactfetch` uses asyncio, it is possible for the Chase `login` method to wait for one of multiple conditions: either login succeeds, or SMS 2FA is required. In the case of the latter, we can get the 2FA code from the secret server and enter it into the form to complete the login process. The real magic here is how we're getting the 2FA code from the SMS message. There are two components to this. First, I've installed [SMS to URL Forwarder][0] on my phone. This app does what it says on the tin: it relays SMS messages to an HTTP(S) server. I have configured it to forward messages from the Chase SMS 2FA short code to an _ntfy_ topic. The second component is the `chase2fa` script, which is called by the secret server. This script listens for notifications on the _ntfy_ topic where the SMS messages are forwarded. When a message arrives, it extracts the verification code using a simple regular expression that identifies a several-digit number. With all these pieces in place, the `xactfetch` script is no longer thwarted by the SMS 2FA barrier! [0]: https://github.com/bogkonstantin/android_income_sms_gateway_webhook --- chase2fa.py | 19 +++++++++++++++++++ xactfetch.py | 41 ++++++++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 chase2fa.py diff --git a/chase2fa.py b/chase2fa.py new file mode 100644 index 0000000..f179c5c --- /dev/null +++ b/chase2fa.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import re + +import httpx + +stream = httpx.stream( + 'GET', + 'https://ntfy.pyrocufflink.blue/chase2fa/raw', + timeout=httpx.Timeout(5, read=None), +) +with stream as r: + for line in r.iter_lines(): + line = line.strip() + if not line: + continue + m = re.search(r'\d{4,}', line) + if m: + print(m.group(0)) + break diff --git a/xactfetch.py b/xactfetch.py index d3a0a04..3ce1b28 100644 --- a/xactfetch.py +++ b/xactfetch.py @@ -551,11 +551,8 @@ class Chase: with self.saved_cookies.open(encoding='utf-8') as f: cookies = await asyncio.to_thread(json.load, f) await self.page.context.add_cookies(cookies) - except: - log.warning( - 'Could not load saved cookies, ' - 'SMS verification will be required!' - ) + except Exception as e: + log.debug('Failed to load saved cookies: %s', e) else: log.info('Successfully loaded saved cookies') @@ -587,9 +584,39 @@ class Chase: await logonbox.get_by_role('button', name='Sign in').click() log.debug('Waiting for page load') await self.page.wait_for_load_state() - await self.page.get_by_role('button', name='Pay Card').wait_for( - timeout=120000 + logonframe = self.page.frame_locator('iframe[title="logon"]') + t_2fa = asyncio.create_task( + logonframe.get_by_role( + 'heading', name="We don't recognize this device" + ).wait_for() ) + t_finished = asyncio.create_task( + self.page.get_by_role('button', name='Pay Card').wait_for() + ) + done, pending = await asyncio.wait( + (t_2fa, t_finished), + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + for t in done: + await t + if t_2fa in done: + log.warning('Device verification (SMS 2-factor auth) required') + await logonframe.get_by_label('Tell us how: Choose one').click() + await logonframe.locator( + '#container-1-simplerAuth-dropdownoptions-styledselect' + ).click() + otp_task = asyncio.create_task(self.get_secret('bank.chase.otp')) + await logonframe.get_by_role('button', name='Next').click() + log.info('Waiting for SMS verification code') + otp = await otp_task + log.debug('Filling verification code form') + await logonframe.get_by_label('One-time code').fill(otp) + await logonframe.get_by_label('Password').fill(password) + await logonframe.get_by_role('button', name='Next').click() + await self.page.wait_for_load_state() + await self.page.get_by_role('button', name='Pay Card').wait_for() log.info('Successfully logged in to Chase') self._logged_in = True