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__":