Skip to content

Commit ba02715

Browse files
committed
Suppot Duo for MFA
Duo MFA: * Uses local on-box webserver to serve Duo iFrame web UI * Why: Web interface currently required for Duo<->Okta * Webserver is opened in a process which closes itself; no zombies * Webserver binds to 127.0.0.1 to prevent access by other hosts on network * Does not add any new dependencies Other: * Adds support for Okta Verify to time out and exit rather than being in a permanent loop * Fixes comment typo in okta.py * Change sleep to 0 during testing; no need for the 100ms waits * Exclude Python 2/3 import switches from code coverage
1 parent 13b6396 commit ba02715

File tree

8 files changed

+417
-11
lines changed

8 files changed

+417
-11
lines changed

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
show_missing = True
33

44
exclude_lines =
5+
pragma: no cover
56
if __name__ == .__main__.:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ nd_okta_auth.egg-info
1010
.idea/
1111
build/
1212
htmlcov/
13+
__pycache__

nd_okta_auth/duo.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from multiprocessing import Process
2+
import sys
3+
import time
4+
if sys.version_info[0] < 3: # pragma: no cover
5+
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
6+
else:
7+
from http.server import HTTPServer, BaseHTTPRequestHandler
8+
9+
10+
class QuietHandler(BaseHTTPRequestHandler, object):
11+
'''
12+
We have to do this HTTP sever siliness because the Duo widget has to be
13+
presented over HTTP or HTTPS or the callback won't work.
14+
'''
15+
def __init__(self, html, *args):
16+
self.html = html
17+
super(QuietHandler, self).__init__(*args)
18+
19+
def log_message(self, _format, *args):
20+
# This quiets the server log to prevent request logging in the terminal
21+
pass
22+
23+
def do_GET(self):
24+
self.send_response(200)
25+
self.send_header('Content-type', 'text/html')
26+
self.end_headers()
27+
self.wfile.write(self.html.encode('utf-8'))
28+
return
29+
30+
31+
class Duo:
32+
def __init__(self, details, state_token):
33+
self.details = details
34+
self.token = state_token
35+
self.html = None
36+
37+
def trigger_duo(self):
38+
host = self.details['host']
39+
sig = self.details['signature']
40+
script = self.details['_links']['script']['href']
41+
callback = self.details['_links']['complete']['href']
42+
43+
self.html = '''<p style="text-align:center">You may close this
44+
after the next page loads successfully</p>
45+
<iframe id="duo_iframe" style="margin: 0 auto;display:block;"
46+
width="620" height="330" frameborder="0"></iframe>
47+
<form method="POST" id="duo_form" action="{cb}">
48+
<input type="hidden" name="stateToken" value="{tkn}" /></form>
49+
<script src="{scr}"></script><script>Duo.init(
50+
{{'host': '{hst}','sig_request': '{sig}','post_action': '{cb}'}}
51+
);</script>'''.format(tkn=self.token, scr=script,
52+
hst=host, sig=sig,
53+
cb=callback)
54+
55+
p = Process(target=self.duo_webserver)
56+
p.start()
57+
time.sleep(10)
58+
p.terminate()
59+
60+
def duo_webserver(self):
61+
server_address = ('127.0.0.1', 65432)
62+
httpd = HTTPServer(server_address, self.handler_with_html)
63+
httpd.serve_forever()
64+
65+
def handler_with_html(self, *args):
66+
QuietHandler(self.html, *args)

nd_okta_auth/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ def main(argv):
156156
while not verified:
157157
passcode = user_input('MFA Passcode: ')
158158
verified = okta_client.validate_mfa(e.fid, e.state_token, passcode)
159+
except okta.UnknownError as e:
160+
log.fatal('Fatal error.')
161+
sys.exit(1)
159162

160163
# Once we're authenticated with an OktaSaml client object, we can use that
161164
# object to get a fresh SAMLResponse repeatedly and refresh our AWS

nd_okta_auth/okta.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
import sys
1414
import bs4
1515
import requests
16-
if sys.version_info[0] < 3: # Python 2
17-
from exceptions import Exception
16+
import webbrowser
17+
from multiprocessing import Process
18+
from nd_okta_auth.duo import Duo
19+
if sys.version_info[0] < 3: # pragma: no cover
20+
from exceptions import Exception # Python 2
1821

1922
log = logging.getLogger(__name__)
2023

@@ -158,7 +161,7 @@ def validate_mfa(self, fid, state_token, passcode):
158161
def okta_verify_with_push(self, fid, state_token, sleep=1):
159162
'''Triggers an Okta Push Verification and waits.
160163
161-
This metho is meant to be called by self.auth() if a Login session
164+
This method is meant to be called by self.auth() if a Login session
162165
requires MFA, and the users profile supports Okta Push with Verify.
163166
164167
We trigger the push, and then immediately go into a wait loop. Each
@@ -184,6 +187,57 @@ def okta_verify_with_push(self, fid, state_token, sleep=1):
184187
log.error('Okta Verify Push REJECTED')
185188
return False
186189

190+
if ret.get('factorResult', 'TIMEOUT') == 'TIMEOUT':
191+
log.error('Okta Verify Push TIMEOUT')
192+
return False
193+
194+
links = ret.get('_links')
195+
ret = self._request(links['next']['href'], data)
196+
197+
self.set_token(ret)
198+
return True
199+
200+
def duo_auth(self, uid, fid, state_token, sleep=1):
201+
'''Triggers a Duo Auth request.
202+
203+
This method is meant to be called by self.auth() if a Login session
204+
requires MFA, and the users profile supports Duo Web.
205+
206+
We set up a local web server for web auth and then open a browser for
207+
the user. We then immediately go into a wait loop. Each time we loop
208+
around, we pull the latest status for that push event. If it's Declined
209+
we will throw an error. If its accepted, we write out our SessionToken.
210+
211+
Args:
212+
uid: Okta user ID required by Duo
213+
fid: Okta Factor ID used to trigger the push
214+
state_token: State Token allowing us to trigger the push
215+
'''
216+
log.warning('Duo requied; opening browser...')
217+
path = '/authn/factors/{fid}/verify'.format(fid=fid)
218+
data = {'fid': fid,
219+
'stateToken': state_token}
220+
ret = self._request(path, data)
221+
222+
verification = ret['_embedded']['factor']['_embedded']['verification']
223+
duo = Duo(verification, state_token)
224+
p = Process(target=duo.trigger_duo)
225+
p.start()
226+
time.sleep(2)
227+
webbrowser.open_new('http://127.0.0.1:65432/duo.html')
228+
229+
while ret['status'] != 'SUCCESS':
230+
log.info('Waiting for Okta Verification...')
231+
time.sleep(sleep)
232+
233+
if ret.get('factorResult', 'REJECTED') == 'REJECTED':
234+
log.error('Duo Push REJECTED')
235+
return False
236+
237+
if ret.get('factorResult', 'TIMEOUT') == 'TIMEOUT':
238+
log.error('Duo Push TIMEOUT')
239+
return False
240+
187241
links = ret.get('_links')
188242
ret = self._request(links['next']['href'], data)
189243

@@ -231,15 +285,21 @@ def auth(self):
231285

232286
if status == 'MFA_REQUIRED' or status == 'MFA_CHALLENGE':
233287
for factor in ret['_embedded']['factors']:
234-
if factor['factorType'] == 'push':
235-
try:
288+
try:
289+
if factor['factorType'] == 'push':
236290
if self.okta_verify_with_push(factor['id'],
237291
ret['stateToken']):
238292
return
239-
except KeyboardInterrupt:
240-
# Allow users to use MFA Passcode by
241-
# breaking out of waiting for the push.
242-
break
293+
if factor['provider'] == 'DUO':
294+
if self.duo_auth(ret['_embedded']['user']['id'],
295+
factor['id'],
296+
ret['stateToken']):
297+
return
298+
299+
except KeyboardInterrupt:
300+
# Allow users to use MFA Passcode by
301+
# breaking out of waiting for the push.
302+
break
243303

244304
for factor in ret['_embedded']['factors']:
245305
if factor['factorType'] == 'token:software:totp':

nd_okta_auth/test/duo_test.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from __future__ import unicode_literals
2+
import sys
3+
import unittest
4+
from nd_okta_auth import duo
5+
if sys.version_info[0] < 3:
6+
import mock # Python 2
7+
from StringIO import StringIO as IO
8+
else:
9+
from unittest import mock # Python 3
10+
from io import BytesIO as IO
11+
12+
DETAILS = {
13+
'host': 'somehost',
14+
'signature': 'somesig',
15+
'_links': {
16+
'script': {
17+
'href': 'http://example.com/script.js'
18+
},
19+
'complete': {
20+
'href': 'http://example.com/callback'
21+
},
22+
},
23+
}
24+
25+
HTML = ('''<p style="text-align:center">You may close this after the\n'''
26+
''' next page loads successfully</p>\n '''
27+
'''<iframe id="duo_iframe" style="margin: 0 auto;display:block;"\n'''
28+
''' width="620" height="330" frameborder="0"></iframe>\n'''
29+
''' <form method="POST" id="duo_form" '''
30+
'''action="http://example.com/callback">\n '''
31+
'''<input type="hidden" name="stateToken" value="token" /></form>\n'''
32+
''' <script src="http://example.com/script.js"></script>'''
33+
'''<script>Duo.init(\n '''
34+
'''{\'host\': \'somehost\',\'sig_request\': \'somesig\','''
35+
'''\'post_action\': \'http://example.com/callback\'}\n'''
36+
''' );</script>''')
37+
38+
39+
class TestDuo(unittest.TestCase):
40+
def test_init_missing_args(self):
41+
with self.assertRaises(TypeError):
42+
duo.Duo()
43+
44+
def test_init_with_args(self):
45+
duo_test = duo.Duo(DETAILS, 'token')
46+
self.assertEquals(duo_test.details, DETAILS)
47+
self.assertEquals(duo_test.token, 'token')
48+
49+
@mock.patch('time.sleep', return_value=None)
50+
@mock.patch('nd_okta_auth.duo.Process')
51+
def test_trigger_duo(self, process_mock, _sleep_mock):
52+
process_mock.start.return_value = None
53+
54+
duo_test = duo.Duo(DETAILS, 'token')
55+
duo_test.trigger_duo()
56+
57+
process_mock.assert_has_calls([
58+
mock.call().start(),
59+
mock.call().terminate(),
60+
61+
])
62+
63+
@mock.patch('nd_okta_auth.duo.HTTPServer')
64+
def test_duo_webserver(self, server_mock):
65+
server_mock.return_value = mock.MagicMock()
66+
67+
duo_test = duo.Duo(DETAILS, 'token')
68+
duo_test.duo_webserver()
69+
70+
server_mock.assert_has_calls([
71+
mock.call(('127.0.0.1', 65432), duo_test.handler_with_html),
72+
mock.call().serve_forever()
73+
])
74+
75+
@mock.patch('nd_okta_auth.duo.QuietHandler')
76+
def test_handler(self, qh_mock):
77+
duo_test = duo.Duo(DETAILS, 'token')
78+
duo_test.handler_with_html('foo', 'bar', 'baz')
79+
80+
assert qh_mock.called
81+
82+
qh_mock.assert_has_calls([
83+
mock.call(None, 'foo', 'bar', 'baz')
84+
])
85+
86+
87+
class TestQuietHandler(unittest.TestCase):
88+
def test_init_missing_args(self):
89+
with self.assertRaises(TypeError):
90+
duo.QuietHandler()
91+
92+
def test_init_with_args(self):
93+
qh_test = duo.QuietHandler(HTML, MockRequestPOST(), 'bar', 'baz')
94+
self.assertEquals(qh_test.html, HTML)
95+
96+
def test_log_message(self):
97+
qh_test = duo.QuietHandler(HTML, MockRequestPOST(), 'bar', 'baz')
98+
self.assertEquals(qh_test.log_message(''), None)
99+
100+
def test_do_get(self):
101+
mr = MockRequest()
102+
duo.QuietHandler(HTML, mr, 'bar', 'baz')
103+
104+
105+
class MockRequestPOST(object):
106+
def makefile(self, *args, **kwargs):
107+
return IO(b"POST /")
108+
109+
def sendall(self, *args):
110+
return None
111+
112+
113+
class MockRequest(object):
114+
def __init__(self):
115+
self.resp = None
116+
117+
def makefile(self, *args, **kwargs):
118+
return IO(b"GET /")
119+
120+
def sendall(self, *args):
121+
self.resp = args[0]
122+
return None

nd_okta_auth/test/main_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,25 @@ def test_entry_point_bad_password(self, pass_mock, config_mock, okta_mock):
184184
with self.assertRaises(SystemExit):
185185
main.main('test')
186186

187+
@mock.patch('nd_okta_auth.okta.OktaSaml')
188+
@mock.patch('nd_okta_auth.main.get_config_parser')
189+
@mock.patch('getpass.getpass')
190+
def test_entry_point_okta_unknown(self, pass_mock, config_mock, okta_mock):
191+
# Mock out the password getter and return a simple password
192+
pass_mock.return_value = 'test_password'
193+
194+
# Just mock out the entire Okta object, we won't really instantiate it
195+
fake_okta = mock.MagicMock(name='fake_okta')
196+
fake_okta.auth.side_effect = okta.UnknownError
197+
okta_mock.return_value = fake_okta
198+
199+
# Mock out the arguments that were passed in
200+
fake_parser = mock.MagicMock(name='fake_parser')
201+
config_mock.return_value = fake_parser
202+
203+
with self.assertRaises(SystemExit):
204+
main.main('test')
205+
187206
@mock.patch('nd_okta_auth.okta.OktaSaml')
188207
@mock.patch('nd_okta_auth.main.get_config_parser')
189208
@mock.patch('getpass.getpass')

0 commit comments

Comments
 (0)