5     gi.require_version('Gtk', '3.0')
 
   6     gi.require_version('WebKit2', '4.0')
 
   7     from gi.repository import Gtk, WebKit2, GLib
 
  11         gi.require_version('Gtk', '3.0')
 
  12         gi.require_version('WebKit2', '4.0')
 
  13         from pgi.repository import Gtk, WebKit2, GLib
 
  17     raise ImportError("Either gi (PyGObject) or pgi module is required.")
 
  22 import xml.etree.ElementTree as ET
 
  26 from operator import setitem
 
  27 from os import path, dup2, execvp
 
  28 from shlex import quote
 
  29 from sys import stderr, platform
 
  30 from binascii import a2b_base64, b2a_base64
 
  31 from urllib.parse import urlparse, urlencode
 
  32 from html.parser import HTMLParser
 
  35 class CommentHtmlParser(HTMLParser):
 
  40     def handle_comment(self, data: str) -> None:
 
  41         self.comments.append(data)
 
  44 COOKIE_FIELDS = ('prelogin-cookie', 'portal-userauthcookie')
 
  48     def __init__(self, uri, html=None, verbose=False, cookies=None, verify=True, user_agent=None):
 
  52         # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
 
  57         self.verbose = verbose
 
  59         self.ctx = WebKit2.WebContext.get_default()
 
  61             self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
 
  62         self.cookies = self.ctx.get_cookie_manager()
 
  64             self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS)
 
  65             self.cookies.set_persistent_storage(cookies, WebKit2.CookiePersistentStorage.TEXT)
 
  66         self.wview = WebKit2.WebView()
 
  68         if user_agent is None:
 
  69             user_agent = 'PAN GlobalProtect'
 
  70         settings = self.wview.get_settings()
 
  71         settings.set_user_agent(user_agent)
 
  72         self.wview.set_settings(settings)
 
  74         window.resize(500, 500)
 
  75         window.add(self.wview)
 
  77         window.set_title("SAML Login")
 
  78         window.connect('delete-event', self.close)
 
  79         self.wview.connect('load-changed', self.on_load_changed)
 
  80         self.wview.connect('resource-load-started', self.log_resources)
 
  83             self.wview.load_html(html, uri)
 
  85             self.wview.load_uri(uri)
 
  87     def close(self, window, event):
 
  91     def log_resources(self, webview, resource, request):
 
  93             print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
 
  95             resource.connect('finished', self.log_resource_details, request)
 
  97     def log_resource_details(self, resource, request):
 
  98         m = request.get_http_method() or 'Request'
 
  99         uri = resource.get_uri()
 
 100         rs = resource.get_response()
 
 101         h = rs.get_http_headers() if rs else None
 
 103             ct, cl = h.get_content_type(), h.get_content_length()
 
 104             content_type, charset = ct[0], ct.params.get('charset')
 
 105             content_details = '%d bytes of %s%s for ' % (cl, content_type, ('; charset='+charset) if charset else '')
 
 106         print('[RECEIVE] %sresource %s %s' % (content_details if h else '', m, uri), file=stderr)
 
 108     def log_resource_text(self, resource, result, content_type, charset=None, show_headers=None):
 
 109         data = resource.get_data_finish(result)
 
 110         content_details = '%d bytes of %s%s for ' % (len(data), content_type, ('; charset='+charset) if charset else '')
 
 111         print('[DATA   ] %sresource %s' % (content_details, resource.get_uri()), file=stderr)
 
 113             for h,v in show_headers.items():
 
 114                 print('%s: %s' % (h, v), file=stderr)
 
 116         if charset or content_type.startswith('text/'):
 
 117             print(data.decode(charset or 'utf-8'), file=stderr)
 
 119     def on_load_changed(self, webview, event):
 
 120         if event != WebKit2.LoadEvent.FINISHED:
 
 123         mr = webview.get_main_resource()
 
 125         rs = mr.get_response()
 
 126         h = rs.get_http_headers() if rs else None
 
 127         ct = h.get_content_type()
 
 130             print('[PAGE   ] Finished loading page %s' % uri, file=stderr)
 
 132         # convert to normal dict
 
 134         h.foreach(lambda k, v: setitem(d, k.lower(), v))
 
 135         # filter to interesting headers
 
 136         fd = {name: v for name, v in d.items() if name.startswith('saml-') or name in COOKIE_FIELDS}
 
 140                 print("[SAML   ] Got SAML result headers: %r" % fd, file=stderr)
 
 142                     # display everything we found
 
 143                     mr.get_data(None, self.log_resource_text, ct[0], ct.params.get('charset'), d)
 
 144             self.saml_result.update(fd, server=urlparse(uri).netloc)
 
 149                 print("[SAML   ] No headers in response, searching body for xml comments", file=stderr)
 
 150             # asynchronous call to fetch body content, continue processing in callback:
 
 151             mr.get_data(None, self.response_callback, ct)
 
 153     def response_callback(self, resource, result, ct):
 
 154         data = resource.get_data_finish(result)
 
 155         content = data.decode(ct.params.get("charset") or "utf-8")
 
 157         html_parser = CommentHtmlParser()
 
 158         html_parser.feed(content)
 
 161         for comment in html_parser.comments:
 
 163                 print("[SAML   ] Found comment in response body: '%s'" % comment, file=stderr)
 
 165                 # xml parser requires valid xml with a single root tag, but our expected content
 
 166                 # is just a list of data tags, so we need to improvise
 
 167                 xmlroot = ET.fromstring("<fakexmlroot>%s</fakexmlroot>" % comment)
 
 168                 # search for any valid first level xml tags (inside our fake root) that could contain SAML data
 
 170                     if elem.tag.startswith("saml-") or elem.tag in COOKIE_FIELDS:
 
 171                         fd[elem.tag] = elem.text
 
 172             except ET.ParseError:
 
 173                 pass  # silently ignore any comments that don't contain valid xml
 
 176             print("[SAML   ] Finished parsing response body for %s" % resource.get_uri(), file=stderr)
 
 179                 print("[SAML   ] Got SAML result tags: %s" % fd, file=stderr)
 
 180             self.saml_result.update(fd, server=urlparse(resource.get_uri()).netloc)
 
 182         if not self.check_done():
 
 183             # Work around timing/race condition by retrying check_done after 1 second
 
 184             GLib.timeout_add(1000, self.check_done)
 
 186     def check_done(self):
 
 188         if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
 
 190                 print("[SAML   ] Got all required SAML headers, done.", file=stderr)
 
 196 class TLSAdapter(requests.adapters.HTTPAdapter):
 
 197     '''Adapt to older TLS stacks that would raise errors otherwise.
 
 199     We try to work around different issues:
 
 200     * Enable weak ciphers such as 3DES or RC4, that have been disabled by default
 
 201       in OpenSSL 3.0 or recent Linux distributions.
 
 202     * Enable weak Diffie-Hellman key exchange sizes.
 
 203     * Enable unsafe legacy renegotiation for servers without RFC 5746 support.
 
 207     https://github.com/psf/requests/issues/4775#issuecomment-478198879
 
 211     Python is missing an ssl.OP_LEGACY_SERVER_CONNECT constant.
 
 212     We have extracted the relevant value from <openssl/ssl.h>.
 
 215     def init_poolmanager(self, connections, maxsize, block=False):
 
 216         ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
 
 217         ssl_context.set_ciphers('DEFAULT:@SECLEVEL=1')
 
 218         ssl_context.options |= 1<<2  # OP_LEGACY_SERVER_CONNECT
 
 219         self.poolmanager = urllib3.PoolManager(
 
 220                 num_pools=connections,
 
 223                 ssl_context=ssl_context)
 
 225 def parse_args(args = None):
 
 226     pf2clientos = dict(linux='Linux', darwin='Mac', win32='Windows', cygwin='Windows')
 
 227     clientos2ocos = dict(Linux='linux-64', Mac='mac-intel', Windows='win')
 
 228     default_clientos = pf2clientos.get(platform, 'Windows')
 
 230     p = argparse.ArgumentParser()
 
 231     p.add_argument('server', help='GlobalProtect server (portal or gateway)')
 
 232     p.add_argument('--no-verify', dest='verify', action='store_false', default=True, help='Ignore invalid server certificate')
 
 233     x = p.add_mutually_exclusive_group()
 
 234     x.add_argument('-C', '--cookies', default='~/.gp-saml-gui-cookies',
 
 235                    help='Use and store cookies in this file (instead of default %(default)s)')
 
 236     x.add_argument('-K', '--no-cookies', dest='cookies', action='store_const', const=None,
 
 237                    help="Don't use or store cookies at all")
 
 238     x = p.add_mutually_exclusive_group()
 
 239     x.add_argument('-g','--gateway', dest='interface', action='store_const', const='gateway', default='portal',
 
 240                    help='SAML auth to gateway')
 
 241     x.add_argument('-p','--portal', dest='interface', action='store_const', const='portal',
 
 242                    help='SAML auth to portal (default)')
 
 243     g = p.add_argument_group('Client certificate')
 
 244     g.add_argument('-c','--cert', help='PEM file containing client certificate (and optionally private key)')
 
 245     g.add_argument('--key', help='PEM file containing client private key (if not included in same file as certificate)')
 
 246     g = p.add_argument_group('Debugging and advanced options')
 
 247     x = p.add_mutually_exclusive_group()
 
 248     x.add_argument('-v','--verbose', default=1, action='count', help='Increase verbosity of explanatory output to stderr')
 
 249     x.add_argument('-q','--quiet', dest='verbose', action='store_const', const=0, help='Reduce verbosity to a minimum')
 
 250     x = p.add_mutually_exclusive_group()
 
 251     x.add_argument('-x','--external', action='store_true', help='Launch external browser (for debugging)')
 
 252     x.add_argument('-P','--pkexec-openconnect', action='store_const', dest='exec', const='pkexec', help='Use PolicyKit to exec openconnect')
 
 253     x.add_argument('-S','--sudo-openconnect', action='store_const', dest='exec', const='sudo', help='Use sudo to exec openconnect')
 
 254     g.add_argument('-u','--uri', action='store_true', help='Treat server as the complete URI of the SAML entry point, rather than GlobalProtect server')
 
 255     g.add_argument('--clientos', choices=set(pf2clientos.values()), default=default_clientos, help="clientos value to send (default is %(default)s)")
 
 256     p.add_argument('-f','--field', dest='extra', action='append', default=[],
 
 257                    help='Extra form field(s) to pass to include in the login query string (e.g. "-f magic-cookie-value=deadbeef01234567")')
 
 258     p.add_argument('--allow-insecure-crypto', dest='insecure', action='store_true',
 
 259                    help='Allow use of insecure renegotiation or ancient 3DES and RC4 ciphers')
 
 260     p.add_argument('--user-agent', '--useragent', default='PAN GlobalProtect',
 
 261                    help='Use the provided string as the HTTP User-Agent header (default is %(default)r, as used by OpenConnect)')
 
 262     p.add_argument('openconnect_extra', nargs='*', help="Extra arguments to include in output OpenConnect command-line")
 
 263     args = p.parse_args(args)
 
 265     args.ocos = clientos2ocos[args.clientos]
 
 266     args.extra = dict(x.split('=', 1) for x in args.extra)
 
 269         args.cookies = path.expanduser(args.cookies)
 
 271     if args.cert and args.key:
 
 272         args.cert, args.key = (args.cert, args.key), None
 
 274         args.cert = (args.cert, None)
 
 276         p.error('--key specified without --cert')
 
 282 def main(args = None):
 
 283     p, args = parse_args(args)
 
 285     s = requests.Session()
 
 287         s.mount('https://', TLSAdapter())
 
 288     s.headers['User-Agent'] = 'PAN GlobalProtect' if args.user_agent is None else args.user_agent
 
 291     if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
 
 292     if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
 
 294     # query prelogin.esp and parse SAML bits
 
 296         sam, uri, html = 'URI', args.server, None
 
 298         endpoint = 'https://{}/{}'.format(args.server, if2prelogin[args.interface])
 
 299         data = {'tmp':'tmp', 'kerberos-support':'yes', 'ipv6-support':'yes', 'clientVer':4100, 'clientos':args.clientos, **args.extra}
 
 301             print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
 
 303             res = s.post(endpoint, verify=args.verify, data=data)
 
 304         except Exception as ex:
 
 307                 if isinstance(rootex, ssl.SSLError):
 
 309                 elif not rootex.__cause__ and not rootex.__context__:
 
 311                 rootex = rootex.__cause__ or rootex.__context__
 
 312             if isinstance(rootex, ssl.CertificateError):
 
 313                 p.error("SSL certificate error (try --no-verify to ignore): %s" % rootex)
 
 314             elif isinstance(rootex, ssl.SSLError):
 
 315                 p.error("SSL error (try --allow-insecure-crypto to ignore): %s" % rootex)
 
 318         xml = ET.fromstring(res.content)
 
 319         if xml.tag != 'prelogin-response':
 
 320             p.error("This does not appear to be a GlobalProtect prelogin response\nCheck in browser: {}?{}".format(endpoint, urlencode(data)))
 
 321         status = xml.find('status')
 
 322         if status != None and status.text != 'Success':
 
 323             msg = xml.find('msg')
 
 324             if msg != None and msg.text == 'GlobalProtect {} does not exist'.format(args.interface):
 
 325                 p.error("{} interface does not exist; specify {} instead".format(
 
 326                     args.interface.title(), '--portal' if args.interface=='gateway' else '--gateway'))
 
 328                 p.error("Error in {} prelogin response: {}".format(args.interface, msg.text))
 
 329         sam = xml.find('saml-auth-method')
 
 330         sr = xml.find('saml-request')
 
 331         if sam is None or sr is None:
 
 332             p.error("{} prelogin response does not contain SAML tags (<saml-auth-method> or <saml-request> missing)\n\n"
 
 334                     "1) Spoof an officially supported OS (e.g. --clientos=Windows or --clientos=Mac)\n"
 
 335                     "2) Check in browser: {}?{}".format(args.interface.title(), endpoint, urlencode(data)))
 
 337         sr = a2b_base64(sr.text).decode()
 
 340         elif sam == 'REDIRECT':
 
 343             p.error("Unknown SAML method (%s)" % sam)
 
 345     # launch external browser for debugging
 
 347         print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
 
 350             uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
 
 354     # spawn WebKit view to do SAML interactive login
 
 356         print("Got SAML %s, opening browser..." % sam, file=stderr)
 
 357     slv = SAMLLoginView(uri, html, verbose=args.verbose, cookies=args.cookies, verify=args.verify, user_agent=args.user_agent)
 
 360         print("Login window closed by user.", file=stderr)
 
 363         p.error('''Login window closed without producing SAML cookies.''')
 
 365     # extract response and convert to OpenConnect command-line
 
 366     un = slv.saml_result.get('saml-username')
 
 367     server = slv.saml_result.get('server', args.server)
 
 369     for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
 
 370         cv = slv.saml_result.get(cn)
 
 375         p.error("Didn't get an expected cookie. Something went wrong.")
 
 377     urlpath = args.interface + ":" + cn
 
 382         "--usergroup="+urlpath,
 
 385     ] + args.openconnect_extra
 
 388         openconnect_args.insert(1, "--allow-insecure-crypto")
 
 390         openconnect_args.insert(1, "--useragent="+args.user_agent)
 
 392         cert, key = args.cert
 
 394             openconnect_args.insert(1, "--sslkey="+key)
 
 395         openconnect_args.insert(1, "--certificate="+cert)
 
 397     openconnect_command = '''    echo {} |\n        sudo openconnect {}'''.format(
 
 398         quote(cv), " ".join(map(quote, openconnect_args)))
 
 401         # Warn about ambiguities
 
 402         if server != args.server and not args.uri:
 
 403             print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}. This probably '''
 
 404                   '''means you should specify {1} as the server for final connection, but we're not 100% '''
 
 405                   '''sure about this. You should probably try both.\n'''.format(args.server, server), file=stderr)
 
 406         if ifh != args.interface and not args.uri:
 
 407             print('''IMPORTANT: We started with SAML auth to the {} interface, but received a cookie '''
 
 408                   '''that's often associated with the {} interface. You should probably try both.\n'''.format(args.interface, ifh),
 
 410         print('''\nSAML response converted to OpenConnect command line invocation:\n''', file=stderr)
 
 411         print(openconnect_command, file=stderr)
 
 413         print('''\nSAML response converted to test-globalprotect-login.py invocation:\n''', file=stderr)
 
 414         print('''    test-globalprotect-login.py --user={} --clientos={} -p '' \\\n         https://{}/{} {}={}\n'''.format(
 
 415             quote(un), quote(args.clientos), quote(server), quote(if2auth[args.interface]), quote(cn), quote(cv)), file=stderr)
 
 418         print('''Launching OpenConnect with {}, equivalent to:\n{}'''.format(args.exec, openconnect_command), file=stderr)
 
 419         with tempfile.TemporaryFile('w+') as tf:
 
 423             # redirect stdin from this file, before it is closed by the context manager
 
 424             # (it will remain accessible via the open file descriptor)
 
 426         if args.exec == 'pkexec':
 
 427             cmd = ["pkexec", "--user", "root", "openconnect"] + openconnect_args
 
 428         elif args.exec == 'sudo':
 
 429             cmd = ["sudo", "openconnect"] + openconnect_args
 
 434             'HOST': quote('https://%s/%s' % (server, urlpath)),
 
 435             'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
 
 437         print('\n'.join('%s=%s' % pair for pair in varvals.items()))
 
 439 if __name__ == "__main__":