From 58c1d37f938857cd8c40dc50b3dd126051d3760d Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Mon, 10 Sep 2018 15:02:06 -0400 Subject: [PATCH 01/29] Add option for second round of Okta authentication This appears to be required for my VPN --- gp-okta.conf | 1 + gp-okta.py | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/gp-okta.conf b/gp-okta.conf index d14ad64..c779b63 100644 --- a/gp-okta.conf +++ b/gp-okta.conf @@ -11,5 +11,6 @@ gateway = Manual ny1-gw.example.com #openconnect_cmd = sudo openconnect openconnect_args = # optional arguments to openconnect execute = 0 # execute openconnect command +another_dance = 0 # second round of authentication required bug.nl = 0 # newline work-around for openconnect bug.username = 0 # username work-around for openconnect diff --git a/gp-okta.py b/gp-okta.py index 2ccba41..cc6e0bc 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -153,7 +153,6 @@ def mfa_priority(conf, ftype, fprovider): priority += (512 - line_nr) return priority - def get_redirect_url(conf, c, current_url = None): rx_base_url = re.search(r'var\s*baseUrl\s*=\s*\'([^\']+)\'', c) rx_from_uri = re.search(r'var\s*fromUri\s*=\s*\'([^\']+)\'', c) @@ -193,9 +192,12 @@ def send_req(conf, s, name, url, data, **kwargs): return r.headers, r.text -def paloalto_prelogin(conf, s): +def paloalto_prelogin(conf, s, again=False): log('prelogin request') - url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) + if again: + url = '{0}/ssl-vpn/prelogin.esp'.format(conf.get('vpn_url')) + else: + url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) h, c = send_req(conf, s, 'prelogin', url, {}, get=True) x = parse_xml(c) saml_req = x.find('.//saml-request') @@ -411,6 +413,31 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): err('empty portal_userauthcookie') return portal_userauthcookie +# Combined first half of okta_saml with second half of okta_redirect +def okta_saml_2(conf, s, saml_xml): + log('okta saml request') + url, data = parse_form(saml_xml) + r = s.post(url, data=data) + if r.status_code != 200: + err('redirect request failed. {0}'.format(reprr(r))) + dbg(conf.get('debug'), 'redirect.response', r.status_code, r.text) + xhtml = parse_html(r.text) + + url, data = parse_form(xhtml) + log('okta redirect form request') + r = s.post(url, data=data) + if r.status_code != 200: + err('redirect form request failed. {0}'.format(reprr(r))) + dbg(conf.get('debug'), 'form.response', r.status_code, r.text) + saml_username = r.headers.get('saml-username', '').strip() + if len(saml_username) == 0 and not again: + err('saml-username empty') + saml_auth_status = r.headers.get('saml-auth-status', '').strip() + saml_slo = r.headers.get('saml-slo', '').strip() + prelogin_cookie = r.headers.get('prelogin-cookie', '').strip() + if len(prelogin_cookie) == 0: + err('prelogin-cookie empty') + return saml_username, prelogin_cookie def main(): if len(sys.argv) < 2: @@ -425,10 +452,16 @@ def main(): token = okta_auth(conf, s) log('sessionToken: {0}'.format(token)) saml_username, prelogin_cookie = okta_redirect(conf, s, token, redirect_url) - log('saml-username: {0}'.format(saml_username)) - log('prelogin-cookie: {0}'.format(prelogin_cookie)) userauthcookie = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) log('portal-userauthcookie: {0}'.format(userauthcookie)) + + # Another dance? + if conf.get('another_dance', '').lower() in ['1', 'true']: + saml_xml = paloalto_prelogin(conf, s, again=True) + saml_username, prelogin_cookie = okta_saml_2(conf, s, saml_xml) + + log('saml-username: {0}'.format(saml_username)) + log('prelogin-cookie: {0}'.format(prelogin_cookie)) username = saml_username cmd = conf.get('openconnect_cmd') or 'openconnect' From 5f24bc5ec60013f6b2096a74124e01212b36b2de Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Tue, 11 Sep 2018 09:14:51 -0400 Subject: [PATCH 02/29] Pass prelogin-cookie if portal-userauthcookie is empty --- gp-okta.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index cc6e0bc..453beef 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -462,13 +462,20 @@ def main(): log('saml-username: {0}'.format(saml_username)) log('prelogin-cookie: {0}'.format(prelogin_cookie)) + + if userauthcookie == 'empty' and prelogin_cookie != 'empty': + cookie_type = "gateway:prelogin-cookie" + cookie = prelogin_cookie + else: + cookie_type = "portal:portal-userauthcookie" + cookie = userauthcookie username = saml_username cmd = conf.get('openconnect_cmd') or 'openconnect' cmd += ' --protocol=gp -u \'{0}\'' - cmd += ' --usergroup portal:portal-userauthcookie' - cmd += ' --passwd-on-stdin ' + conf.get('openconnect_args', '') + ' \'{1}\'' - cmd = cmd.format(username, conf.get('vpn_url')) + cmd += ' --usergroup {1}' + cmd += ' --passwd-on-stdin ' + conf.get('openconnect_args', '') + ' \'{2}\'' + cmd = cmd.format(username, cookie_type, conf.get('vpn_url')) gw = (conf.get('gateway') or '').strip() bugs = '' if conf.get('bug.nl', '').lower() in ['1', 'true']: @@ -476,9 +483,9 @@ def main(): if conf.get('bug.username', '').lower() in ['1', 'true']: bugs += '{0}\\n'.format(username.replace('\\', '\\\\')) if len(gw) > 0: - pcmd = 'printf \'' + bugs + '{0}\\n{1}\''.format(userauthcookie, gw) + pcmd = 'printf \'' + bugs + '{0}\\n{1}\''.format(cookie, gw) else: - pcmd = 'printf \'' + bugs + '{0}\''.format(userauthcookie) + pcmd = 'printf \'' + bugs + '{0}\''.format(cookie) print() if conf.get('execute', '').lower() in ['1', 'true']: cmd = shlex.split(cmd) From 29527325fda6b606e1411bda832026af99400bdc Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 4 Jun 2019 09:21:36 +0200 Subject: [PATCH 03/29] Implement config option `cert` to use a client certificate. This fixes Issue https://github.com/arthepsy/pan-globalprotect-okta/issues/17 raised by @lvml. (Note that if you need to specify a client certificate for the `vpn_url`, then most probably you will also need to give the same certificate to the final `openconnect` call with `--certificate` via the `openconnect_args`) --- gp-okta.conf | 3 ++- gp-okta.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/gp-okta.conf b/gp-okta.conf index c779b63..ac1b828 100644 --- a/gp-okta.conf +++ b/gp-okta.conf @@ -8,8 +8,9 @@ sms.okta = 0 totp.okta = ABCDEFGHIJKLMNOP totp.google = ABCDEFGHIJKLMNOP gateway = Manual ny1-gw.example.com +#cert = path-to-client-cert-as-unencrypted-pem-file.pem #openconnect_cmd = sudo openconnect -openconnect_args = # optional arguments to openconnect +openconnect_args = # optional arguments to openconnect, probably you might want to repeat the cert here (if used above) with --certificate=xxx execute = 0 # execute openconnect command another_dance = 0 # second round of authentication required bug.nl = 0 # newline work-around for openconnect diff --git a/gp-okta.py b/gp-okta.py index 453beef..e3e22c6 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -446,6 +446,9 @@ def main(): conf = load_conf(sys.argv[1]) s = requests.Session() + if conf.get('cert'): + s.cert = conf.get('cert') + s.headers['User-Agent'] = 'PAN GlobalProtect' saml_xml = paloalto_prelogin(conf, s) redirect_url = okta_saml(conf, s, saml_xml) From 3d4ebf719f6125d288c8df0b1a9fac9111f40ef4 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 4 Jun 2019 09:40:29 +0200 Subject: [PATCH 04/29] Implement double authn login (via stateToken) needed for some corporate VPN setups. The first authn call only returns the `sessionToken`, with the redirect later one gets the `stateToken` and afterwards need to do the authn call again for full authentication (and mfa). --- gp-okta.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index e3e22c6..e265847 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -153,6 +153,14 @@ def mfa_priority(conf, ftype, fprovider): priority += (512 - line_nr) return priority +def get_state_token(conf, c, current_url = None): + rx_state_token = re.search(r'var\s*stateToken\s*=\s*\'([^\']+)\'', c) + if not rx_state_token: + dbg(conf.get('debug'), 'not found', 'stateToken') + return None + state_token = to_b(rx_state_token.group(1)).decode('unicode_escape').strip() + return state_token + def get_redirect_url(conf, c, current_url = None): rx_base_url = re.search(r'var\s*baseUrl\s*=\s*\'([^\']+)\'', c) rx_from_uri = re.search(r'var\s*fromUri\s*=\s*\'([^\']+)\'', c) @@ -222,7 +230,7 @@ def okta_saml(conf, s, saml_xml): err('did not find redirect url') return redirect_url -def okta_auth(conf, s): +def okta_auth(conf, s, stateToken = None): log('okta auth request') url = '{0}/api/v1/authn'.format(conf.get('okta_url')) data = { @@ -232,6 +240,8 @@ def okta_auth(conf, s): 'warnBeforePasswordExpired':True, 'multiOptionalFactorEnroll':True } + } if stateToken is None else { + 'stateToken': stateToken } h, j = send_req(conf, s, 'auth', url, data, json=True) @@ -370,12 +380,15 @@ def okta_redirect(conf, s, session_token, redirect_url): url = '{0}/login/sessionCookieRedirect'.format(conf.get('okta_url')) log('okta redirect request') h, c = send_req(conf, s, 'redirect', url, data) + state_token = get_state_token(conf, c, url) redirect_url = get_redirect_url(conf, c, url) if redirect_url: form_url, form_data = None, {} else: xhtml = parse_html(c) form_url, form_data = parse_form(xhtml, url) + if state_token is not None: + okta_auth(conf, s, state_token) elif form_url: log('okta redirect form request') h, c = send_req(conf, s, 'redirect form', form_url, form_data) From 0c1905092b1945297d6fd7128727e5e7145dc23c Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 6 Jun 2019 10:05:58 +0200 Subject: [PATCH 05/29] Fix: inconsistent spacing (tabs<->spaces) --- gp-okta.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index e265847..76c929d 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -154,7 +154,7 @@ def mfa_priority(conf, ftype, fprovider): return priority def get_state_token(conf, c, current_url = None): - rx_state_token = re.search(r'var\s*stateToken\s*=\s*\'([^\']+)\'', c) + rx_state_token = re.search(r'var\s*stateToken\s*=\s*\'([^\']+)\'', c) if not rx_state_token: dbg(conf.get('debug'), 'not found', 'stateToken') return None @@ -380,15 +380,15 @@ def okta_redirect(conf, s, session_token, redirect_url): url = '{0}/login/sessionCookieRedirect'.format(conf.get('okta_url')) log('okta redirect request') h, c = send_req(conf, s, 'redirect', url, data) - state_token = get_state_token(conf, c, url) + state_token = get_state_token(conf, c, url) redirect_url = get_redirect_url(conf, c, url) if redirect_url: form_url, form_data = None, {} else: xhtml = parse_html(c) form_url, form_data = parse_form(xhtml, url) - if state_token is not None: - okta_auth(conf, s, state_token) + if state_token is not None: + okta_auth(conf, s, state_token) elif form_url: log('okta redirect form request') h, c = send_req(conf, s, 'redirect form', form_url, form_data) @@ -459,8 +459,8 @@ def main(): conf = load_conf(sys.argv[1]) s = requests.Session() - if conf.get('cert'): - s.cert = conf.get('cert') + if conf.get('cert'): + s.cert = conf.get('cert') s.headers['User-Agent'] = 'PAN GlobalProtect' saml_xml = paloalto_prelogin(conf, s) From fab69fe18ccc75c9e63dc1c94c96a47547aca4f4 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Fri, 7 Jun 2019 09:37:21 +0200 Subject: [PATCH 06/29] Set certificate automatically also in openconnect call, if one is set in `gp-okta.conf` for the requests-dance --- gp-okta.conf | 2 +- gp-okta.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/gp-okta.conf b/gp-okta.conf index ac1b828..d1e3b69 100644 --- a/gp-okta.conf +++ b/gp-okta.conf @@ -10,7 +10,7 @@ totp.google = ABCDEFGHIJKLMNOP gateway = Manual ny1-gw.example.com #cert = path-to-client-cert-as-unencrypted-pem-file.pem #openconnect_cmd = sudo openconnect -openconnect_args = # optional arguments to openconnect, probably you might want to repeat the cert here (if used above) with --certificate=xxx +openconnect_args = # optional arguments to openconnect execute = 0 # execute openconnect command another_dance = 0 # second round of authentication required bug.nl = 0 # newline work-around for openconnect diff --git a/gp-okta.py b/gp-okta.py index 76c929d..7ddde29 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -490,6 +490,8 @@ def main(): cmd = conf.get('openconnect_cmd') or 'openconnect' cmd += ' --protocol=gp -u \'{0}\'' cmd += ' --usergroup {1}' + if conf.get('cert'): + cmd += ' --certificate=\'{0}\''.format(conf.get('cert')) cmd += ' --passwd-on-stdin ' + conf.get('openconnect_args', '') + ' \'{2}\'' cmd = cmd.format(username, cookie_type, conf.get('vpn_url')) gw = (conf.get('gateway') or '').strip() From 412560eff7184cc8ab30a46c68f7bbae4d152f97 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 11 Jun 2019 14:31:23 +0200 Subject: [PATCH 07/29] - Add ssl cert verification for vpn and okta side, if configured - new option `vpn_url_cert` - new option `okta_url_cert` - rename client certificate option to `client_cert` from `cert` - totp: comment out ABCDEFGHIJKLMNOP secrets, default to not set (so you get asked) - grab and display prelogin errors (for example: client certificate needed or such) - new option `openconnect_certs` that points to a file to collect all given and collected server certificates, will be given to the final openconnect call (inspired by nicklans fork, thank you). If it is not set it will be a temp file. - check all redirect and parsed urls if they point to an expected domain (either `okta_url` or `vpn_url`) - learn `gateway` from getconfig call, if not set/overridden by config file --- gp-okta.conf | 17 +++++-- gp-okta.py | 129 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/gp-okta.conf b/gp-okta.conf index d1e3b69..7eead47 100644 --- a/gp-okta.conf +++ b/gp-okta.conf @@ -1,15 +1,24 @@ debug = 0 + vpn_url = https://vpn.example.com +#vpn_url_cert = vpn_url.cert + okta_url = https://example.okta.com +#okta_url_cert = okta_url.cert + username = myuser password = mypass +#client_cert = path-to-myusers-client-cert-as-unencrypted-pem-file.pem + # mfa_order = totp sms sms.okta = 0 -totp.okta = ABCDEFGHIJKLMNOP -totp.google = ABCDEFGHIJKLMNOP -gateway = Manual ny1-gw.example.com -#cert = path-to-client-cert-as-unencrypted-pem-file.pem +#totp.okta = ABCDEFGHIJKLMNOP +#totp.google = ABCDEFGHIJKLMNOP + +gateway = Manual ny1-gw.example.com # optional hardcoded gateway + #openconnect_cmd = sudo openconnect +#openconnect_certs = path-to-a-writeable-filename-in-which-the-script-will-collect-all-involved-server-certs-if-not-set-a-temp-file-is-used-instead openconnect_args = # optional arguments to openconnect execute = 0 # execute openconnect command another_dance = 0 # second round of authentication required diff --git a/gp-okta.py b/gp-okta.py index 7ddde29..ef9e9de 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -27,12 +27,15 @@ from __future__ import print_function import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal from lxml import etree import requests +import tempfile if sys.version_info >= (3,): + from urllib.parse import urlparse from urllib.parse import urljoin text_type = str binary_type = bytes else: + from urlparse import urlparse from urlparse import urljoin text_type = unicode binary_type = str @@ -93,6 +96,7 @@ def parse_form(html, current_url = None): def load_conf(cf): + log('load conf') conf = {} keys = ['vpn_url', 'username', 'password', 'okta_url'] line_nr = 0 @@ -121,6 +125,22 @@ def load_conf(cf): conf['username'] = input('username: ').strip() if len(conf.get('password', '').strip()) == 0: conf['password'] = getpass.getpass('password: ').strip() + if len(conf.get('openconnect_certs', '').strip()) == 0: + conf['openconnect_certs'] = tempfile.NamedTemporaryFile() + log('will collect openconnect_certs in temporary file: {0} and verify against them'.format(conf['openconnect_certs'].name)) + else: + conf['openconnect_certs'] = open(conf['openconnect_certs'], 'wb') + if len(conf.get('vpn_url_cert', '').strip()) != 0: + if not os.path.exists(conf.get('vpn_url_cert')): + err('configured vpn_url_cert file does not exist') + conf['openconnect_certs'].write(open(conf.get('vpn_url_cert'), 'rb').read()) # copy it + if len(conf.get('okta_url_cert', '').strip()) != 0: + if not os.path.exists(conf.get('okta_url_cert')): + err('configured okta_url_cert file does not exist') + conf['openconnect_certs'].write(open(conf.get('okta_url_cert'), 'rb').read()) # copy it + if len(conf.get('client_cert', '').strip()) != 0: + if not os.path.exists(conf.get('client_cert')): + err('configured client_cert file does not exist') for k in keys: if k not in conf: err('missing configuration key: {0}'.format(k)) @@ -180,6 +200,13 @@ def get_redirect_url(conf, c, current_url = None): def send_req(conf, s, name, url, data, **kwargs): dbg(conf.get('debug'), '{0}.request'.format(name), url) + if kwargs.get('expected_url'): + purl = list(urlparse(url)) + purl = (purl[0], purl[1].split(':')[0]) + pexp = list(urlparse(kwargs.get('expected_url'))) + pexp = (pexp[0], pexp[1].split(':')[0]) + if purl != pexp: + err('{0}: unexpected url found {1} != {2}'.format(name, purl, pexp)) do_json = True if kwargs.get('json') else False headers = {} if do_json: @@ -187,9 +214,11 @@ def send_req(conf, s, name, url, data, **kwargs): headers['Accept'] = 'application/json' headers['Content-Type'] = 'application/json' if kwargs.get('get'): - r = s.get(url, headers=headers) + r = s.get(url, headers=headers, + verify=kwargs.get('verify', True)) else: - r = s.post(url, data=data, headers=headers) + r = s.post(url, data=data, headers=headers, + verify=kwargs.get('verify', True)) hdump = '\n'.join([k + ': ' + v for k, v in sorted(r.headers.items())]) rr = 'status: {0}\n\n{1}\n\n{2}'.format(r.status_code, hdump, r.text) if r.status_code != 200: @@ -201,16 +230,19 @@ def send_req(conf, s, name, url, data, **kwargs): def paloalto_prelogin(conf, s, again=False): - log('prelogin request') + log('prelogin request [vpn_url]') if again: url = '{0}/ssl-vpn/prelogin.esp'.format(conf.get('vpn_url')) else: url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) - h, c = send_req(conf, s, 'prelogin', url, {}, get=True) + h, c = send_req(conf, s, 'prelogin', url, {}, get=True, + verify=conf.get('vpn_url_cert')) x = parse_xml(c) saml_req = x.find('.//saml-request') if saml_req is None: - err('did not find saml request') + msg = x.find('.//msg') + msg = msg.text.strip() if msg is not None else 'Probably you need a certificate?' + err('did not find saml request. {0}'.format(msg)) if len(saml_req.text.strip()) == 0: err('empty saml request') try: @@ -222,16 +254,17 @@ def paloalto_prelogin(conf, s, again=False): return saml_xml def okta_saml(conf, s, saml_xml): - log('okta saml request') + log('okta saml request [okta_url]') url, data = parse_form(saml_xml) - h, c = send_req(conf, s, 'saml', url, data) + h, c = send_req(conf, s, 'saml', url, data, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) redirect_url = get_redirect_url(conf, c, url) if redirect_url is None: err('did not find redirect url') return redirect_url def okta_auth(conf, s, stateToken = None): - log('okta auth request') + log('okta auth request [okta_url]') url = '{0}/api/v1/authn'.format(conf.get('okta_url')) data = { 'username': conf.get('username'), @@ -243,7 +276,8 @@ def okta_auth(conf, s, stateToken = None): } if stateToken is None else { 'stateToken': stateToken } - h, j = send_req(conf, s, 'auth', url, data, json=True) + h, j = send_req(conf, s, 'auth', url, data, json=True, + verify=conf.get('okta_url_cert')) while True: ok, r = okta_transaction_state(conf, s, j) @@ -266,7 +300,8 @@ def okta_transaction_state(conf, s, j): if len(state_token) == 0: err('empty state token') data = {'stateToken': state_token} - h, j = send_req(conf, s, 'skip', url, data, json=True) + h, j = send_req(conf, s, 'skip', url, data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return False, j # status: password_expired # status: recovery @@ -314,7 +349,7 @@ def okta_mfa(conf, s, j): if len(factors) == 0: err('no factors found') for f in sorted(factors, key=lambda x: x.get('priority', 0), reverse=True): - print(f) + #print(f) ftype = f.get('type') if ftype == 'token:software:totp': r = okta_mfa_totp(conf, s, f, state_token) @@ -333,7 +368,10 @@ def okta_mfa_totp(conf, s, factor, state_token): if len(secret) == 0: code = input('{0} TOTP: '.format(provider)).strip() else: - import pyotp + try: + import pyotp + except ImportError: + err('Need pyotp package, consider doing \'pip install pyotp\' (or similar)') totp = pyotp.TOTP(secret) code = totp.now() code = code or '' @@ -344,8 +382,9 @@ def okta_mfa_totp(conf, s, factor, state_token): 'stateToken': state_token, 'passCode': code } - log('mfa {0} totp request'.format(provider)) - h, j = send_req(conf, s, 'totp mfa', factor.get('url'), data, json=True) + log('mfa {0} totp request: {1} [okta_url]'.format(provider, code)) + h, j = send_req(conf, s, 'totp mfa', factor.get('url'), data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j def okta_mfa_sms(conf, s, factor, state_token): @@ -354,13 +393,16 @@ def okta_mfa_sms(conf, s, factor, state_token): 'factorId': factor.get('id'), 'stateToken': state_token, } - log('mfa {0} sms request'.format(provider)) - h, j = send_req(conf, s, 'sms mfa', factor.get('url'), data, json=True) + log('mfa {0} sms request [okta_url]'.format(provider)) + h, j = send_req(conf, s, 'sms mfa (1)', factor.get('url'), data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) code = input('{0} SMS verification code: '.format(provider)).strip() if len(code) == 0: return None data['passCode'] = code - h, j = send_req(conf, s, 'sms mfa', factor.get('url'), data, json=True) + log('mfa {0} sms request [okta_url]'.format(provider)) + h, j = send_req(conf, s, 'sms mfa (2)', factor.get('url'), data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j def okta_redirect(conf, s, session_token, redirect_url): @@ -378,8 +420,9 @@ def okta_redirect(conf, s, session_token, redirect_url): 'redirectUrl': redirect_url } url = '{0}/login/sessionCookieRedirect'.format(conf.get('okta_url')) - log('okta redirect request') - h, c = send_req(conf, s, 'redirect', url, data) + log('okta redirect request {0} [okta_url]'.format(rc)) + h, c = send_req(conf, s, 'redirect', url, data, + verify=conf.get('okta_url_cert')) state_token = get_state_token(conf, c, url) redirect_url = get_redirect_url(conf, c, url) if redirect_url: @@ -387,11 +430,13 @@ def okta_redirect(conf, s, session_token, redirect_url): else: xhtml = parse_html(c) form_url, form_data = parse_form(xhtml, url) - if state_token is not None: - okta_auth(conf, s, state_token) + if state_token is not None: + log('stateToken: {0}'.format(state_token)) + okta_auth(conf, s, state_token) elif form_url: - log('okta redirect form request') - h, c = send_req(conf, s, 'redirect form', form_url, form_data) + log('okta redirect form request [vpn_url]') + h, c = send_req(conf, s, 'redirect form', form_url, form_data, + expected_url=conf.get('vpn_url'), verify=conf.get('vpn_url_cert')) saml_username = h.get('saml-username', '').strip() prelogin_cookie = h.get('prelogin-cookie', '').strip() if saml_username and prelogin_cookie: @@ -401,7 +446,7 @@ def okta_redirect(conf, s, session_token, redirect_url): return saml_username, prelogin_cookie def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): - log('getconfig request') + log('getconfig request [vpn_url]') url = '{0}/global-protect/getconfig.esp'.format(conf.get('vpn_url')) data = { 'user': saml_username, @@ -416,7 +461,8 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): 'prelogin-cookie': prelogin_cookie, 'ipv6-support': 'yes' } - h, c = send_req(conf, s, 'getconfig', url, data) + h, c = send_req(conf, s, 'getconfig', url, data, + verify=conf.get('vpn_url_cert')) x = parse_xml(c) xtmp = x.find('.//portal-userauthcookie') if xtmp is None: @@ -424,7 +470,12 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): portal_userauthcookie = xtmp.text if len(portal_userauthcookie) == 0: err('empty portal_userauthcookie') - return portal_userauthcookie + gateway = x.find('.//gateways//entry').get('name') + with open(conf['openconnect_certs'].name, 'ab') as cafile: + for entry in x.find('.//root-ca'): + cert = entry.find('.//cert').text + cafile.write(to_b(cert)) + return portal_userauthcookie, gateway # Combined first half of okta_saml with second half of okta_redirect def okta_saml_2(conf, s, saml_xml): @@ -445,8 +496,8 @@ def okta_saml_2(conf, s, saml_xml): saml_username = r.headers.get('saml-username', '').strip() if len(saml_username) == 0 and not again: err('saml-username empty') - saml_auth_status = r.headers.get('saml-auth-status', '').strip() - saml_slo = r.headers.get('saml-slo', '').strip() + #saml_auth_status = r.headers.get('saml-auth-status', '').strip() + #saml_slo = r.headers.get('saml-slo', '').strip() prelogin_cookie = r.headers.get('prelogin-cookie', '').strip() if len(prelogin_cookie) == 0: err('prelogin-cookie empty') @@ -456,20 +507,24 @@ def main(): if len(sys.argv) < 2: print('usage: {0} '.format(sys.argv[0])) sys.exit(1) + conf = load_conf(sys.argv[1]) s = requests.Session() - if conf.get('cert'): - s.cert = conf.get('cert') - + + if conf.get('client_cert'): + s.cert = conf.get('client_cert') + s.headers['User-Agent'] = 'PAN GlobalProtect' saml_xml = paloalto_prelogin(conf, s) redirect_url = okta_saml(conf, s, saml_xml) token = okta_auth(conf, s) log('sessionToken: {0}'.format(token)) saml_username, prelogin_cookie = okta_redirect(conf, s, token, redirect_url) - userauthcookie = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) + userauthcookie, gateway = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) + log('portal-userauthcookie: {0}'.format(userauthcookie)) + log('gateway: {0}'.format(gateway)) # Another dance? if conf.get('another_dance', '').lower() in ['1', 'true']: @@ -487,14 +542,16 @@ def main(): cookie = userauthcookie username = saml_username - cmd = conf.get('openconnect_cmd') or 'openconnect' + cmd = conf.get('openconnect_cmd', 'openconnect') cmd += ' --protocol=gp -u \'{0}\'' cmd += ' --usergroup {1}' - if conf.get('cert'): - cmd += ' --certificate=\'{0}\''.format(conf.get('cert')) + if conf.get('client_cert'): + cmd += ' --certificate=\'{0}\''.format(conf.get('client_cert')) + if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: + cmd += ' --cafile=\'{0}\''.format(conf.get('openconnect_certs').name) cmd += ' --passwd-on-stdin ' + conf.get('openconnect_args', '') + ' \'{2}\'' cmd = cmd.format(username, cookie_type, conf.get('vpn_url')) - gw = (conf.get('gateway') or '').strip() + gw = conf.get('gateway', gateway).strip() bugs = '' if conf.get('bug.nl', '').lower() in ['1', 'true']: bugs += '\\n' From 15c57348789b73e4798896fa41e598781c8f039f Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Wed, 12 Jun 2019 11:06:44 +0200 Subject: [PATCH 08/29] - flush openconnect_certs file in between and finally close before giving to openconnect call --- gp-okta.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index ef9e9de..e62ade5 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -148,6 +148,7 @@ def load_conf(cf): if len(conf[k].strip()) == 0: err('empty configuration key: {0}'.format(k)) conf['debug'] = conf.get('debug', '').lower() in ['1', 'true'] + conf['openconnect_certs'].flush() return conf def mfa_priority(conf, ftype, fprovider): @@ -471,10 +472,10 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): if len(portal_userauthcookie) == 0: err('empty portal_userauthcookie') gateway = x.find('.//gateways//entry').get('name') - with open(conf['openconnect_certs'].name, 'ab') as cafile: - for entry in x.find('.//root-ca'): - cert = entry.find('.//cert').text - cafile.write(to_b(cert)) + for entry in x.find('.//root-ca'): + cert = entry.find('.//cert').text + conf['openconnect_certs'].write(to_b(cert)) + conf['openconnect_certs'].flush() return portal_userauthcookie, gateway # Combined first half of okta_saml with second half of okta_redirect @@ -562,6 +563,7 @@ def main(): else: pcmd = 'printf \'' + bugs + '{0}\''.format(cookie) print() + conf['openconnect_certs'].close() if conf.get('execute', '').lower() in ['1', 'true']: cmd = shlex.split(cmd) cmd = [os.path.expandvars(os.path.expanduser(x)) for x in cmd] From d14109df282d5e0d396d2ae9806bf872ccd5499b Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Wed, 12 Jun 2019 12:53:55 +0200 Subject: [PATCH 09/29] do not close file, flush is enough for now - otherwise it might get deleted in case of the temp file solution --- gp-okta.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index e62ade5..5f6cbb5 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -563,7 +563,6 @@ def main(): else: pcmd = 'printf \'' + bugs + '{0}\''.format(cookie) print() - conf['openconnect_certs'].close() if conf.get('execute', '').lower() in ['1', 'true']: cmd = shlex.split(cmd) cmd = [os.path.expandvars(os.path.expanduser(x)) for x in cmd] From 92a167d00e42944467e64cafd2952b2ca1759e13 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 13 Jun 2019 16:59:18 +0200 Subject: [PATCH 10/29] Use stderr for error messages. --- gp-okta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index 5f6cbb5..efc505a 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -57,7 +57,7 @@ def dbg(d, h, *xs): print('---') def err(s): - print('err: {0}'.format(s)) + print('err: {0}'.format(s), file=sys.stderr) sys.exit(1) def parse_xml(xml): From 59af2acf8e834c04d1beb0481c4117c7140dee9e Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 13 Jun 2019 17:09:53 +0200 Subject: [PATCH 11/29] Shorten imports --- gp-okta.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index efc505a..1557301 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -30,13 +30,11 @@ import requests import tempfile if sys.version_info >= (3,): - from urllib.parse import urlparse - from urllib.parse import urljoin + from urllib.parse import urlparse, urljoin text_type = str binary_type = bytes else: - from urlparse import urlparse - from urlparse import urljoin + from urlparse import urlparse, urljoin text_type = unicode binary_type = str input = raw_input From d6a3863774e0d01e0702c7ef68d7bdc48679c9c2 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 13 Jun 2019 17:10:52 +0200 Subject: [PATCH 12/29] Add the committing people all to the copyright notice --- gp-okta.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gp-okta.py b/gp-okta.py index 1557301..6709580 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -4,6 +4,9 @@ The MIT License (MIT) Copyright (C) 2018 Andris Raugulis (moo@arthepsy.eu) + Copyright (C) 2018 Nick Lanham (nick@afternight.org) + Copyright (C) 2019 Aaron Lindsay (aclindsa@gmail.com) + Copyright (C) 2019 Tino Lange (coldcoff@yahoo.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From ceeaebba6d74a027374b8b6322a0a018b3e6045d Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 13 Jun 2019 17:17:58 +0200 Subject: [PATCH 13/29] Do the "another_dance" at the desired gateway to obtain a cookie that is valid there --- gp-okta.py | 73 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 6709580..a820462 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -230,15 +230,20 @@ def send_req(conf, s, name, url, data, **kwargs): return r.headers, parse_rjson(r) return r.headers, r.text - -def paloalto_prelogin(conf, s, again=False): - log('prelogin request [vpn_url]') - if again: - url = '{0}/ssl-vpn/prelogin.esp'.format(conf.get('vpn_url')) +def paloalto_prelogin(conf, s, gateway=None): + verify = None + if gateway: + # 2nd round: use gateway + log('prelogin request [gateway]') + url = 'https://{0}/ssl-vpn/prelogin.esp'.format(gateway) + verify = conf.get('vpn_url_cert') else: + # 1st round: use portal + log('prelogin request [vpn_url]') url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) - h, c = send_req(conf, s, 'prelogin', url, {}, get=True, - verify=conf.get('vpn_url_cert')) + if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: + verify = conf.get('openconnect_certs').name + h, c = send_req(conf, s, 'prelogin', url, {}, get=True, verify=verify) x = parse_xml(c) saml_req = x.find('.//saml-request') if saml_req is None: @@ -472,7 +477,7 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): portal_userauthcookie = xtmp.text if len(portal_userauthcookie) == 0: err('empty portal_userauthcookie') - gateway = x.find('.//gateways//entry').get('name') + gateway = x.find('.//gateways//entry').get('name') # FIXME: this just grabs the very first, probably not what you want for entry in x.find('.//root-ca'): cert = entry.find('.//cert').text conf['openconnect_certs'].write(to_b(cert)) @@ -480,29 +485,27 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): return portal_userauthcookie, gateway # Combined first half of okta_saml with second half of okta_redirect -def okta_saml_2(conf, s, saml_xml): - log('okta saml request') +def okta_saml_2(conf, s, gateway, saml_xml): + log('okta saml request (2) [okta_url]') url, data = parse_form(saml_xml) - r = s.post(url, data=data) - if r.status_code != 200: - err('redirect request failed. {0}'.format(reprr(r))) - dbg(conf.get('debug'), 'redirect.response', r.status_code, r.text) - xhtml = parse_html(r.text) - + h, c = send_req(conf, s, 'okta saml request (2)', url, data, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) + xhtml = parse_html(c) url, data = parse_form(xhtml) - log('okta redirect form request') - r = s.post(url, data=data) - if r.status_code != 200: - err('redirect form request failed. {0}'.format(reprr(r))) - dbg(conf.get('debug'), 'form.response', r.status_code, r.text) - saml_username = r.headers.get('saml-username', '').strip() - if len(saml_username) == 0 and not again: + + log('okta redirect form request (2) [gateway]') + verify = None + if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: + verify = conf.get('openconnect_certs').name + h, c = send_req(conf, s, 'okta redirect form (2)', url, data, + expected_url='https://{0}'.format(gateway), verify=verify) + saml_username = h.get('saml-username', '').strip() + if len(saml_username) == 0: err('saml-username empty') - #saml_auth_status = r.headers.get('saml-auth-status', '').strip() - #saml_slo = r.headers.get('saml-slo', '').strip() - prelogin_cookie = r.headers.get('prelogin-cookie', '').strip() + prelogin_cookie = h.get('prelogin-cookie', '').strip() if len(prelogin_cookie) == 0: err('prelogin-cookie empty') + return saml_username, prelogin_cookie def main(): @@ -524,14 +527,16 @@ def main(): log('sessionToken: {0}'.format(token)) saml_username, prelogin_cookie = okta_redirect(conf, s, token, redirect_url) userauthcookie, gateway = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) + gateway = conf.get('gateway', gateway).strip() log('portal-userauthcookie: {0}'.format(userauthcookie)) log('gateway: {0}'.format(gateway)) + log('saml-username: {0}'.format(saml_username)) - # Another dance? + # We're done with the portal now. Another dance with the gateway now? if conf.get('another_dance', '').lower() in ['1', 'true']: - saml_xml = paloalto_prelogin(conf, s, again=True) - saml_username, prelogin_cookie = okta_saml_2(conf, s, saml_xml) + saml_xml = paloalto_prelogin(conf, s, gateway) + saml_username, prelogin_cookie = okta_saml_2(conf, s, gateway, saml_xml) log('saml-username: {0}'.format(saml_username)) log('prelogin-cookie: {0}'.format(prelogin_cookie)) @@ -544,6 +549,7 @@ def main(): cookie = userauthcookie username = saml_username + cmd = conf.get('openconnect_cmd', 'openconnect') cmd += ' --protocol=gp -u \'{0}\'' cmd += ' --usergroup {1}' @@ -552,15 +558,16 @@ def main(): if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: cmd += ' --cafile=\'{0}\''.format(conf.get('openconnect_certs').name) cmd += ' --passwd-on-stdin ' + conf.get('openconnect_args', '') + ' \'{2}\'' - cmd = cmd.format(username, cookie_type, conf.get('vpn_url')) - gw = conf.get('gateway', gateway).strip() + cmd = cmd.format(username, cookie_type, + 'https://{0}'.format(gateway) if conf.get('another_dance', '').lower() in ['1', 'true'] else conf.get('vpn_url')) + bugs = '' if conf.get('bug.nl', '').lower() in ['1', 'true']: bugs += '\\n' if conf.get('bug.username', '').lower() in ['1', 'true']: bugs += '{0}\\n'.format(username.replace('\\', '\\\\')) - if len(gw) > 0: - pcmd = 'printf \'' + bugs + '{0}\\n{1}\''.format(cookie, gw) + if len(gateway) > 0: + pcmd = 'printf \'' + bugs + '{0}\\n{1}\''.format(cookie, gateway) else: pcmd = 'printf \'' + bugs + '{0}\''.format(cookie) print() From 9ee875bf569371317d18fb5a5148bb50884666f1 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Fri, 14 Jun 2019 12:58:30 +0200 Subject: [PATCH 14/29] Some portals seem not to return root-ca entries, detect that and just continue --- gp-okta.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index a820462..44dc5fd 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -478,10 +478,12 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): if len(portal_userauthcookie) == 0: err('empty portal_userauthcookie') gateway = x.find('.//gateways//entry').get('name') # FIXME: this just grabs the very first, probably not what you want - for entry in x.find('.//root-ca'): - cert = entry.find('.//cert').text - conf['openconnect_certs'].write(to_b(cert)) - conf['openconnect_certs'].flush() + xtmp = x.find('.//root-ca') + if xtmp is not None: + for entry in xtmp: + cert = entry.find('.//cert').text + conf['openconnect_certs'].write(to_b(cert)) + conf['openconnect_certs'].flush() return portal_userauthcookie, gateway # Combined first half of okta_saml with second half of okta_redirect From 54145a7545d29703c167595975986de204e615f6 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Wed, 3 Jul 2019 23:46:50 +0200 Subject: [PATCH 15/29] Bugfix: guard case when error msg is not returned --- gp-okta.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index 44dc5fd..d9b2c05 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -248,7 +248,12 @@ def paloalto_prelogin(conf, s, gateway=None): saml_req = x.find('.//saml-request') if saml_req is None: msg = x.find('.//msg') - msg = msg.text.strip() if msg is not None else 'Probably you need a certificate?' + if msg is not None: + msg = msg.text + if msg is not None: + msg = msg.strip() + else: + msg = 'Probably you need a certificate?' err('did not find saml request. {0}'.format(msg)) if len(saml_req.text.strip()) == 0: err('empty saml request') From f0c785f19c8fc8a5b36381365401b4b82f274365 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 4 Jul 2019 00:02:18 +0200 Subject: [PATCH 16/29] Bugfix: use unicode literals also on python2, this avoids errors with formatstring.format(...) (see: https://stackoverflow.com/questions/3235386/python-using-format-on-a-unicode-escaped-string) --- gp-okta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index d9b2c05..5b70161 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -26,7 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from __future__ import print_function +from __future__ import print_function, unicode_literals import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal from lxml import etree import requests From c4e7498f4d489be73dcd4726f825566bcb5cb4d2 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Sun, 7 Jul 2019 19:06:59 +0200 Subject: [PATCH 17/29] Feature: Allow Yubikey `webauthn` authentication, if configured as 2FA in Okta. Just insert your YubiKey and press the blinking button, when asked to do so. Needs `fido2` packages to work (`pip install fido2`). --- gp-okta.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 5b70161..f0aa9d4 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -27,10 +27,10 @@ THE SOFTWARE. """ from __future__ import print_function, unicode_literals -import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal +import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal, tempfile, traceback + from lxml import etree import requests -import tempfile if sys.version_info >= (3,): from urllib.parse import urlparse, urljoin @@ -42,6 +42,25 @@ else: binary_type = str input = raw_input +# Optional: fido2 support (webauthn via Yubikey) +have_fido = False +try: + from fido2.utils import websafe_decode, websafe_encode + from fido2.hid import CtapHidDevice + from fido2.client import Fido2Client + have_fido = True +except ImportError: + pass + +# Optional: pyotp support +have_pyotp = False +try: + import pyotp + have_pyotp = True +except ImportError: + pass + + to_b = lambda v: v if isinstance(v, binary_type) else v.encode('utf-8') to_u = lambda v: v if isinstance(v, text_type) else v.decode('utf-8') @@ -155,7 +174,7 @@ def load_conf(cf): def mfa_priority(conf, ftype, fprovider): if ftype == 'token:software:totp': ftype = 'totp' - if ftype not in ['totp', 'sms']: + if ftype not in ['totp', 'sms', 'webauthn']: return 0 mfa_order = conf.get('mfa_order', '') if ftype in mfa_order: @@ -163,7 +182,7 @@ def mfa_priority(conf, ftype, fprovider): else: priority = 0 value = conf.get('{0}.{1}'.format(ftype, fprovider)) - if ftype == 'sms': + if ftype in ('sms', 'webauthn'): if not (value or '').lower() in ['1', 'true']: value = None line_nr = conf.get('{0}.{1}.line'.format(ftype, fprovider), 0) @@ -367,6 +386,8 @@ def okta_mfa(conf, s, j): r = okta_mfa_totp(conf, s, f, state_token) elif ftype == 'sms': r = okta_mfa_sms(conf, s, f, state_token) + elif ftype == 'webauthn': + r = okta_mfa_webauthn(conf, s, f, state_token) else: r = None if r is not None: @@ -380,9 +401,7 @@ def okta_mfa_totp(conf, s, factor, state_token): if len(secret) == 0: code = input('{0} TOTP: '.format(provider)).strip() else: - try: - import pyotp - except ImportError: + if not have_pyotp: err('Need pyotp package, consider doing \'pip install pyotp\' (or similar)') totp = pyotp.TOTP(secret) code = totp.now() @@ -403,7 +422,7 @@ def okta_mfa_sms(conf, s, factor, state_token): provider = factor.get('provider', '') data = { 'factorId': factor.get('id'), - 'stateToken': state_token, + 'stateToken': state_token } log('mfa {0} sms request [okta_url]'.format(provider)) h, j = send_req(conf, s, 'sms mfa (1)', factor.get('url'), data, json=True, @@ -417,6 +436,52 @@ def okta_mfa_sms(conf, s, factor, state_token): expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j +def okta_mfa_webauthn(conf, s, factor, state_token): + if not have_fido: + err('Need fido2 package(s) for webauthn. Consider doing `pip install fido2` (or similar)') + devices = list(CtapHidDevice.list_devices()) + if not devices: + err('webauthn configured, but no U2F devices found') + return None + provider = factor.get('provider', '') + log('mfa {0} challenge request [okta_url]'.format(provider)) + data = { + 'stateToken': state_token + } + h, j = send_req(conf, s, 'webauthn mfa challenge', factor.get('url'), data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) + factor = j['_embedded']['factor'] + profile = factor['profile'] + purl = list(urlparse(conf.get('okta_url'))) + rpid = purl[1].split(':')[0] + origin = '{0}://{1}'.format(purl[0], rpid) + challenge = factor['_embedded']['challenge']['challenge'] + credentialId = websafe_decode(profile['credentialId']) + allow_list = [{'type': 'public-key', 'id': credentialId}] + for dev in devices: + client = Fido2Client(dev, origin) + print('!!! Touch the flashing U2F device to authenticate... !!!') + try: + result = client.get_assertion(rpid, challenge, allow_list) + dbg(conf.get('debug'), 'assertion.result', result) + break + except: + traceback.print_exc(file=sys.stderr) + result = None + if not result: + return None + assertion, client_data = result[0][0], result[1] # only one cred in allowList, so only one response. + data = { + 'stateToken': state_token, + 'clientData': to_u(base64.b64encode(client_data)), + 'signatureData': to_u(base64.b64encode(assertion.signature)), + 'authenticatorData': to_u(base64.b64encode(assertion.auth_data)) + } + log('mfa {0} signature request [okta_url]'.format(provider)) + h, j = send_req(conf, s, 'uf2 mfa signature', j['_links']['next']['href'], data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) + return j + def okta_redirect(conf, s, session_token, redirect_url): rc = 0 form_url, form_data = None, {} @@ -589,7 +654,8 @@ def main(): cp.communicate() else: print('{0} | {1}'.format(pcmd, cmd)) + return 0 if __name__ == '__main__': - main() + sys.exit(main()) \ No newline at end of file From 536a88f4870b5b4dc72457959d8cd41514bbfa69 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 3 Sep 2019 10:30:21 +0200 Subject: [PATCH 18/29] Bugfix: formatting of error message when send_req() fails --- gp-okta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index f0aa9d4..433e400 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -243,7 +243,7 @@ def send_req(conf, s, name, url, data, **kwargs): hdump = '\n'.join([k + ': ' + v for k, v in sorted(r.headers.items())]) rr = 'status: {0}\n\n{1}\n\n{2}'.format(r.status_code, hdump, r.text) if r.status_code != 200: - err('okta {0} request failed. {0}'.format(rr)) + err('{0}.request failed.\n{1}'.format(name, rr)) dbg(conf.get('debug'), '{0}.response'.format(name), rr) if do_json: return r.headers, parse_rjson(r) From c9944a15c8ce708e184022972b223dc6623bf272 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 3 Sep 2019 10:31:48 +0200 Subject: [PATCH 19/29] No functional changes, just document (commented out) more potential parameters for getconfig.esp (found in OpenConnect source code and docs) --- gp-okta.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 433e400..5585ab6 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -526,14 +526,20 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): log('getconfig request [vpn_url]') url = '{0}/global-protect/getconfig.esp'.format(conf.get('vpn_url')) data = { + #'jnlpReady': 'jnlpReady', + #'ok': 'Login', + #'direct': 'yes', + 'clientVer': '4100', + #'prot': 'https:', + 'clientos': 'Windows', + 'os-version': 'Microsoft Windows 10 Pro, 64-bit', + #'server': '', + 'computer': 'DESKTOP', + #'preferred-ip': '', + 'inputStr': '', 'user': saml_username, 'passwd': '', - 'inputStr': '', - 'clientVer': '4100', - 'clientos': 'Windows', 'clientgpversion': '4.1.0.98', - 'computer': 'DESKTOP', - 'os-version': 'Microsoft Windows 10 Pro, 64-bit', # 'host-id': '00:11:22:33:44:55' 'prelogin-cookie': prelogin_cookie, 'ipv6-support': 'yes' @@ -658,4 +664,4 @@ def main(): if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) From fce587af6145dcae1d83c611abf04978da10cba9 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Thu, 30 May 2019 16:12:20 -0700 Subject: [PATCH 20/29] Treat "Symantec VIP Access" as TOTP Although not advertised as such, Symantec VIP Access is based on TOTP and there is an open-source implementation for provisioning the tokens and obtaining the TOTP secret at http://github.com/dlenski/python-vipaccess --- gp-okta.conf | 1 + gp-okta.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gp-okta.conf b/gp-okta.conf index 7eead47..73af663 100644 --- a/gp-okta.conf +++ b/gp-okta.conf @@ -14,6 +14,7 @@ password = mypass sms.okta = 0 #totp.okta = ABCDEFGHIJKLMNOP #totp.google = ABCDEFGHIJKLMNOP +#totp.symantec = ABCDEFGHIJKLMNOP gateway = Manual ny1-gw.example.com # optional hardcoded gateway diff --git a/gp-okta.py b/gp-okta.py index 5585ab6..0f11c19 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -172,7 +172,7 @@ def load_conf(cf): return conf def mfa_priority(conf, ftype, fprovider): - if ftype == 'token:software:totp': + if ftype == 'token:software:totp' or (ftype, fprovider) == ('token', 'symantec'): ftype = 'totp' if ftype not in ['totp', 'sms', 'webauthn']: return 0 @@ -382,7 +382,8 @@ def okta_mfa(conf, s, j): for f in sorted(factors, key=lambda x: x.get('priority', 0), reverse=True): #print(f) ftype = f.get('type') - if ftype == 'token:software:totp': + fprovider = f.get('provider') + if ftype == 'token:software:totp' or (ftype, fprovider) == ('token', 'symantec'): r = okta_mfa_totp(conf, s, f, state_token) elif ftype == 'sms': r = okta_mfa_sms(conf, s, f, state_token) From 8f628a9f78b6c3bc0b27a5f249ea335548f2b69e Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 14 Apr 2020 11:51:47 +0200 Subject: [PATCH 21/29] make the error message more verbose in case a SAML request is missing in the response --- gp-okta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index 0f11c19..53f8e89 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -272,7 +272,7 @@ def paloalto_prelogin(conf, s, gateway=None): if msg is not None: msg = msg.strip() else: - msg = 'Probably you need a certificate?' + msg = 'Probably SAML is disabled at the portal? Or you need a certificate? Try another_dance=0 with some concrete gateway instead.' err('did not find saml request. {0}'.format(msg)) if len(saml_req.text.strip()) == 0: err('empty saml request') From fa5e58f333c1109e70f14785dec34a2fed85ebfa Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 14 Apr 2020 11:52:42 +0200 Subject: [PATCH 22/29] allow to dance only with some concrete gateway when SAML is disabled at the portal (but not at the gateway) --- gp-okta.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 53f8e89..e8700f8 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -599,28 +599,45 @@ def main(): if conf.get('client_cert'): s.cert = conf.get('client_cert') + another_dance = conf.get('another_dance', '').lower() in ['1', 'true'] + + if conf.get('gateway'): + gateway = conf.get('gateway').strip() + + if gateway and not another_dance: + vpn_url = 'https://{0}'.format(gateway) + if vpn_url != conf.get('vpn_url'): + log('Discarding \'vpn_url\', as concrete gateway is given and another_dance = 0') + conf['vpn_url'] = vpn_url + + userauthcookie = None + s.headers['User-Agent'] = 'PAN GlobalProtect' - saml_xml = paloalto_prelogin(conf, s) + if another_dance or not gateway: + saml_xml = paloalto_prelogin(conf, s) + else: + saml_xml = paloalto_prelogin(conf, s, gateway) redirect_url = okta_saml(conf, s, saml_xml) token = okta_auth(conf, s) log('sessionToken: {0}'.format(token)) saml_username, prelogin_cookie = okta_redirect(conf, s, token, redirect_url) - userauthcookie, gateway = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) - gateway = conf.get('gateway', gateway).strip() + if not gateway: + userauthcookie, gateway = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) + gateway = conf.get('gateway', gateway).strip() log('portal-userauthcookie: {0}'.format(userauthcookie)) log('gateway: {0}'.format(gateway)) log('saml-username: {0}'.format(saml_username)) - # We're done with the portal now. Another dance with the gateway now? - if conf.get('another_dance', '').lower() in ['1', 'true']: + if another_dance: + # 1st step: dance with the portal, 2nd step: dance with the gateway saml_xml = paloalto_prelogin(conf, s, gateway) saml_username, prelogin_cookie = okta_saml_2(conf, s, gateway, saml_xml) log('saml-username: {0}'.format(saml_username)) log('prelogin-cookie: {0}'.format(prelogin_cookie)) - if userauthcookie == 'empty' and prelogin_cookie != 'empty': + if (not userauthcookie or userauthcookie == 'empty') and prelogin_cookie != 'empty': cookie_type = "gateway:prelogin-cookie" cookie = prelogin_cookie else: From 748749e78f5e2d7aac956674461e4817d8f824ac Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 14 Apr 2020 11:53:46 +0200 Subject: [PATCH 23/29] trim trailing whitespace --- gp-okta.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index e8700f8..2d7d456 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -2,22 +2,22 @@ # -*- coding: utf-8 -*- """ The MIT License (MIT) - + Copyright (C) 2018 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2018 Nick Lanham (nick@afternight.org) Copyright (C) 2019 Aaron Lindsay (aclindsa@gmail.com) Copyright (C) 2019 Tino Lange (coldcoff@yahoo.com) - + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -226,7 +226,7 @@ def send_req(conf, s, name, url, data, **kwargs): purl = (purl[0], purl[1].split(':')[0]) pexp = list(urlparse(kwargs.get('expected_url'))) pexp = (pexp[0], pexp[1].split(':')[0]) - if purl != pexp: + if purl != pexp: err('{0}: unexpected url found {1} != {2}'.format(name, purl, pexp)) do_json = True if kwargs.get('json') else False headers = {} @@ -571,7 +571,7 @@ def okta_saml_2(conf, s, gateway, saml_xml): expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) xhtml = parse_html(c) url, data = parse_form(xhtml) - + log('okta redirect form request (2) [gateway]') verify = None if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: @@ -593,7 +593,7 @@ def main(): sys.exit(1) conf = load_conf(sys.argv[1]) - + s = requests.Session() if conf.get('client_cert'): @@ -643,7 +643,7 @@ def main(): else: cookie_type = "portal:portal-userauthcookie" cookie = userauthcookie - + username = saml_username cmd = conf.get('openconnect_cmd', 'openconnect') From 3946d55e59e1e688d878f066cb7278576b76fb94 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Wed, 15 Apr 2020 12:27:09 +0200 Subject: [PATCH 24/29] Add some good ideas from @limitz404 --- gp-okta.py | 139 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 95 insertions(+), 44 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 2d7d456..8e50758 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -6,7 +6,8 @@ Copyright (C) 2018 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2018 Nick Lanham (nick@afternight.org) Copyright (C) 2019 Aaron Lindsay (aclindsa@gmail.com) - Copyright (C) 2019 Tino Lange (coldcoff@yahoo.com) + Copyright (C) 2019 Taylor Dean (taylor@makeshift.dev) + Copyright (C) 2019-2020 Tino Lange (coldcoff@yahoo.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -31,6 +32,7 @@ import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal, tempfi from lxml import etree import requests +import argparse if sys.version_info >= (3,): from urllib.parse import urlparse, urljoin @@ -60,24 +62,35 @@ try: except ImportError: pass +# Optional: gnupg support +have_gnupg = False +try: + import gnupg + have_gnupg = True +except ImportError: + pass to_b = lambda v: v if isinstance(v, binary_type) else v.encode('utf-8') to_u = lambda v: v if isinstance(v, text_type) else v.decode('utf-8') +quiet = False def log(s): - print('[INFO] {0}'.format(s)) + if not quiet: + print('[INFO] {0}'.format(s)) def dbg(d, h, *xs): + if quiet: + return if not d: return - print('# {0}:'.format(h)) + print('[DEBUG] {0}:'.format(h)) for x in xs: - print(x) - print('---') + print('[DEBUG] {0}'.format(x)) + print('[DEBUG] ---') def err(s): - print('err: {0}'.format(s), file=sys.stderr) + print('[ERROR] {0}'.format(s), file=sys.stderr) sys.exit(1) def parse_xml(xml): @@ -85,21 +98,21 @@ def parse_xml(xml): xml = bytes(bytearray(xml, encoding='utf-8')) parser = etree.XMLParser(ns_clean=True, recover=True) return etree.fromstring(xml, parser) - except: - err('failed to parse xml') + except Exception as e: + err('failed to parse xml: ' + e) def parse_html(html): try: parser = etree.HTMLParser() return etree.fromstring(html, parser) - except: - err('failed to parse html') + except Exception as e: + err('failed to parse html: ' + e) def parse_rjson(r): try: return r.json() - except: - err('failed to parse json') + except Exception as e: + err('failed to parse json: ' + e) def parse_form(html, current_url = None): xform = html.find('.//form') @@ -119,21 +132,22 @@ def load_conf(cf): log('load conf') conf = {} keys = ['vpn_url', 'username', 'password', 'okta_url'] + if isinstance(cf, binary_type): + cf = cf.decode('utf-8') line_nr = 0 - with io.open(cf, 'r', encoding='utf-8') as fp: - for rline in fp: - line_nr += 1 - line = rline.strip() - mx = re.match(r'^\s*([^=\s]+)\s*=\s*(.*?)\s*(?:#\s+.*)?\s*$', line) - if mx: - k, v = mx.group(1).lower(), mx.group(2) - if k.startswith('#'): - continue - for q in '"\'': - if re.match(r'^{0}.*{0}$'.format(q), v): - v = v[1:-1] - conf[k] = v - conf['{0}.line'.format(k)] = line_nr + for rline in cf.split('\n'): + line_nr += 1 + line = rline.strip() + mx = re.match(r'^\s*([^=\s]+)\s*=\s*(.*?)\s*(?:#\s+.*)?\s*$', line) + if mx: + k, v = mx.group(1).lower(), mx.group(2) + if k.startswith('#'): + continue + for q in '"\'': + if re.match(r'^{0}.*{0}$'.format(q), v): + v = v[1:-1] + conf[k] = v + conf['{0}.line'.format(k)] = line_nr for k, v in os.environ.items(): k = k.lower() if k.startswith('gp_'): @@ -262,7 +276,7 @@ def paloalto_prelogin(conf, s, gateway=None): url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0: verify = conf.get('openconnect_certs').name - h, c = send_req(conf, s, 'prelogin', url, {}, get=True, verify=verify) + _h, c = send_req(conf, s, 'prelogin', url, {}, get=True, verify=verify) x = parse_xml(c) saml_req = x.find('.//saml-request') if saml_req is None: @@ -278,8 +292,8 @@ def paloalto_prelogin(conf, s, gateway=None): err('empty saml request') try: saml_raw = base64.b64decode(saml_req.text) - except: - err('failed to decode saml request') + except Exception as e: + err('failed to decode saml request: ' + e) dbg(conf.get('debug'), 'prelogin.decoded', saml_raw) saml_xml = parse_html(saml_raw) return saml_xml @@ -287,7 +301,7 @@ def paloalto_prelogin(conf, s, gateway=None): def okta_saml(conf, s, saml_xml): log('okta saml request [okta_url]') url, data = parse_form(saml_xml) - h, c = send_req(conf, s, 'saml', url, data, + _h, c = send_req(conf, s, 'saml', url, data, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) redirect_url = get_redirect_url(conf, c, url) if redirect_url is None: @@ -307,7 +321,7 @@ def okta_auth(conf, s, stateToken = None): } if stateToken is None else { 'stateToken': stateToken } - h, j = send_req(conf, s, 'auth', url, data, json=True, + _h, j = send_req(conf, s, 'auth', url, data, json=True, verify=conf.get('okta_url_cert')) while True: @@ -331,7 +345,7 @@ def okta_transaction_state(conf, s, j): if len(state_token) == 0: err('empty state token') data = {'stateToken': state_token} - h, j = send_req(conf, s, 'skip', url, data, json=True, + _h, j = send_req(conf, s, 'skip', url, data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return False, j # status: password_expired @@ -415,7 +429,7 @@ def okta_mfa_totp(conf, s, factor, state_token): 'passCode': code } log('mfa {0} totp request: {1} [okta_url]'.format(provider, code)) - h, j = send_req(conf, s, 'totp mfa', factor.get('url'), data, json=True, + _h, j = send_req(conf, s, 'totp mfa', factor.get('url'), data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j @@ -426,14 +440,14 @@ def okta_mfa_sms(conf, s, factor, state_token): 'stateToken': state_token } log('mfa {0} sms request [okta_url]'.format(provider)) - h, j = send_req(conf, s, 'sms mfa (1)', factor.get('url'), data, json=True, + _h, j = send_req(conf, s, 'sms mfa (1)', factor.get('url'), data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) code = input('{0} SMS verification code: '.format(provider)).strip() if len(code) == 0: return None data['passCode'] = code log('mfa {0} sms request [okta_url]'.format(provider)) - h, j = send_req(conf, s, 'sms mfa (2)', factor.get('url'), data, json=True, + _h, j = send_req(conf, s, 'sms mfa (2)', factor.get('url'), data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j @@ -449,7 +463,7 @@ def okta_mfa_webauthn(conf, s, factor, state_token): data = { 'stateToken': state_token } - h, j = send_req(conf, s, 'webauthn mfa challenge', factor.get('url'), data, json=True, + _h, j = send_req(conf, s, 'webauthn mfa challenge', factor.get('url'), data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) factor = j['_embedded']['factor'] profile = factor['profile'] @@ -479,7 +493,7 @@ def okta_mfa_webauthn(conf, s, factor, state_token): 'authenticatorData': to_u(base64.b64encode(assertion.auth_data)) } log('mfa {0} signature request [okta_url]'.format(provider)) - h, j = send_req(conf, s, 'uf2 mfa signature', j['_links']['next']['href'], data, json=True, + _h, j = send_req(conf, s, 'uf2 mfa signature', j['_links']['next']['href'], data, json=True, expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j @@ -545,7 +559,7 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): 'prelogin-cookie': prelogin_cookie, 'ipv6-support': 'yes' } - h, c = send_req(conf, s, 'getconfig', url, data, + _h, c = send_req(conf, s, 'getconfig', url, data, verify=conf.get('vpn_url_cert')) x = parse_xml(c) xtmp = x.find('.//portal-userauthcookie') @@ -588,11 +602,48 @@ def okta_saml_2(conf, s, gateway, saml_xml): return saml_username, prelogin_cookie def main(): - if len(sys.argv) < 2: - print('usage: {0} '.format(sys.argv[0])) - sys.exit(1) - conf = load_conf(sys.argv[1]) + parser = argparse.ArgumentParser(description=""" + This is an OpenConnect wrapper script that automates connecting to a + PaloAlto Networks GlobalProtect VPN using Okta 2FA.""") + + parser.add_argument('conf_file', + help='e.g. ~/.config/gp-okta.conf') + parser.add_argument('--gpg-decrypt', action='store_true', + help='use gpg and settings from gpg-home to decrypt gpg encrypted conf_file') + parser.add_argument('--gpg-home', default=os.path.expanduser('~/.gnupg')) + parser.add_argument('--quiet', default=False, action='store_true', + help='disable verbose logging') + args = parser.parse_args() + + global quiet + quiet = args.quiet + + assert os.path.exists(args.conf_file) + assert not args.gpg_decrypt or os.path.isdir(args.gpg_home) + + config_contents = '' + with io.open(args.conf_file, 'rb') as fp: + config_contents = fp.read() + + if args.conf_file.endswith('.gpg') and not args.gpg_decrypt: + err('conf file looks like gpg encrypted. Did you forget the --gpg-decrypt?') + + if args.gpg_decrypt: + if not have_gnupg: + err('Need gnupg package for reading gnupg encrypted files. Consider doing `pip install python-gnupg` (or similar)') + gpg = gnupg.GPG(gnupghome=args.gpg_home) + decrypted_contents = gpg.decrypt(config_contents) + + if not decrypted_contents.ok: + print('[ERROR] failed to decrypt config file:', file=sys.stderr) + print('[ERROR] status: {}'.format(decrypted_contents.status), file=sys.stderr) + print('[ERROR] error: {}'.format(decrypted_contents.stderr), file=sys.stderr) + sys.exit(1) + + config_contents = decrypted_contents.data + + conf = load_conf(config_contents) s = requests.Session() @@ -638,10 +689,10 @@ def main(): log('prelogin-cookie: {0}'.format(prelogin_cookie)) if (not userauthcookie or userauthcookie == 'empty') and prelogin_cookie != 'empty': - cookie_type = "gateway:prelogin-cookie" + cookie_type = 'gateway:prelogin-cookie' cookie = prelogin_cookie else: - cookie_type = "portal:portal-userauthcookie" + cookie_type = 'portal:portal-userauthcookie' cookie = userauthcookie username = saml_username From 7d478c0e34a002fc494c65615a708193a729f12f Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 16 Apr 2020 09:48:19 +0200 Subject: [PATCH 25/29] Implement '--show-list-of-gateways' option to display the list of gateways fetched from the portal - useful to see what values the 'gateway' option can take --- gp-okta.py | 53 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 8e50758..85e0557 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -127,7 +127,6 @@ def parse_form(html, current_url = None): data[k] = v return url, data - def load_conf(cf): log('load conf') conf = {} @@ -537,26 +536,26 @@ def okta_redirect(conf, s, session_token, redirect_url): dbg(conf.get('debug'), 'saml prop', [saml_auth_status, saml_slo]) return saml_username, prelogin_cookie -def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): +def paloalto_getconfig(conf, s, username = None, prelogin_cookie = None): log('getconfig request [vpn_url]') url = '{0}/global-protect/getconfig.esp'.format(conf.get('vpn_url')) data = { - #'jnlpReady': 'jnlpReady', - #'ok': 'Login', - #'direct': 'yes', + #'jnlpReady': 'jnlpReady', + #'ok': 'Login', + #'direct': 'yes', 'clientVer': '4100', - #'prot': 'https:', + #'prot': 'https:', 'clientos': 'Windows', 'os-version': 'Microsoft Windows 10 Pro, 64-bit', - #'server': '', + #'server': '', 'computer': 'DESKTOP', - #'preferred-ip': '', + #'preferred-ip': '', 'inputStr': '', - 'user': saml_username, - 'passwd': '', + 'user': username or conf['username'], + 'passwd': '' if prelogin_cookie else conf['password'], 'clientgpversion': '4.1.0.98', # 'host-id': '00:11:22:33:44:55' - 'prelogin-cookie': prelogin_cookie, + 'prelogin-cookie': prelogin_cookie or '', 'ipv6-support': 'yes' } _h, c = send_req(conf, s, 'getconfig', url, data, @@ -568,14 +567,18 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie): portal_userauthcookie = xtmp.text if len(portal_userauthcookie) == 0: err('empty portal_userauthcookie') - gateway = x.find('.//gateways//entry').get('name') # FIXME: this just grabs the very first, probably not what you want + gateways = set() + xtmp = x.find('.//gateways//external//list') + if xtmp is not None: + for entry in xtmp: + gateways.add(entry.get('name')) xtmp = x.find('.//root-ca') if xtmp is not None: for entry in xtmp: cert = entry.find('.//cert').text conf['openconnect_certs'].write(to_b(cert)) conf['openconnect_certs'].flush() - return portal_userauthcookie, gateway + return portal_userauthcookie, gateways # Combined first half of okta_saml with second half of okta_redirect def okta_saml_2(conf, s, gateway, saml_xml): @@ -612,6 +615,8 @@ def main(): parser.add_argument('--gpg-decrypt', action='store_true', help='use gpg and settings from gpg-home to decrypt gpg encrypted conf_file') parser.add_argument('--gpg-home', default=os.path.expanduser('~/.gnupg')) + parser.add_argument('--show-list-of-gateways', default=False, action='store_true', + help='get list of gateways from portal') parser.add_argument('--quiet', default=False, action='store_true', help='disable verbose logging') args = parser.parse_args() @@ -646,10 +651,18 @@ def main(): conf = load_conf(config_contents) s = requests.Session() + s.headers['User-Agent'] = 'PAN GlobalProtect' if conf.get('client_cert'): s.cert = conf.get('client_cert') + if args.show_list_of_gateways: + userauthcookie, gateways = paloalto_getconfig(conf, s) + print("Gateways:") + for gateway in sorted(gateways): + print(" ", gateway) + sys.exit(0) + another_dance = conf.get('another_dance', '').lower() in ['1', 'true'] if conf.get('gateway'): @@ -663,7 +676,6 @@ def main(): userauthcookie = None - s.headers['User-Agent'] = 'PAN GlobalProtect' if another_dance or not gateway: saml_xml = paloalto_prelogin(conf, s) else: @@ -672,21 +684,22 @@ def main(): token = okta_auth(conf, s) log('sessionToken: {0}'.format(token)) saml_username, prelogin_cookie = okta_redirect(conf, s, token, redirect_url) - if not gateway: - userauthcookie, gateway = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) - gateway = conf.get('gateway', gateway).strip() + if another_dance or not gateway: + userauthcookie, gateways = paloalto_getconfig(conf, s, saml_username, prelogin_cookie) + if not gateway and len(gateways): + gateway = gateways.pop() # this just grabs an arbitrary gateway log('portal-userauthcookie: {0}'.format(userauthcookie)) log('gateway: {0}'.format(gateway)) log('saml-username: {0}'.format(saml_username)) + log('prelogin-cookie: {0}'.format(prelogin_cookie)) if another_dance: # 1st step: dance with the portal, 2nd step: dance with the gateway saml_xml = paloalto_prelogin(conf, s, gateway) saml_username, prelogin_cookie = okta_saml_2(conf, s, gateway, saml_xml) - - log('saml-username: {0}'.format(saml_username)) - log('prelogin-cookie: {0}'.format(prelogin_cookie)) + log('saml-username (2): {0}'.format(saml_username)) + log('prelogin-cookie (2): {0}'.format(prelogin_cookie)) if (not userauthcookie or userauthcookie == 'empty') and prelogin_cookie != 'empty': cookie_type = 'gateway:prelogin-cookie' From a4db9833a10e1c0876188906c67cb28f3e884ad0 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Thu, 16 Apr 2020 16:04:25 +0200 Subject: [PATCH 26/29] remove strange hex chars in docstring (probably an artifact from copy&paste) --- gp-okta.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gp-okta.py b/gp-okta.py index 85e0557..fd449f7 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -607,8 +607,8 @@ def okta_saml_2(conf, s, gateway, saml_xml): def main(): parser = argparse.ArgumentParser(description=""" - This is an OpenConnect wrapper script that automates connecting to a - PaloAlto Networks GlobalProtect VPN using Okta 2FA.""") + This is an OpenConnect wrapper script that automates connecting to a + PaloAlto Networks GlobalProtect VPN using Okta 2FA.""") parser.add_argument('conf_file', help='e.g. ~/.config/gp-okta.conf') From 0aa34dc5479214679398babf06da04b4bcca6319 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Fri, 8 May 2020 08:56:36 +0200 Subject: [PATCH 27/29] fix 'gateway used before assignement' error (code review by @cardonator) --- gp-okta.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gp-okta.py b/gp-okta.py index fd449f7..e96d366 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -667,6 +667,8 @@ def main(): if conf.get('gateway'): gateway = conf.get('gateway').strip() + else: + gateway = None if gateway and not another_dance: vpn_url = 'https://{0}'.format(gateway) From d9c3b2198c9a3951a72d7d752fdf464b843d6815 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Fri, 8 May 2020 09:17:18 +0200 Subject: [PATCH 28/29] merge PR https://github.com/arthepsy/pan-globalprotect-okta/pull/14 from @sirmax --- gp-okta.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index e96d366..35cfff0 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -7,6 +7,7 @@ Copyright (C) 2018 Nick Lanham (nick@afternight.org) Copyright (C) 2019 Aaron Lindsay (aclindsa@gmail.com) Copyright (C) 2019 Taylor Dean (taylor@makeshift.dev) + Copyright (C) 2020 Max Lanin (mlanin@evolutiongaming.com) Copyright (C) 2019-2020 Tino Lange (coldcoff@yahoo.com) Permission is hereby granted, free of charge, to any person obtaining a copy @@ -28,7 +29,7 @@ THE SOFTWARE. """ from __future__ import print_function, unicode_literals -import io, os, sys, re, json, base64, getpass, subprocess, shlex, signal, tempfile, traceback +import io, os, sys, time, re, json, base64, getpass, subprocess, shlex, signal, tempfile, traceback from lxml import etree import requests @@ -400,6 +401,8 @@ def okta_mfa(conf, s, j): r = okta_mfa_totp(conf, s, f, state_token) elif ftype == 'sms': r = okta_mfa_sms(conf, s, f, state_token) + elif ftype == 'push': + r = okta_mfa_push(conf, s, f, state_token) elif ftype == 'webauthn': r = okta_mfa_webauthn(conf, s, f, state_token) else: @@ -450,6 +453,26 @@ def okta_mfa_sms(conf, s, factor, state_token): expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) return j +def okta_mfa_push(conf, s, factor, state_token): + provider = factor.get('provider', '') + data = { + 'factorId': factor.get('id'), + 'stateToken': state_token, + } + log('mfa {0} push request [okta_url]'.format(provider)) + status = 'MFA_CHALLENGE' + counter = 0 + while status == 'MFA_CHALLENGE': + _h, j = send_req(conf, s, 'push mfa ({0})'.format(counter), + factor.get('url'), data, json=True, + expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert')) + status = j.get('status', '').strip() + dbg(conf.get('debug'), 'status', status) + if status == 'MFA_CHALLENGE': + time.sleep(3.33) + counter += 1 + return j + def okta_mfa_webauthn(conf, s, factor, state_token): if not have_fido: err('Need fido2 package(s) for webauthn. Consider doing `pip install fido2` (or similar)') From 7fbaeee6041051ca7f108bf6356b7ce887a7eab5 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Fri, 8 May 2020 09:35:06 +0200 Subject: [PATCH 29/29] support 'push' also in `mfa_priority` --- gp-okta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gp-okta.py b/gp-okta.py index 35cfff0..b83cb69 100755 --- a/gp-okta.py +++ b/gp-okta.py @@ -188,7 +188,7 @@ def load_conf(cf): def mfa_priority(conf, ftype, fprovider): if ftype == 'token:software:totp' or (ftype, fprovider) == ('token', 'symantec'): ftype = 'totp' - if ftype not in ['totp', 'sms', 'webauthn']: + if ftype not in ['totp', 'sms', 'push', 'webauthn']: return 0 mfa_order = conf.get('mfa_order', '') if ftype in mfa_order: