Add some good ideas from @limitz404

This commit is contained in:
Tino Lange
2020-04-15 12:27:09 +02:00
parent 748749e78f
commit 3946d55e59
+81 -30
View File
@@ -6,7 +6,8 @@
Copyright (C) 2018 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2018 Andris Raugulis (moo@arthepsy.eu)
Copyright (C) 2018 Nick Lanham (nick@afternight.org) Copyright (C) 2018 Nick Lanham (nick@afternight.org)
Copyright (C) 2019 Aaron Lindsay (aclindsa@gmail.com) 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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 from lxml import etree
import requests import requests
import argparse
if sys.version_info >= (3,): if sys.version_info >= (3,):
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
@@ -60,24 +62,35 @@ try:
except ImportError: except ImportError:
pass 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_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') to_u = lambda v: v if isinstance(v, text_type) else v.decode('utf-8')
quiet = False
def log(s): def log(s):
if not quiet:
print('[INFO] {0}'.format(s)) print('[INFO] {0}'.format(s))
def dbg(d, h, *xs): def dbg(d, h, *xs):
if quiet:
return
if not d: if not d:
return return
print('# {0}:'.format(h)) print('[DEBUG] {0}:'.format(h))
for x in xs: for x in xs:
print(x) print('[DEBUG] {0}'.format(x))
print('---') print('[DEBUG] ---')
def err(s): def err(s):
print('err: {0}'.format(s), file=sys.stderr) print('[ERROR] {0}'.format(s), file=sys.stderr)
sys.exit(1) sys.exit(1)
def parse_xml(xml): def parse_xml(xml):
@@ -85,21 +98,21 @@ def parse_xml(xml):
xml = bytes(bytearray(xml, encoding='utf-8')) xml = bytes(bytearray(xml, encoding='utf-8'))
parser = etree.XMLParser(ns_clean=True, recover=True) parser = etree.XMLParser(ns_clean=True, recover=True)
return etree.fromstring(xml, parser) return etree.fromstring(xml, parser)
except: except Exception as e:
err('failed to parse xml') err('failed to parse xml: ' + e)
def parse_html(html): def parse_html(html):
try: try:
parser = etree.HTMLParser() parser = etree.HTMLParser()
return etree.fromstring(html, parser) return etree.fromstring(html, parser)
except: except Exception as e:
err('failed to parse html') err('failed to parse html: ' + e)
def parse_rjson(r): def parse_rjson(r):
try: try:
return r.json() return r.json()
except: except Exception as e:
err('failed to parse json') err('failed to parse json: ' + e)
def parse_form(html, current_url = None): def parse_form(html, current_url = None):
xform = html.find('.//form') xform = html.find('.//form')
@@ -119,9 +132,10 @@ def load_conf(cf):
log('load conf') log('load conf')
conf = {} conf = {}
keys = ['vpn_url', 'username', 'password', 'okta_url'] keys = ['vpn_url', 'username', 'password', 'okta_url']
if isinstance(cf, binary_type):
cf = cf.decode('utf-8')
line_nr = 0 line_nr = 0
with io.open(cf, 'r', encoding='utf-8') as fp: for rline in cf.split('\n'):
for rline in fp:
line_nr += 1 line_nr += 1
line = rline.strip() line = rline.strip()
mx = re.match(r'^\s*([^=\s]+)\s*=\s*(.*?)\s*(?:#\s+.*)?\s*$', line) mx = re.match(r'^\s*([^=\s]+)\s*=\s*(.*?)\s*(?:#\s+.*)?\s*$', line)
@@ -262,7 +276,7 @@ def paloalto_prelogin(conf, s, gateway=None):
url = '{0}/global-protect/prelogin.esp'.format(conf.get('vpn_url')) 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: if conf.get('openconnect_certs') and os.path.getsize(conf.get('openconnect_certs').name) > 0:
verify = conf.get('openconnect_certs').name 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) x = parse_xml(c)
saml_req = x.find('.//saml-request') saml_req = x.find('.//saml-request')
if saml_req is None: if saml_req is None:
@@ -278,8 +292,8 @@ def paloalto_prelogin(conf, s, gateway=None):
err('empty saml request') err('empty saml request')
try: try:
saml_raw = base64.b64decode(saml_req.text) saml_raw = base64.b64decode(saml_req.text)
except: except Exception as e:
err('failed to decode saml request') err('failed to decode saml request: ' + e)
dbg(conf.get('debug'), 'prelogin.decoded', saml_raw) dbg(conf.get('debug'), 'prelogin.decoded', saml_raw)
saml_xml = parse_html(saml_raw) saml_xml = parse_html(saml_raw)
return saml_xml return saml_xml
@@ -287,7 +301,7 @@ def paloalto_prelogin(conf, s, gateway=None):
def okta_saml(conf, s, saml_xml): def okta_saml(conf, s, saml_xml):
log('okta saml request [okta_url]') log('okta saml request [okta_url]')
url, data = parse_form(saml_xml) 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
redirect_url = get_redirect_url(conf, c, url) redirect_url = get_redirect_url(conf, c, url)
if redirect_url is None: if redirect_url is None:
@@ -307,7 +321,7 @@ def okta_auth(conf, s, stateToken = None):
} if stateToken is None else { } if stateToken is None else {
'stateToken': stateToken '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')) verify=conf.get('okta_url_cert'))
while True: while True:
@@ -331,7 +345,7 @@ def okta_transaction_state(conf, s, j):
if len(state_token) == 0: if len(state_token) == 0:
err('empty state token') err('empty state token')
data = {'stateToken': 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
return False, j return False, j
# status: password_expired # status: password_expired
@@ -415,7 +429,7 @@ def okta_mfa_totp(conf, s, factor, state_token):
'passCode': code 'passCode': code
} }
log('mfa {0} totp request: {1} [okta_url]'.format(provider, 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
return j return j
@@ -426,14 +440,14 @@ def okta_mfa_sms(conf, s, factor, state_token):
'stateToken': state_token 'stateToken': state_token
} }
log('mfa {0} sms request [okta_url]'.format(provider)) 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
code = input('{0} SMS verification code: '.format(provider)).strip() code = input('{0} SMS verification code: '.format(provider)).strip()
if len(code) == 0: if len(code) == 0:
return None return None
data['passCode'] = code data['passCode'] = code
log('mfa {0} sms request [okta_url]'.format(provider)) 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
return j return j
@@ -449,7 +463,7 @@ def okta_mfa_webauthn(conf, s, factor, state_token):
data = { data = {
'stateToken': state_token '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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
factor = j['_embedded']['factor'] factor = j['_embedded']['factor']
profile = factor['profile'] profile = factor['profile']
@@ -479,7 +493,7 @@ def okta_mfa_webauthn(conf, s, factor, state_token):
'authenticatorData': to_u(base64.b64encode(assertion.auth_data)) 'authenticatorData': to_u(base64.b64encode(assertion.auth_data))
} }
log('mfa {0} signature request [okta_url]'.format(provider)) 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')) expected_url=conf.get('okta_url'), verify=conf.get('okta_url_cert'))
return j return j
@@ -545,7 +559,7 @@ def paloalto_getconfig(conf, s, saml_username, prelogin_cookie):
'prelogin-cookie': prelogin_cookie, 'prelogin-cookie': prelogin_cookie,
'ipv6-support': 'yes' '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')) verify=conf.get('vpn_url_cert'))
x = parse_xml(c) x = parse_xml(c)
xtmp = x.find('.//portal-userauthcookie') xtmp = x.find('.//portal-userauthcookie')
@@ -588,11 +602,48 @@ def okta_saml_2(conf, s, gateway, saml_xml):
return saml_username, prelogin_cookie return saml_username, prelogin_cookie
def main(): def main():
if len(sys.argv) < 2:
print('usage: {0} <conf>'.format(sys.argv[0])) 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) sys.exit(1)
conf = load_conf(sys.argv[1]) config_contents = decrypted_contents.data
conf = load_conf(config_contents)
s = requests.Session() s = requests.Session()
@@ -638,10 +689,10 @@ def main():
log('prelogin-cookie: {0}'.format(prelogin_cookie)) log('prelogin-cookie: {0}'.format(prelogin_cookie))
if (not userauthcookie or userauthcookie == 'empty') and prelogin_cookie != 'empty': if (not userauthcookie or userauthcookie == 'empty') and prelogin_cookie != 'empty':
cookie_type = "gateway:prelogin-cookie" cookie_type = 'gateway:prelogin-cookie'
cookie = prelogin_cookie cookie = prelogin_cookie
else: else:
cookie_type = "portal:portal-userauthcookie" cookie_type = 'portal:portal-userauthcookie'
cookie = userauthcookie cookie = userauthcookie
username = saml_username username = saml_username