From 412560eff7184cc8ab30a46c68f7bbae4d152f97 Mon Sep 17 00:00:00 2001 From: Tino Lange Date: Tue, 11 Jun 2019 14:31:23 +0200 Subject: [PATCH] - 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'