]> code.communitydata.science - nu-vpn-proxy.git/blob - gp-saml-gui.py
update README-CDSC file to make the new openconnect issue clear
[nu-vpn-proxy.git] / gp-saml-gui.py
1 #!/usr/bin/env python3
2
3 try:
4     import gi
5     gi.require_version('Gtk', '3.0')
6     gi.require_version('WebKit2', '4.0')
7     from gi.repository import Gtk, WebKit2, GLib
8 except ImportError:
9     try:
10         import pgi as gi
11         gi.require_version('Gtk', '3.0')
12         gi.require_version('WebKit2', '4.0')
13         from pgi.repository import Gtk, WebKit2, GLib
14     except ImportError:
15         gi = None
16 if gi is None:
17     raise ImportError("Either gi (PyGObject) or pgi module is required.")
18
19 import argparse
20 import urllib3
21 import requests
22 import xml.etree.ElementTree as ET
23 import ssl
24 import tempfile
25
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
33
34
35 class CommentHtmlParser(HTMLParser):
36     def __init__(self):
37         super().__init__()
38         self.comments = []
39
40     def handle_comment(self, data: str) -> None:
41         self.comments.append(data)
42
43
44 COOKIE_FIELDS = ('prelogin-cookie', 'portal-userauthcookie')
45
46
47 class SAMLLoginView:
48     def __init__(self, uri, html=None, verbose=False, cookies=None, verify=True, user_agent=None):
49         Gtk.init(None)
50         window = Gtk.Window()
51
52         # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
53
54         self.closed = False
55         self.success = False
56         self.saml_result = {}
57         self.verbose = verbose
58
59         self.ctx = WebKit2.WebContext.get_default()
60         if not verify:
61             self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
62         self.cookies = self.ctx.get_cookie_manager()
63         if cookies:
64             self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS)
65             self.cookies.set_persistent_storage(cookies, WebKit2.CookiePersistentStorage.TEXT)
66         self.wview = WebKit2.WebView()
67
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)
73
74         window.resize(500, 500)
75         window.add(self.wview)
76         window.show_all()
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)
81
82         if html:
83             self.wview.load_html(html, uri)
84         else:
85             self.wview.load_uri(uri)
86
87     def close(self, window, event):
88         self.closed = True
89         Gtk.main_quit()
90
91     def log_resources(self, webview, resource, request):
92         if self.verbose > 1:
93             print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
94         if self.verbose > 2:
95             resource.connect('finished', self.log_resource_details, request)
96
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
102         if h:
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)
107
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)
112         if show_headers:
113             for h,v in show_headers.items():
114                 print('%s: %s' % (h, v), file=stderr)
115             print(file=stderr)
116         if charset or content_type.startswith('text/'):
117             print(data.decode(charset or 'utf-8'), file=stderr)
118
119     def on_load_changed(self, webview, event):
120         if event != WebKit2.LoadEvent.FINISHED:
121             return
122
123         mr = webview.get_main_resource()
124         uri = mr.get_uri()
125         rs = mr.get_response()
126         h = rs.get_http_headers() if rs else None
127         ct = h.get_content_type()
128
129         if self.verbose:
130             print('[PAGE   ] Finished loading page %s' % uri, file=stderr)
131
132         # convert to normal dict
133         d = {}
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}
137
138         if fd:
139             if self.verbose:
140                 print("[SAML   ] Got SAML result headers: %r" % fd, file=stderr)
141                 if self.verbose > 1:
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)
145             self.check_done()
146
147         if not self.success:
148             if self.verbose > 1:
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)
152
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")
156
157         html_parser = CommentHtmlParser()
158         html_parser.feed(content)
159
160         fd = {}
161         for comment in html_parser.comments:
162             if self.verbose > 1:
163                 print("[SAML   ] Found comment in response body: '%s'" % comment, file=stderr)
164             try:
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
169                 for elem in xmlroot:
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
174
175         if self.verbose > 1:
176             print("[SAML   ] Finished parsing response body for %s" % resource.get_uri(), file=stderr)
177         if fd:
178             if self.verbose:
179                 print("[SAML   ] Got SAML result tags: %s" % fd, file=stderr)
180             self.saml_result.update(fd, server=urlparse(resource.get_uri()).netloc)
181
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)
185
186     def check_done(self):
187         d = self.saml_result
188         if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
189             if self.verbose:
190                 print("[SAML   ] Got all required SAML headers, done.", file=stderr)
191             self.success = True
192             Gtk.main_quit()
193             return True
194
195
196 class TLSAdapter(requests.adapters.HTTPAdapter):
197     '''Adapt to older TLS stacks that would raise errors otherwise.
198
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.
204
205     See Also
206     --------
207     https://github.com/psf/requests/issues/4775#issuecomment-478198879
208
209     Notes
210     -----
211     Python is missing an ssl.OP_LEGACY_SERVER_CONNECT constant.
212     We have extracted the relevant value from <openssl/ssl.h>.
213
214     '''
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,
221                 maxsize=maxsize,
222                 block=block,
223                 ssl_context=ssl_context)
224
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')
229
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)
264
265     args.ocos = clientos2ocos[args.clientos]
266     args.extra = dict(x.split('=', 1) for x in args.extra)
267
268     if args.cookies:
269         args.cookies = path.expanduser(args.cookies)
270
271     if args.cert and args.key:
272         args.cert, args.key = (args.cert, args.key), None
273     elif args.cert:
274         args.cert = (args.cert, None)
275     elif args.key:
276         p.error('--key specified without --cert')
277     else:
278         args.cert = None
279
280     return p, args
281
282 def main(args = None):
283     p, args = parse_args(args)
284
285     s = requests.Session()
286     if args.insecure:
287         s.mount('https://', TLSAdapter())
288     s.headers['User-Agent'] = 'PAN GlobalProtect' if args.user_agent is None else args.user_agent
289     s.cert = args.cert
290
291     if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
292     if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
293
294     # query prelogin.esp and parse SAML bits
295     if args.uri:
296         sam, uri, html = 'URI', args.server, None
297     else:
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}
300         if args.verbose:
301             print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
302         try:
303             res = s.post(endpoint, verify=args.verify, data=data)
304         except Exception as ex:
305             rootex = ex
306             while True:
307                 if isinstance(rootex, ssl.SSLError):
308                     break
309                 elif not rootex.__cause__ and not rootex.__context__:
310                     break
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)
316             else:
317                 raise
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'))
327             else:
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"
333                     "Things to try:\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)))
336         sam = sam.text
337         sr = a2b_base64(sr.text).decode()
338         if sam == 'POST':
339             html, uri = sr, None
340         elif sam == 'REDIRECT':
341             uri, html = sr, None
342         else:
343             p.error("Unknown SAML method (%s)" % sam)
344
345     # launch external browser for debugging
346     if args.external:
347         print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
348         import webbrowser
349         if html:
350             uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
351         webbrowser.open(uri)
352         raise SystemExit
353
354     # spawn WebKit view to do SAML interactive login
355     if args.verbose:
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)
358     Gtk.main()
359     if slv.closed:
360         print("Login window closed by user.", file=stderr)
361         p.exit(1)
362     if not slv.success:
363         p.error('''Login window closed without producing SAML cookies.''')
364
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)
368
369     for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
370         cv = slv.saml_result.get(cn)
371         if cv:
372             break
373     else:
374         cn = ifh = None
375         p.error("Didn't get an expected cookie. Something went wrong.")
376
377     urlpath = args.interface + ":" + cn
378     openconnect_args = [
379         "--protocol=gp",
380         "--user="+un,
381         "--os="+args.ocos,
382         "--usergroup="+urlpath,
383         "--passwd-on-stdin",
384         server
385     ] + args.openconnect_extra
386
387     if args.insecure:
388         openconnect_args.insert(1, "--allow-insecure-crypto")
389     if args.user_agent:
390         openconnect_args.insert(1, "--useragent="+args.user_agent)
391     if args.cert:
392         cert, key = args.cert
393         if key:
394             openconnect_args.insert(1, "--sslkey="+key)
395         openconnect_args.insert(1, "--certificate="+cert)
396
397     openconnect_command = '''    echo {} |\n        sudo openconnect {}'''.format(
398         quote(cv), " ".join(map(quote, openconnect_args)))
399
400     if args.verbose:
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),
409                   file=stderr)
410         print('''\nSAML response converted to OpenConnect command line invocation:\n''', file=stderr)
411         print(openconnect_command, file=stderr)
412
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)
416
417     if args.exec:
418         print('''Launching OpenConnect with {}, equivalent to:\n{}'''.format(args.exec, openconnect_command), file=stderr)
419         with tempfile.TemporaryFile('w+') as tf:
420             tf.write(cv)
421             tf.flush()
422             tf.seek(0)
423             # redirect stdin from this file, before it is closed by the context manager
424             # (it will remain accessible via the open file descriptor)
425             dup2(tf.fileno(), 0)
426         if args.exec == 'pkexec':
427             cmd = ["pkexec", "--user", "root", "openconnect"] + openconnect_args
428         elif args.exec == 'sudo':
429             cmd = ["sudo", "openconnect"] + openconnect_args
430         execvp(cmd[0], cmd)
431
432     else:
433         varvals = {
434             'HOST': quote('https://%s/%s' % (server, urlpath)),
435             'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
436         }
437         print('\n'.join('%s=%s' % pair for pair in varvals.items()))
438
439 if __name__ == "__main__":
440     main()

Community Data Science Collective || Want to submit a patch?