Skip to content

Commit

Permalink
Added the ability to use phone-call 2FA. (Closes #2).
Browse files Browse the repository at this point in the history
Added the ability to use a phone-call second factor, by introducing
an additional constructor argument, called `twofactor_type`.
Currently, we support TwofactorType.DUO_PUSH, for the old 2FA flow,
or TwofactorType.PHONE_CALL, for phone-call 2FA.

The phone-call 2FA requires additional status page calls.
  • Loading branch information
meson800 committed Jan 15, 2022
1 parent 5ffb037 commit 80e3e0a
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 20 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2022-01-15
### Added
- Added the ability to use phone-call second factor.
- Added additional constructor argument to select between
available second factor options.
- Added typing-extensions to dependencies to properly support
Python 3.6 and Python 3.7

## [0.2.0]
### Added
- First working version of the touchstone auth.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ with TouchstoneSession(
```

When you call this the first time, your Python script will hang on the 2FA step until
the Duo push is accepted. Subsequent requests should not block until the 30-day
the second-factor (by default, Duo push) is accepted. Subsequent requests should not block until the 30-day
"remember me" period is exceeded.

If this blocking behavior is undesired, you can set the argument `should_block=False`
Expand Down Expand Up @@ -116,6 +116,30 @@ For the various "new Atlas" OAUTH2 applications, you need to find the relevant a

How did I find the proper URL for Covidpass? By looking in your browser's Developer Tools, you can locate the last GET request prior to redirect to `idp.mit.edu`, then remove the extraneous `state` parameter.

### Selecting two-factor method
With version 0.3.0, you can also select between phone-call and Duo Push two factor
authentication. `touchstone-auth` defaults to Duo Push if you do not select one.

To switch between the two, pass an additional `twofactor_auth` argument. For example,
to use the phone-call two factor method in the above example, additionally import
the TwofactorType enum and pass it to the session constructor:
```
import json
from touchstone_auth import TouchstoneSession, TwofactorType
with open('credentials.json') as cred_file:
credentials = json.load(cred_file)
with TouchstoneSession(
base_url=r'https://atlas-auth.mit.edu/oauth2/authorize?identity_provider=Touchstone&redirect_uri=https://covidpass.mit.edu&response_type=TOKEN&client_id=2ao42ccnajj7jpqd7h059n7eoc&scope=covid19/impersonate covid19/user digital-id/search digital-id/user openid profile',
pkcs12_filename=credentials['certfile'],
pkcs12_pass=credentials['password'],
cookiejar_filename='cookies.pickle',
twofactor_type=TwofactorType.PHONE_CALL) as s:
response = json.loads(s.get('https://api.mit.edu/pass-v1/pass/access_status').text)
print('Current Covidpass status: {}'.format(response['status']))
```


## Developer install
Expand All @@ -134,5 +158,17 @@ $ pip install -e .
After this 'local install', you can use and import `touchstone-auth` freely without
having to re-install after each update.

## Changelog
See the [CHANGELOG](CHANGELOG.md) for detailed changes.
```
## [0.3.0] - 2022-01-15
### Added
- Added the ability to use phone-call second factor.
- Added additional constructor argument to select between
available second factor options.
- Added typing-extensions to dependencies to properly support
Python 3.6 and Python 3.7
```

## License
This is licensed by the MIT license. Use freely!
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setuptools.setup(
name='touchstone-auth',
version='0.2.0',
version='0.3.0',
author='Christopher Johnstone',
author_email='[email protected]',
description='Access Touchstone SSO sites without a web browser.',
Expand All @@ -28,6 +28,7 @@
install_requires=[
'beautifulsoup4',
'requests',
'requests-pkcs12==1.10'
'requests-pkcs12==1.10',
'typing-extensions'
]
)
90 changes: 73 additions & 17 deletions src/touchstone_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
or bypassed; this simply allows programatic access to Duo 2FA
and Touchstone auth outside of a web browser.
"""
import enum
import json
import pathlib
import pickle
import re
from typing import Literal, Union
from typing import Union
try:
from typing import Literal
except ImportError:
from typing_extensions import Literal


from bs4 import BeautifulSoup # type: ignore
import requests
Expand All @@ -22,6 +28,10 @@ class TouchstoneError(RuntimeError):
class WouldBlockError(TouchstoneError):
"""Called when a 2FA blocking push is required in non-blocking mode"""

class TwofactorType(enum.Enum):
DUO_PUSH = enum.auto()
PHONE_CALL = enum.auto()

class TouchstoneSession:
"""
This is a wrapper context manager class for requests.Session.
Expand All @@ -36,6 +46,7 @@ def __init__(self,
pkcs12_pass:str,
cookiejar_filename:Union[str,pathlib.Path],
should_block:bool=True,
twofactor_type:TwofactorType=TwofactorType.DUO_PUSH,
verbose:bool=False) -> None:
"""
Creates a new Touchstone session.
Expand All @@ -48,6 +59,9 @@ def __init__(self,
cookiejar_filename: The location to persist cookies at.
should_block: If False, if a Duo 2FA push is required, we instead raise a
WouldBlockError. Does not error if cookies are recent enough to avoid 2FA.
twofactor_type: The desired second factor to use for Duo authentication.
Only Duo Push (TwofactorType.DUO_PUSH) and phone call (TwofactorType.PHONE_CALL)
are currently supported.
verbose: If True, extra information during log-in is printed to stdout
wipe_domains: If not None, wipes cookies for that domain before continuing.
"""
Expand All @@ -57,6 +71,7 @@ def __init__(self,
self._pkcs12 = {'filename': pkcs12_filename, 'password': pkcs12_pass}
self._cookiejar_filename = cookiejar_filename
self._blocking = should_block
self._twofactor_type = twofactor_type
self._verbose = verbose
self._session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36'
Expand Down Expand Up @@ -171,8 +186,8 @@ def perform_touchstone(self, conversation):
if len(auth_request.history) > 0:
# A redirect happened, do the full auth flow if we have time to block
if not self._blocking:
raise WouldBlockError('Duo push required, but blocking is not allowed')
self.vlog('Duo push required: requested Duo auth page')
raise WouldBlockError('Second factor auth required, but blocking is not allowed')
self.vlog('Second factor auth required: requested Duo auth page')

prompt_url = auth_request.request.url
prompt_sid = re.match(r".*\/frame\/prompt\?sid=(.*)", prompt_url).group(1)
Expand All @@ -184,38 +199,78 @@ def perform_touchstone(self, conversation):
}

# POST to send the push
factor = {
TwofactorType.DUO_PUSH: 'Duo+Push',
TwofactorType.PHONE_CALL: 'Phone+Call'
}[self._twofactor_type]
r = self._session.post(f"https://{duo_json['host']}/frame/prompt",
# Push data through as raw bytes; this is the correct URL encoding
# (don't let requests mess with it by sending as a dict)
data=bytes(
f"sid={prompt_sid}&device=phone1&factor=Duo+Push&cookies_allowed=true&dampen_choice=true&out_of_date=&days_out_of_date=&days_to_block=None",
f"sid={prompt_sid}&device=phone1&factor={factor}&cookies_allowed=true&dampen_choice=true&out_of_date=&days_out_of_date=&days_to_block=None",
'utf-8'),
headers=extra_prompt_headers)

self.vlog('Requested Duo push to phone')
self.vlog(f'Requested second factor authentication ({factor})')

prompt_response = json.loads(r.text)
if prompt_response['stat'] != 'OK':
raise TouchstoneError("Unable to send prompt (push to phone 1)")
raise TouchstoneError("Unable to send two-factor request")

# Do a first request (this returns the info 'Pushed a login request to your device')
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
if json.loads(r.text)['response']['status_code'] != 'pushed':
raise TouchstoneError("Push-to-phone failed")
expected_return_status = {
TwofactorType.DUO_PUSH: 'pushed',
TwofactorType.PHONE_CALL: 'calling'
}[self._twofactor_type]
if json.loads(r.text)['response']['status_code'] != expected_return_status:
raise TouchstoneError(f"Second-factor auth (self._twofactor_type) failed")

self.vlog('Successfully pushed Duo request. Blocking until response...')

# Block until the user does something with the request
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
post_prompt_response = json.loads(r.text)
if post_prompt_response['stat'] != 'OK':
raise TouchstoneError("User declined prompt or prompt timed out")

self.vlog('Duo push accepted!')
if self._twofactor_type == TwofactorType.DUO_PUSH:
self.vlog('Successfully pushed Duo push request. Blocking until response...')
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
post_prompt_response = json.loads(r.text)
self.vlog(post_prompt_response)
if post_prompt_response['stat'] != 'OK':
raise TouchstoneError("User declined prompt or prompt timed out")

self.vlog('Second factor auth successful!')
elif self._twofactor_type == TwofactorType.PHONE_CALL:
self.vlog('Successfully pushed phone call request...')
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
post_request_response = json.loads(r.text)
if (post_request_response['stat'] != 'OK' or
post_request_response['response']['status_code'] != 'calling'):
raise TouchstoneError("Unable to call registered phone number.")
self.vlog(post_request_response['response']['status'])
# After the dialing response, we expect the answered response.
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
post_request_response = json.loads(r.text)
if (post_request_response['stat'] != 'OK' or
post_request_response['response']['status_code'] != 'answered'):
raise TouchstoneError("Twofactor call declined.")
self.vlog("Two-factor call answered. Waiting for user input...")
# Check for successful response
r = self._session.post(f"https://{duo_json['host']}/frame/status",
data=bytes(f"sid={prompt_sid}&txid={prompt_response['response']['txid']}", 'utf-8'),
headers=extra_prompt_headers)
post_prompt_response = json.loads(r.text)
if (post_prompt_response['stat'] != 'OK' or
post_prompt_response['response']['status_code'] != 'allow'):
raise TouchstoneError("Two-factor call failed.")
self.vlog('Second factor auth successful!')
else:
raise TouchstoneError('Unknown two-factor flow')

# Get the AUTH token
r = self._session.post(f"https://{duo_json['host']}{post_prompt_response['response']['result_url']}",
Expand Down Expand Up @@ -290,5 +345,6 @@ def __exit__(self, ex_type, value, traceback) -> Literal[False]:

with TouchstoneSession('https://atlas.mit.edu',
config['certfile'], config['password'], 'cookiejar.pickle',
twofactor_type=TwofactorType.PHONE_CALL,
verbose=True) as s:
s.get('https://atlas.mit.edu')

0 comments on commit 80e3e0a

Please sign in to comment.