mirror of
https://github.com/os-mnemo/pan-globalprotect-okta
synced 2026-07-31 15:54:36 +02:00
Add some good ideas from @limitz404
This commit is contained in:
+95
-44
@@ -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):
|
||||||
print('[INFO] {0}'.format(s))
|
if not quiet:
|
||||||
|
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,21 +132,22 @@ 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)
|
if mx:
|
||||||
if mx:
|
k, v = mx.group(1).lower(), mx.group(2)
|
||||||
k, v = mx.group(1).lower(), mx.group(2)
|
if k.startswith('#'):
|
||||||
if k.startswith('#'):
|
continue
|
||||||
continue
|
for q in '"\'':
|
||||||
for q in '"\'':
|
if re.match(r'^{0}.*{0}$'.format(q), v):
|
||||||
if re.match(r'^{0}.*{0}$'.format(q), v):
|
v = v[1:-1]
|
||||||
v = v[1:-1]
|
conf[k] = v
|
||||||
conf[k] = v
|
conf['{0}.line'.format(k)] = line_nr
|
||||||
conf['{0}.line'.format(k)] = line_nr
|
|
||||||
for k, v in os.environ.items():
|
for k, v in os.environ.items():
|
||||||
k = k.lower()
|
k = k.lower()
|
||||||
if k.startswith('gp_'):
|
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'))
|
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]))
|
|
||||||
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()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user