1
0
Fork 0

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
master
Dustin 2024-07-10 10:52:08 -05:00
parent 43aba0c848
commit 8de0d93eb1
2 changed files with 53 additions and 7 deletions

19
chase2fa.py Normal file
View File

@ -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

View File

@ -551,11 +551,8 @@ class Chase:
with self.saved_cookies.open(encoding='utf-8') as f: with self.saved_cookies.open(encoding='utf-8') as f:
cookies = await asyncio.to_thread(json.load, f) cookies = await asyncio.to_thread(json.load, f)
await self.page.context.add_cookies(cookies) await self.page.context.add_cookies(cookies)
except: except Exception as e:
log.warning( log.debug('Failed to load saved cookies: %s', e)
'Could not load saved cookies, '
'SMS verification will be required!'
)
else: else:
log.info('Successfully loaded saved cookies') log.info('Successfully loaded saved cookies')
@ -587,9 +584,39 @@ class Chase:
await logonbox.get_by_role('button', name='Sign in').click() await logonbox.get_by_role('button', name='Sign in').click()
log.debug('Waiting for page load') log.debug('Waiting for page load')
await self.page.wait_for_load_state() await self.page.wait_for_load_state()
await self.page.get_by_role('button', name='Pay Card').wait_for( logonframe = self.page.frame_locator('iframe[title="logon"]')
timeout=120000 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') log.info('Successfully logged in to Chase')
self._logged_in = True self._logged_in = True