11 raise ImportError("Either gi (PyGObject) or pgi module is required.")
17 import xml.etree.ElementTree as ET
20 from operator import setitem
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
27 gi.require_version('Gtk', '3.0')
28 gi.require_version('WebKit2', '4.0')
29 from gi.repository import Gtk, WebKit2, GLib
32 def __init__(self, uri, html=None, verbose=False, cookies=None, verify=True):
35 # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
40 self.verbose = verbose
42 self.ctx = WebKit2.WebContext.get_default()
44 self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
45 self.cookies = self.ctx.get_cookie_manager()
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()
51 window.resize(500, 500)
52 window.add(self.wview)
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)
60 self.wview.load_html(html, uri)
62 self.wview.load_uri(uri)
64 def close(self, window, event):
68 def log_resources(self, webview, resource, request):
70 print('[REQUEST] %s for resource %s' % (request.get_http_method() or 'Request', resource.get_uri()), file=stderr)
72 resource.connect('finished', self.log_resource_details, request)
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
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)
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)
90 for h,v in show_headers.items():
91 print('%s: %s' % (h, v), file=stderr)
93 if charset or content_type.startswith('text/'):
94 print(data.decode(charset or 'utf-8'), file=stderr)
96 def get_saml_headers(self, webview, event):
97 if event != WebKit2.LoadEvent.FINISHED:
100 mr = webview.get_main_resource()
102 rs = mr.get_response()
103 h = rs.get_http_headers()
105 print('[PAGE ] Finished loading page %s' % uri, file=stderr)
109 # convert to normal dict
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)
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)
121 # check if we're done
122 self.saml_result.update(fd, server=urlparse(uri).netloc)
123 GLib.timeout_add(1000, self.check_done)
125 def check_done(self):
127 if 'saml-username' in d and ('prelogin-cookie' in d or 'portal-userauthcookie' in d):
129 print("[SAML ] Got all required SAML headers, done.", file=stderr)
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')
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)
164 args.ocos = clientos2ocos[args.clientos]
165 args.extra = dict(x.split('=', 1) for x in args.extra)
168 args.cookies = path.expanduser(args.cookies)
170 if args.cert and args.key:
171 args.cert, args.key = (args.cert, args.key), None
173 args.cert = (args.cert, None)
175 p.error('--key specified without --cert')
181 if __name__ == "__main__":
182 p, args = parse_args()
184 s = requests.Session()
185 s.headers['User-Agent'] = 'PAN GlobalProtect'
188 if2prelogin = {'portal':'global-protect/prelogin.esp','gateway':'ssl-vpn/prelogin.esp'}
189 if2auth = {'portal':'global-protect/getconfig.esp','gateway':'ssl-vpn/login.esp'}
191 # query prelogin.esp and parse SAML bits
193 sam, uri, html = 'URI', args.server, None
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}
198 print("Looking for SAML auth tags in response to %s..." % endpoint, file=stderr)
200 res = s.post(endpoint, verify=args.verify, data=data)
201 except Exception as ex:
204 if isinstance(rootex, ssl.SSLError):
206 elif not rootex.__cause__ and not rootex.__context__:
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)
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"
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)))
226 sr = a2b_base64(sr.text).decode()
229 elif sam == 'REDIRECT':
232 p.error("Unknown SAML method (%s)" % sam)
234 # launch external browser for debugging
236 print("Got SAML %s, opening external browser for debugging..." % sam, file=stderr)
239 uri = 'data:text/html;base64,' + b2a_base64(html.encode()).decode()
243 # spawn WebKit view to do SAML interactive login
245 print("Got SAML %s, opening browser..." % sam, file=stderr)
246 slv = SAMLLoginView(uri, html, verbose=args.verbose, cookies=args.cookies, verify=args.verify)
249 print("Login window closed by user.", file=stderr)
252 p.error('''Login window closed without producing SAML cookies.''')
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)
258 for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
259 cv = slv.saml_result.get(cn)
264 p.error("Didn't get an expected cookie. Something went wrong.")
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),
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)
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)
284 'HOST': quote('https://%s/%s:%s' % (server, if2auth[args.interface], cn)),
285 'USER': quote(un), 'COOKIE': quote(cv), 'OS': quote(args.ocos),
287 print('\n'.join('%s=%s' % pair for pair in varvals.items()))