]> code.communitydata.science - nu-vpn-proxy.git/blob - gp-saml-gui.py
d6f8f15c4bbf2a517b6daf2c3946a18e688c32f5
[nu-vpn-proxy.git] / gp-saml-gui.py
1 #!/usr/bin/env python3
2
3 try:
4     import gi
5 except ImportError:
6     try:
7         import pgi as gi
8     except ImportError:
9         gi = None
10 if gi is None:
11     raise ImportError("Either gi (PyGObject) or pgi module is required.")
12
13 import argparse
14 import pprint
15 import urllib
16 import requests
17 import xml.etree.ElementTree as ET
18 import ssl
19
20 from operator import setitem
21 from os import path
22 from shlex import quote
23 from sys import stderr, platform
24 from binascii import a2b_base64, b2a_base64
25 from urllib.parse import urlparse, urlencode
26
27 gi.require_version('Gtk', '3.0')
28 gi.require_version('WebKit2', '4.0')
29 from gi.repository import Gtk, WebKit2, GLib
30
31 class SAMLLoginView:
32     def __init__(self, uri, html=None, verbose=False, cookies=None, verify=True):
33         window = Gtk.Window()
34
35         # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
36
37         self.closed = False
38         self.success = False
39         self.saml_result = {}
40         self.verbose = verbose
41
42         self.ctx = WebKit2.WebContext.get_default()
43         if not args.verify:
44             self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
45         self.cookies = self.ctx.get_cookie_manager()
46         if args.cookies:
47             self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS)
48             self.cookies.set_persistent_storage(args.cookies, WebKit2.CookiePersistentStorage.TEXT)
49         self.wview = WebKit2.WebView()
50
51         window.resize(500, 500)
52         window.add(self.wview)
53         window.show_all()
54         window.set_title("SAML Login")
55         window.connect('delete-event', self.close)
56         self.wview.connect('load-changed', self.get_saml_headers)
57         self.wview.connect('resource-load-started', self.log_resources)
58
59         if html:
60             self.wview.load_html(html, uri)
61         else:
62             self.wview.load_uri(uri)
63
64     def close(self, window, event):
65         self.closed = True
66         Gtk.main_quit()
67
68     def log_resources(self, webview, resource, request):
69         if self.verbose > 1:
70             print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
71         if self.verbose > 2:
72             resource.connect('finished', self.log_resource_details, request)
73
74     def log_resource_details(self, resource, request):
75         m = request.get_http_method() or 'Request'
76         uri = resource.get_uri()
77         rs = resource.get_response()
78         h = rs.get_http_headers() if rs else None
79         if h:
80             ct, cl = h.get_content_type(), h.get_content_length()
81             content_type, charset = ct[0], ct.params.get('charset')
82             content_details = '%d bytes of %s%s for ' % (cl, content_type, ('; charset='+charset) if charset else '')
83         print('[RECEIVE] %sresource %s %s' % (content_details if h else '', m, uri), file=stderr)
84
85     def log_resource_text(self, resource, result, content_type, charset=None, show_headers=None):
86         data = resource.get_data_finish(result)
87         content_details = '%d bytes of %s%s for ' % (len(data), content_type, ('; charset='+charset) if charset else '')
88         print('[DATA   ] %sresource %s' % (content_details, resource.get_uri()), file=stderr)
89         if show_headers:
90             for h,v in show_headers.items():
91                 print('%s: %s' % (h, v), file=stderr)
92             print(file=stderr)
93         if charset or content_type.startswith('text/'):
94             print(data.decode(charset or 'utf-8'), file=stderr)
95
96     def get_saml_headers(self, webview, event):
97         if event != WebKit2.LoadEvent.FINISHED:
98             return
99
100         mr = webview.get_main_resource()
101         uri = mr.get_uri()
102         rs = mr.get_response()
103         h = rs.get_http_headers()
104         if self.verbose:
105             print('[PAGE   ] Finished loading page %s' % uri, file=stderr)
106         if not h:
107             return
108
109         # convert to normal dict
110         d = {}
111         h.foreach(lambda k, v: setitem(d, k, v))
112         # filter to interesting headers
113         fd = {name:v for name, v in d.items() if name.startswith('saml-') or name in ('prelogin-cookie', 'portal-userauthcookie')}
114         if fd and self.verbose:
115             print("[SAML   ] Got SAML result headers: %r" % fd, file=stderr)
116             if self.verbose > 1:
117                 # display everything we found
118                 ct = h.get_content_type()
119                 mr.get_data(None, self.log_resource_text, ct[0], ct.params.get('charset'), d)
120
121         # check if we're done
122         self.saml_result.update(fd, server=urlparse(uri).netloc)
123         GLib.timeout_add(1000, self.check_done)
124
125     def check_done(self):
126         d = self.saml_result
127         if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
128             if args.verbose:
129                 print("[SAML   ] Got all required SAML headers, done.", file=stderr)
130             self.success = True
131             Gtk.main_quit()
132
133 def parse_args(args = None):
134     pf2clientos = dict(linux='Linux', darwin='Mac', win32='Windows', cygwin='Windows')
135     clientos2ocos = dict(Linux='linux-64', Mac='mac-intel', Windows='win')
136     default_clientos = pf2clientos.get(platform, 'Windows')
137
138     p = argparse.ArgumentParser()
139     p.add_argument('server', help='GlobalProtect server (portal or gateway)')
140     p.add_argument('--no-verify', dest='verify', action='store_false', default=True, help='Ignore invalid server certificate')
141     x = p.add_mutually_exclusive_group()
142     x.add_argument('-C', '--cookies', default='~/.gp-saml-gui-cookies',
143                    help='Use and store cookies in this file (instead of default %(default)s)')
144     x.add_argument('-K', '--no-cookies', dest='cookies', action='store_const', const=None,
145                    help="Don't use or store cookies at all")
146     x = p.add_mutually_exclusive_group()
147     x.add_argument('-p','--portal', dest='interface', action='store_const', const='portal', default='gateway',
148                    help='SAML auth to portal')
149     x.add_argument('-g','--gateway', dest='interface', action='store_const', const='gateway',
150                    help='SAML auth to gateway (default)')
151     g = p.add_argument_group('Client certificate')
152     g.add_argument('-c','--cert', help='PEM file containing client certificate (and optionally private key)')
153     g.add_argument('--key', help='PEM file containing client private key (if not included in same file as certificate)')
154     g = p.add_argument_group('Debugging and advanced options')
155     x = p.add_mutually_exclusive_group()
156     x.add_argument('-v','--verbose', default=1, action='count', help='Increase verbosity of explanatory output to stderr')
157     x.add_argument('-q','--quiet', dest='verbose', action='store_const', const=0, help='Reduce verbosity to a minimum')
158     g.add_argument('-x','--external', action='store_true', help='Launch external browser (for debugging)')
159     g.add_argument('-u','--uri', action='store_true', help='Treat server as the complete URI of the SAML entry point, rather than GlobalProtect server')
160     g.add_argument('--clientos', choices=set(pf2clientos.values()), default=default_clientos, help="clientos value to send (default is %(default)s)")
161     p.add_argument('extra', nargs='*', help='Extra form field(s) to pass to include in the login query string (e.g. "magic-cookie-value=deadbeef01234567")')
162     args = p.parse_args(args = None)
163
164     args.ocos = clientos2ocos[args.clientos]
165     args.extra = dict(x.split('=', 1) for x in args.extra)
166
167     if args.cookies:
168         args.cookies = path.expanduser(args.cookies)
169
170     if args.cert and args.key:
171         args.cert, args.key = (args.cert, args.key), None
172     elif args.cert:
173         args.cert = (args.cert, None)
174     elif args.key:
175         p.error('--key specified without --cert')
176     else:
177         args.cert = None
178
179     return p, args
180
181 if __name__ == "__main__":
182     p, args = parse_args()
183
184     s = requests.Session()
185     s.headers['User-Agent'] = 'PAN GlobalProtect'
186     s.cert = args.cert
187
188     if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
189     if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
190
191     # query prelogin.esp and parse SAML bits
192     if args.uri:
193         sam, uri, html = 'URI', args.server, None
194     else:
195         endpoint = 'https://{}/{}'.format(args.server, if2prelogin[args.interface])
196         data = {'tmp':'tmp', 'kerberos-support':'yes', 'ipv6-support':'yes', 'clientVer':4100, 'clientos':args.clientos, **args.extra}
197         if args.verbose:
198             print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
199         try:
200             res = s.post(endpoint, verify=args.verify, data=data)
201         except Exception as ex:
202             rootex = ex
203             while True:
204                 if isinstance(rootex, ssl.SSLError):
205                     break
206                 elif not rootex.__cause__ and not rootex.__context__:
207                     break
208                 rootex = rootex.__cause__ or rootex.__context__
209             if isinstance(rootex, ssl.CertificateError):
210                 p.error("SSL certificate error (try --no-verify to ignore): %s" % rootex)
211             elif isinstance(rootex, ssl.SSLError):
212                 p.error("SSL error: %s" % rootex)
213             else:
214                 raise
215         xml = ET.fromstring(res.content)
216         if xml.tag != 'prelogin-response':
217             p.error("This does not appear to be a GlobalProtect prelogin response\nCheck in browser: {}?{}".format(endpoint, urlencode(data)))
218         sam = xml.find('saml-auth-method')
219         sr = xml.find('saml-request')
220         if sam is None or sr is None:
221             p.error("{} prelogin response does not contain SAML tags (<saml-auth-method> or <saml-request> missing)\n\n"
222                     "Things to try:\n"
223                     "1) Spoof an officially supported OS (e.g. --clientos=Windows or --clientos=Mac)\n"
224                     "2) Check in browser: {}?{}".format(args.interface.title(), endpoint, urlencode(data)))
225         sam = sam.text
226         sr = a2b_base64(sr.text).decode()
227         if sam == 'POST':
228             html, uri = sr, None
229         elif sam == 'REDIRECT':
230             uri, html = sr, None
231         else:
232             p.error("Unknown SAML method (%s)" % sam)
233
234     # launch external browser for debugging
235     if args.external:
236         print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
237         import webbrowser
238         if html:
239             uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
240         webbrowser.open(uri)
241         raise SystemExit
242
243     # spawn WebKit view to do SAML interactive login
244     if args.verbose:
245         print("Got SAML %s, opening browser..." % sam, file=stderr)
246     slv = SAMLLoginView(uri, html, verbose=args.verbose, cookies=args.cookies, verify=args.verify)
247     Gtk.main()
248     if slv.closed:
249         print("Login window closed by user.", file=stderr)
250         p.exit(1)
251     if not slv.success:
252         p.error('''Login window closed without producing SAML cookies.''')
253
254     # extract response and convert to OpenConnect command-line
255     un = slv.saml_result.get('saml-username')
256     server = slv.saml_result.get('server', args.server)
257
258     for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
259         cv = slv.saml_result.get(cn)
260         if cv:
261             break
262     else:
263         cn = ifh = None
264         p.error("Didn't get an expected cookie. Something went wrong.")
265
266     if args.verbose:
267         # Warn about ambiguities
268         if server != args.server and not args.uri:
269             print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}. This probably '''
270                   '''means you should specify {1} as the server for final connection, but we're not 100% '''
271                   '''sure about this. You should probably try both.\n'''.format(args.server, server), file=stderr)
272         if ifh != args.interface and not args.uri:
273             print('''IMPORTANT: We started with SAML auth to the {} interface, but received a cookie '''
274                   '''that's often associated with the {} interface. You should probably try both.\n'''.format(args.interface, ifh),
275                   file=stderr)
276         print('''\nSAML response converted to OpenConnect command line invocation:\n''', file=stderr)
277         print('''    echo {} |\n        openconnect --protocol=gp --user={} --os={} --usergroup={}:{} --passwd-on-stdin {}'''.format(
278             quote(cv), quote(un), quote(args.ocos), quote(args.interface), quote(cn), quote(server)), file=stderr)
279
280         print('''\nSAML response converted to test-globalprotect-login.py invocation:\n''', file=stderr)
281         print('''    test-globalprotect-login.py --user={} --clientos={} -p '' \\\n         https://{}/{} {}={}\n'''.format(
282             quote(un), quote(args.clientos), quote(server), quote(if2auth[args.interface]), quote(cn), quote(cv)), file=stderr)
283     varvals = {
284         'HOST': quote('https://%s/%s:%s' % (server, if2auth[args.interface], cn)),
285         'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
286     }
287     print('\n'.join('%s=%s' % pair for pair in varvals.items()))

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