]> code.communitydata.science - nu-vpn-proxy.git/blob - gp-saml-gui.py
Merge branch 'master' of ssh://gitea.communitydata.science:2200/collective/nu-vpn...
[nu-vpn-proxy.git] / gp-saml-gui.py
1 #!/usr/bin/env python3
2
3 import warnings
4 try:
5     import gi
6
7     gi.require_version('Gtk', '3.0')
8     try:
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
14 except ImportError:
15     try:
16         import pgi as gi
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")
21     except ImportError:
22         gi = None
23 if gi is None:
24     raise ImportError("Either gi (PyGObject) or pgi (obsolete) module is required.")
25
26 import argparse
27 import urllib3
28 import requests
29 import xml.etree.ElementTree as ET
30 import ssl
31 import tempfile
32
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
40
41
42 class CommentHtmlParser(HTMLParser):
43     def __init__(self):
44         super().__init__()
45         self.comments = []
46
47     def handle_comment(self, data: str) -> None:
48         self.comments.append(data)
49
50
51 COOKIE_FIELDS = ('prelogin-cookie', 'portal-userauthcookie')
52
53
54 class SAMLLoginView:
55     def __init__(self, uri, html, args):
56
57         Gtk.init(None)
58         self.window = window = Gtk.Window()
59
60         # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
61
62         self.closed = False
63         self.success = False
64         self.saml_result = {}
65         self.verbose = args.verbose
66
67         self.ctx = WebKit2.WebContext.get_default()
68         if not args.verify:
69             self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
70         self.cookies = self.ctx.get_cookie_manager()
71         if args.cookies:
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()
75
76         if args.no_proxy:
77             data_manager = self.ctx.get_website_data_manager()
78             data_manager.set_network_proxy_settings(WebKit2.NetworkProxyMode.NO_PROXY, None)
79
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)
85
86         window.resize(500, 500)
87         window.add(self.wview)
88         window.show_all()
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)
93
94         if html:
95             self.wview.load_html(html, uri)
96         else:
97             self.wview.load_uri(uri)
98
99     def close(self, window, event):
100         self.closed = True
101         Gtk.main_quit()
102
103     def log_resources(self, webview, resource, request):
104         if self.verbose > 1:
105             print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
106         if self.verbose > 2:
107             resource.connect('finished', self.log_resource_details, request)
108
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
114         if h:
115             ct, cl = h.get_content_type(), h.get_content_length()
116             content_type = ct[0]
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)
120
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)
125         if show_headers:
126             for h,v in show_headers.items():
127                 print('%s: %s' % (h, v), file=stderr)
128             print(file=stderr)
129         if charset or content_type.startswith('text/'):
130             print(data.decode(charset or 'utf-8'), file=stderr)
131
132     def on_load_changed(self, webview, event):
133         if event != WebKit2.LoadEvent.FINISHED:
134             return
135
136         mr = webview.get_main_resource()
137         uri = mr.get_uri()
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
141
142         if self.verbose:
143             print('[PAGE   ] Finished loading page %s' % uri, file=stderr)
144         urip = urlparse(uri)
145         origin = '%s %s' % ('🔒' if urip.scheme == 'https' else '🔴', urip.netloc)
146         self.window.set_title("SAML Login (%s)" % origin)
147
148         # if no response or no headers (for e.g. about:blank), skip checking this
149         if not rs or not h:
150             return
151
152         # convert to normal dict
153         d = {}
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}
157
158         if fd:
159             if self.verbose:
160                 print("[SAML   ] Got SAML result headers: %r" % fd, file=stderr)
161                 if self.verbose > 1:
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)
165             self.check_done()
166
167         if not self.success:
168             if self.verbose > 1:
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)
172
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")
176
177         html_parser = CommentHtmlParser()
178         html_parser.feed(content)
179
180         fd = {}
181         for comment in html_parser.comments:
182             if self.verbose > 1:
183                 print("[SAML   ] Found comment in response body: '%s'" % comment, file=stderr)
184             try:
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
189                 for elem in xmlroot:
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
194
195         if self.verbose > 1:
196             print("[SAML   ] Finished parsing response body for %s" % resource.get_uri(), file=stderr)
197         if fd:
198             if self.verbose:
199                 print("[SAML   ] Got SAML result tags: %s" % fd, file=stderr)
200             self.saml_result.update(fd, server=urlparse(resource.get_uri()).netloc)
201
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)
205
206     def check_done(self):
207         d = self.saml_result
208         if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
209             if self.verbose:
210                 print("[SAML   ] Got all required SAML headers, done.", file=stderr)
211             self.success = True
212             Gtk.main_quit()
213             return True
214
215
216 class TLSAdapter(requests.adapters.HTTPAdapter):
217     '''Adapt to older TLS stacks that would raise errors otherwise.
218
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.
224
225     See Also
226     --------
227     https://github.com/psf/requests/issues/4775#issuecomment-478198879
228
229     Notes
230     -----
231     Python is missing an ssl.OP_LEGACY_SERVER_CONNECT constant.
232     We have extracted the relevant value from <openssl/ssl.h>.
233
234     '''
235
236     def __init__(self, verify=True):
237         self.verify = verify
238         super().__init__()
239
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
244
245         if not self.verify:
246             ssl_context.check_hostname = False
247             ssl_context.verify_mode = ssl.CERT_NONE
248
249         if hasattr(ssl_context, "keylog_filename"):
250             sslkeylogfile = environ.get("SSLKEYLOGFILE")
251             if sslkeylogfile:
252                 ssl_context.keylog_filename = sslkeylogfile
253
254         self.poolmanager = urllib3.PoolManager(
255                 num_pools=connections,
256                 maxsize=maxsize,
257                 block=block,
258                 ssl_context=ssl_context)
259
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')
264
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)
302
303     args.ocos = clientos2ocos[args.clientos]
304     args.extra = dict(x.split('=', 1) for x in args.extra)
305
306     if args.cookies:
307         args.cookies = path.expanduser(args.cookies)
308
309     if args.cert and args.key:
310         args.cert, args.key = (args.cert, args.key), None
311     elif args.cert:
312         args.cert = (args.cert, None)
313     elif args.key:
314         p.error('--key specified without --cert')
315     else:
316         args.cert = None
317
318     return p, args
319
320 def main(args = None):
321     p, args = parse_args(args)
322
323     s = requests.Session()
324     if args.insecure:
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
327     s.cert = args.cert
328
329     if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
330     if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
331
332     # query prelogin.esp and parse SAML bits
333     if args.uri:
334         sam, uri, html = 'URI', args.server, None
335     else:
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}
338         if args.verbose:
339             print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
340         try:
341             res = s.post(endpoint, verify=args.verify, data=data)
342         except Exception as ex:
343             rootex = ex
344             while True:
345                 if isinstance(rootex, ssl.SSLError):
346                     break
347                 elif not rootex.__cause__ and not rootex.__context__:
348                     break
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)
354             else:
355                 raise
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'))
365             else:
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"
371                     "Things to try:\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)))
374         sam = sam.text
375         sr = a2b_base64(sr.text).decode()
376         if sam == 'POST':
377             html, uri = sr, None
378         elif sam == 'REDIRECT':
379             uri, html = sr, None
380         else:
381             p.error("Unknown SAML method (%s)" % sam)
382
383     # launch external browser for debugging
384     if args.external:
385         print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
386         import webbrowser
387         if html:
388             uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
389         webbrowser.open(uri)
390         raise SystemExit
391
392     # spawn WebKit view to do SAML interactive login
393     if args.verbose:
394         print("Got SAML %s, opening browser..." % sam, file=stderr)
395     slv = SAMLLoginView(uri, html, args)
396     Gtk.main()
397     if slv.closed:
398         print("Login window closed by user.", file=stderr)
399         p.exit(1)
400     if not slv.success:
401         p.error('''Login window closed without producing SAML cookies.''')
402
403     # extract response and convert to OpenConnect command-line
404     un = slv.saml_result.get('saml-username')
405     if args.ignore_redirects:
406         server = args.server
407     else:
408         server = slv.saml_result.get('server', args.server)
409
410     for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
411         cv = slv.saml_result.get(cn)
412         if cv:
413             break
414     else:
415         cn = ifh = None
416         p.error("Didn't get an expected cookie. Something went wrong.")
417
418     urlpath = args.interface + ":" + cn
419     openconnect_args = [
420         "--protocol=gp",
421         "--user="+un,
422         "--os="+args.ocos,
423         "--usergroup="+urlpath,
424         "--passwd-on-stdin",
425         server
426     ] + args.openconnect_extra
427
428     if args.insecure:
429         openconnect_args.insert(1, "--allow-insecure-crypto")
430     if args.user_agent:
431         openconnect_args.insert(1, "--useragent="+args.user_agent)
432     if args.cert:
433         cert, key = args.cert
434         if key:
435             openconnect_args.insert(1, "--sslkey="+key)
436         openconnect_args.insert(1, "--certificate="+cert)
437     if args.no_proxy:
438         openconnect_args.insert(1, "--no-proxy")
439
440     openconnect_command = '''    echo {} |\n        sudo openconnect {}'''.format(
441         quote(cv), " ".join(map(quote, openconnect_args)))
442
443     if args.verbose:
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)
451             else:
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),
457                   file=stderr)
458         print('''\nSAML response converted to OpenConnect command line invocation:\n''', file=stderr)
459         print(openconnect_command, file=stderr)
460
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)
464
465     if args.exec:
466         print('''Launching OpenConnect with {}, equivalent to:\n{}'''.format(args.exec, openconnect_command), file=stderr)
467         with tempfile.TemporaryFile('w+') as tf:
468             tf.write(cv)
469             tf.flush()
470             tf.seek(0)
471             # redirect stdin from this file, before it is closed by the context manager
472             # (it will remain accessible via the open file descriptor)
473             dup2(tf.fileno(), 0)
474         cmd = ["openconnect"] + openconnect_args
475         if args.exec == 'pkexec':
476             cmd = ["pkexec", "--user", "root"] + cmd
477         elif args.exec == 'sudo':
478             cmd = ["sudo"] + cmd
479         execvp(cmd[0], cmd)
480
481     else:
482         varvals = {
483             'HOST': quote('https://%s/%s' % (server, urlpath)),
484             'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
485         }
486         print('\n'.join('%s=%s' % pair for pair in varvals.items()))
487
488 if __name__ == "__main__":
489     main()

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