7 gi.require_version('Gtk', '3.0')
9 gi.require_version('WebKit2', '4.1')
10 except ValueError: # I wish this were ImportError
11 gi.require_version('WebKit2', '4.0')
12 warnings.warn("Using WebKit2Gtk 4.0 (obsolete); please upgrade to WebKit2Gtk 4.1")
13 from gi.repository import Gtk, WebKit2, GLib
17 gi.require_version('Gtk', '3.0')
18 gi.require_version('WebKit2', '4.0')
19 from pgi.repository import Gtk, WebKit2, GLib
20 warnings.warn("Using PGI and WebKit2Gtk 4.0 (both obsolete); please upgrade to PyGObject and WebKit2Gtk 4.1")
24 raise ImportError("Either gi (PyGObject) or pgi (obsolete) module is required.")
29 import xml.etree.ElementTree as ET
33 from operator import setitem
34 from os import path, dup2, execvp, environ
35 from shlex import quote
36 from sys import stderr, platform
37 from binascii import a2b_base64, b2a_base64
38 from urllib.parse import urlparse, urlencode, urlunsplit
39 from html.parser import HTMLParser
42 class CommentHtmlParser(HTMLParser):
47 def handle_comment(self, data: str) -> None:
48 self.comments.append(data)
51 COOKIE_FIELDS = ('prelogin-cookie', 'portal-userauthcookie')
55 def __init__(self, uri, html, args):
58 self.window = window = Gtk.Window()
60 # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
65 self.verbose = args.verbose
67 self.ctx = WebKit2.WebContext.get_default()
69 self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
70 self.cookies = self.ctx.get_cookie_manager()
72 self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS)
73 self.cookies.set_persistent_storage(args.cookies, WebKit2.CookiePersistentStorage.TEXT)
74 self.wview = WebKit2.WebView()
77 data_manager = self.ctx.get_website_data_manager()
78 data_manager.set_network_proxy_settings(WebKit2.NetworkProxyMode.NO_PROXY, None)
80 if args.user_agent is None:
81 args.user_agent = 'PAN GlobalProtect'
82 settings = self.wview.get_settings()
83 settings.set_user_agent(args.user_agent)
84 self.wview.set_settings(settings)
86 window.resize(500, 500)
87 window.add(self.wview)
89 window.set_title("SAML Login")
90 window.connect('delete-event', self.close)
91 self.wview.connect('load-changed', self.on_load_changed)
92 self.wview.connect('resource-load-started', self.log_resources)
95 self.wview.load_html(html, uri)
97 self.wview.load_uri(uri)
99 def close(self, window, event):
103 def log_resources(self, webview, resource, request):
105 print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
107 resource.connect('finished', self.log_resource_details, request)
109 def log_resource_details(self, resource, request):
110 m = request.get_http_method() or 'Request'
111 uri = resource.get_uri()
112 rs = resource.get_response()
113 h = rs.get_http_headers() if rs else None
115 ct, cl = h.get_content_type(), h.get_content_length()
117 charset = ct.params.get('charset') if ct.params else None
118 content_details = '%d bytes of %s%s for ' % (cl, content_type, ('; charset='+charset) if charset else '')
119 print('[RECEIVE] %sresource %s %s' % (content_details if h else '', m, uri), file=stderr)
121 def log_resource_text(self, resource, result, content_type, charset=None, show_headers=None):
122 data = resource.get_data_finish(result)
123 content_details = '%d bytes of %s%s for ' % (len(data), content_type, ('; charset='+charset) if charset else '')
124 print('[DATA ] %sresource %s' % (content_details, resource.get_uri()), file=stderr)
126 for h,v in show_headers.items():
127 print('%s: %s' % (h, v), file=stderr)
129 if charset or content_type.startswith('text/'):
130 print(data.decode(charset or 'utf-8'), file=stderr)
132 def on_load_changed(self, webview, event):
133 if event != WebKit2.LoadEvent.FINISHED:
136 mr = webview.get_main_resource()
138 rs = mr.get_response()
139 h = rs.get_http_headers() if rs else None
140 ct = h.get_content_type() if h else None
143 print('[PAGE ] Finished loading page %s' % uri, file=stderr)
145 origin = '%s %s' % ('🔒' if urip.scheme == 'https' else '🔴', urip.netloc)
146 self.window.set_title("SAML Login (%s)" % origin)
148 # if no response or no headers (for e.g. about:blank), skip checking this
152 # convert to normal dict
154 h.foreach(lambda k, v: setitem(d, k.lower(), v))
155 # filter to interesting headers
156 fd = {name: v for name, v in d.items() if name.startswith('saml-') or name in COOKIE_FIELDS}
160 print("[SAML ] Got SAML result headers: %r" % fd, file=stderr)
162 # display everything we found
163 mr.get_data(None, self.log_resource_text, ct[0], ct.params.get('charset'), d)
164 self.saml_result.update(fd, server=urlparse(uri).netloc)
169 print("[SAML ] No headers in response, searching body for xml comments", file=stderr)
170 # asynchronous call to fetch body content, continue processing in callback:
171 mr.get_data(None, self.response_callback, ct)
173 def response_callback(self, resource, result, ct):
174 data = resource.get_data_finish(result)
175 content = data.decode(ct.params.get("charset") or "utf-8")
177 html_parser = CommentHtmlParser()
178 html_parser.feed(content)
181 for comment in html_parser.comments:
183 print("[SAML ] Found comment in response body: '%s'" % comment, file=stderr)
185 # xml parser requires valid xml with a single root tag, but our expected content
186 # is just a list of data tags, so we need to improvise
187 xmlroot = ET.fromstring("<fakexmlroot>%s</fakexmlroot>" % comment)
188 # search for any valid first level xml tags (inside our fake root) that could contain SAML data
190 if elem.tag.startswith("saml-") or elem.tag in COOKIE_FIELDS:
191 fd[elem.tag] = elem.text
192 except ET.ParseError:
193 pass # silently ignore any comments that don't contain valid xml
196 print("[SAML ] Finished parsing response body for %s" % resource.get_uri(), file=stderr)
199 print("[SAML ] Got SAML result tags: %s" % fd, file=stderr)
200 self.saml_result.update(fd, server=urlparse(resource.get_uri()).netloc)
202 if not self.check_done():
203 # Work around timing/race condition by retrying check_done after 1 second
204 GLib.timeout_add(1000, self.check_done)
206 def check_done(self):
208 if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
210 print("[SAML ] Got all required SAML headers, done.", file=stderr)
216 class TLSAdapter(requests.adapters.HTTPAdapter):
217 '''Adapt to older TLS stacks that would raise errors otherwise.
219 We try to work around different issues:
220 * Enable weak ciphers such as 3DES or RC4, that have been disabled by default
221 in OpenSSL 3.0 or recent Linux distributions.
222 * Enable weak Diffie-Hellman key exchange sizes.
223 * Enable unsafe legacy renegotiation for servers without RFC 5746 support.
227 https://github.com/psf/requests/issues/4775#issuecomment-478198879
231 Python is missing an ssl.OP_LEGACY_SERVER_CONNECT constant.
232 We have extracted the relevant value from <openssl/ssl.h>.
236 def __init__(self, verify=True):
240 def init_poolmanager(self, connections, maxsize, block=False):
241 ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
242 ssl_context.set_ciphers('DEFAULT:@SECLEVEL=1')
243 ssl_context.options |= 1<<2 # OP_LEGACY_SERVER_CONNECT
246 ssl_context.check_hostname = False
247 ssl_context.verify_mode = ssl.CERT_NONE
249 if hasattr(ssl_context, "keylog_filename"):
250 sslkeylogfile = environ.get("SSLKEYLOGFILE")
252 ssl_context.keylog_filename = sslkeylogfile
254 self.poolmanager = urllib3.PoolManager(
255 num_pools=connections,
258 ssl_context=ssl_context)
260 def parse_args(args = None):
261 pf2clientos = dict(linux='Linux', darwin='Mac', win32='Windows', cygwin='Windows')
262 clientos2ocos = dict(Linux='linux-64', Mac='mac-intel', Windows='win')
263 default_clientos = pf2clientos.get(platform, 'Windows')
265 p = argparse.ArgumentParser()
266 p.add_argument('server', help='GlobalProtect server (portal or gateway)')
267 p.add_argument('--no-verify', dest='verify', action='store_false', default=True, help='Ignore invalid server certificate')
268 x = p.add_mutually_exclusive_group()
269 x.add_argument('-C', '--cookies', default='~/.gp-saml-gui-cookies',
270 help='Use and store cookies in this file (instead of default %(default)s)')
271 x.add_argument('-K', '--no-cookies', dest='cookies', action='store_const', const=None,
272 help="Don't use or store cookies at all")
273 x = p.add_mutually_exclusive_group()
274 p.add_argument('-i', '--ignore-redirects', action='store_true', help='Use specified gateway hostname as server, ignoring redirects')
275 x.add_argument('-g','--gateway', dest='interface', action='store_const', const='gateway', default='portal',
276 help='SAML auth to gateway')
277 x.add_argument('-p','--portal', dest='interface', action='store_const', const='portal',
278 help='SAML auth to portal (default)')
279 g = p.add_argument_group('Client certificate')
280 g.add_argument('-c','--cert', help='PEM file containing client certificate (and optionally private key)')
281 g.add_argument('--key', help='PEM file containing client private key (if not included in same file as certificate)')
282 g = p.add_argument_group('Debugging and advanced options')
283 x = p.add_mutually_exclusive_group()
284 x.add_argument('-v','--verbose', default=1, action='count', help='Increase verbosity of explanatory output to stderr')
285 x.add_argument('-q','--quiet', dest='verbose', action='store_const', const=0, help='Reduce verbosity to a minimum')
286 x = p.add_mutually_exclusive_group()
287 x.add_argument('-x','--external', action='store_true', help='Launch external browser (for debugging)')
288 x.add_argument('-P','--pkexec-openconnect', action='store_const', dest='exec', const='pkexec', help='Use PolicyKit to exec openconnect')
289 x.add_argument('-S','--sudo-openconnect', action='store_const', dest='exec', const='sudo', help='Use sudo to exec openconnect')
290 x.add_argument('-E','--exec-openconnect', action='store_const', dest='exec', const='exec', help='Execute openconnect directly (advanced users)')
291 g.add_argument('-u','--uri', action='store_true', help='Treat server as the complete URI of the SAML entry point, rather than GlobalProtect server')
292 g.add_argument('--clientos', choices=set(pf2clientos.values()), default=default_clientos, help="clientos value to send (default is %(default)s)")
293 p.add_argument('-f','--field', dest='extra', action='append', default=[],
294 help='Extra form field(s) to pass to include in the login query string (e.g. "-f magic-cookie-value=deadbeef01234567")')
295 p.add_argument('--allow-insecure-crypto', dest='insecure', action='store_true',
296 help='Allow use of insecure renegotiation or ancient 3DES and RC4 ciphers')
297 p.add_argument('--user-agent', '--useragent', default='PAN GlobalProtect',
298 help='Use the provided string as the HTTP User-Agent header (default is %(default)r, as used by OpenConnect)')
299 p.add_argument('--no-proxy', action='store_true', help='Disable system proxy settings')
300 p.add_argument('openconnect_extra', nargs='*', help="Extra arguments to include in output OpenConnect command-line")
301 args = p.parse_args(args)
303 args.ocos = clientos2ocos[args.clientos]
304 args.extra = dict(x.split('=', 1) for x in args.extra)
307 args.cookies = path.expanduser(args.cookies)
309 if args.cert and args.key:
310 args.cert, args.key = (args.cert, args.key), None
312 args.cert = (args.cert, None)
314 p.error('--key specified without --cert')
320 def main(args = None):
321 p, args = parse_args(args)
323 s = requests.Session()
325 s.mount('https://', TLSAdapter(verify=args.verify))
326 s.headers['User-Agent'] = 'PAN GlobalProtect' if args.user_agent is None else args.user_agent
329 if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
330 if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
332 # query prelogin.esp and parse SAML bits
334 sam, uri, html = 'URI', args.server, None
336 endpoint = 'https://{}/{}'.format(args.server, if2prelogin[args.interface])
337 data = {'tmp':'tmp', 'kerberos-support':'yes', 'ipv6-support':'yes', 'clientVer':4100, 'clientos':args.clientos, **args.extra}
339 print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
341 res = s.post(endpoint, verify=args.verify, data=data)
342 except Exception as ex:
345 if isinstance(rootex, ssl.SSLError):
347 elif not rootex.__cause__ and not rootex.__context__:
349 rootex = rootex.__cause__ or rootex.__context__
350 if isinstance(rootex, ssl.CertificateError):
351 p.error("SSL certificate error (try --no-verify to ignore): %s" % rootex)
352 elif isinstance(rootex, ssl.SSLError):
353 p.error("SSL error (try --allow-insecure-crypto to ignore): %s" % rootex)
356 xml = ET.fromstring(res.content)
357 if xml.tag != 'prelogin-response':
358 p.error("This does not appear to be a GlobalProtect prelogin response\nCheck in browser: {}?{}".format(endpoint, urlencode(data)))
359 status = xml.find('status')
360 if status != None and status.text != 'Success':
361 msg = xml.find('msg')
362 if msg != None and msg.text == 'GlobalProtect {} does not exist'.format(args.interface):
363 p.error("{} interface does not exist; specify {} instead".format(
364 args.interface.title(), '--portal' if args.interface=='gateway' else '--gateway'))
366 p.error("Error in {} prelogin response: {}".format(args.interface, msg.text))
367 sam = xml.find('saml-auth-method')
368 sr = xml.find('saml-request')
369 if sam is None or sr is None:
370 p.error("{} prelogin response does not contain SAML tags (<saml-auth-method> or <saml-request> missing)\n\n"
372 "1) Spoof an officially supported OS (e.g. --clientos=Windows or --clientos=Mac)\n"
373 "2) Check in browser: {}?{}".format(args.interface.title(), endpoint, urlencode(data)))
375 sr = a2b_base64(sr.text).decode()
378 elif sam == 'REDIRECT':
381 p.error("Unknown SAML method (%s)" % sam)
383 # launch external browser for debugging
385 print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
388 uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
392 # spawn WebKit view to do SAML interactive login
394 print("Got SAML %s, opening browser..." % sam, file=stderr)
395 slv = SAMLLoginView(uri, html, args)
398 print("Login window closed by user.", file=stderr)
401 p.error('''Login window closed without producing SAML cookies.''')
403 # extract response and convert to OpenConnect command-line
404 un = slv.saml_result.get('saml-username')
405 if args.ignore_redirects:
408 server = slv.saml_result.get('server', args.server)
410 for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
411 cv = slv.saml_result.get(cn)
416 p.error("Didn't get an expected cookie. Something went wrong.")
418 urlpath = args.interface + ":" + cn
423 "--usergroup="+urlpath,
426 ] + args.openconnect_extra
429 openconnect_args.insert(1, "--allow-insecure-crypto")
431 openconnect_args.insert(1, "--useragent="+args.user_agent)
433 cert, key = args.cert
435 openconnect_args.insert(1, "--sslkey="+key)
436 openconnect_args.insert(1, "--certificate="+cert)
438 openconnect_args.insert(1, "--no-proxy")
440 openconnect_command = ''' echo {} |\n sudo openconnect {}'''.format(
441 quote(cv), " ".join(map(quote, openconnect_args)))
444 # Warn about ambiguities
445 if server != args.server and not args.uri:
446 if args.ignore_redirects:
447 print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}. This probably '''
448 '''means you should specify {1} as the server for final connection, but we're not 100% '''
449 '''sure about this. You should probably try both; if necessary, use the '''
450 '''--ignore-redirects option to specify desired behavior.\n'''.format(args.server, server), file=stderr)
452 print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}, however the '''
453 '''redirection was ignored because you specified --ignore-redirects.\n'''.format(args.server, server), file=stderr)
454 if ifh != args.interface and not args.uri:
455 print('''IMPORTANT: We started with SAML auth to the {} interface, but received a cookie '''
456 '''that's often associated with the {} interface. You should probably try both.\n'''.format(args.interface, ifh),
458 print('''\nSAML response converted to OpenConnect command line invocation:\n''', file=stderr)
459 print(openconnect_command, file=stderr)
461 print('''\nSAML response converted to test-globalprotect-login.py invocation:\n''', file=stderr)
462 print(''' test-globalprotect-login.py --user={} --clientos={} -p '' \\\n https://{}/{} {}={}\n'''.format(
463 quote(un), quote(args.clientos), quote(server), quote(if2auth[args.interface]), quote(cn), quote(cv)), file=stderr)
466 print('''Launching OpenConnect with {}, equivalent to:\n{}'''.format(args.exec, openconnect_command), file=stderr)
467 with tempfile.TemporaryFile('w+') as tf:
471 # redirect stdin from this file, before it is closed by the context manager
472 # (it will remain accessible via the open file descriptor)
474 cmd = ["openconnect"] + openconnect_args
475 if args.exec == 'pkexec':
476 cmd = ["pkexec", "--user", "root"] + cmd
477 elif args.exec == 'sudo':
483 'HOST': quote('https://%s/%s' % (server, urlpath)),
484 'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
486 print('\n'.join('%s=%s' % pair for pair in varvals.items()))
488 if __name__ == "__main__":