17
0

updated with the new version of gp-saml-gui (from upstream)

This commit is contained in:
Benjamin Mako Hill 2025-03-09 23:10:06 -07:00
parent 0b6ace2da3
commit 5d9f17d8ee
2 changed files with 75 additions and 32 deletions

View File

@ -1,9 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import warnings
try: try:
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
gi.require_version('WebKit2', '4.0') try:
gi.require_version('WebKit2', '4.1')
except ValueError: # I wish this were ImportError
gi.require_version('WebKit2', '4.0')
warnings.warn("Using WebKit2Gtk 4.0 (obsolete); please upgrade to WebKit2Gtk 4.1")
from gi.repository import Gtk, WebKit2, GLib from gi.repository import Gtk, WebKit2, GLib
except ImportError: except ImportError:
try: try:
@ -11,10 +17,11 @@ except ImportError:
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
gi.require_version('WebKit2', '4.0') gi.require_version('WebKit2', '4.0')
from pgi.repository import Gtk, WebKit2, GLib from pgi.repository import Gtk, WebKit2, GLib
warnings.warn("Using PGI and WebKit2Gtk 4.0 (both obsolete); please upgrade to PyGObject and WebKit2Gtk 4.1")
except ImportError: except ImportError:
gi = None gi = None
if gi is None: if gi is None:
raise ImportError("Either gi (PyGObject) or pgi module is required.") raise ImportError("Either gi (PyGObject) or pgi (obsolete) module is required.")
import argparse import argparse
import urllib3 import urllib3
@ -24,11 +31,11 @@ import ssl
import tempfile import tempfile
from operator import setitem from operator import setitem
from os import path, dup2, execvp from os import path, dup2, execvp, environ
from shlex import quote from shlex import quote
from sys import stderr, platform from sys import stderr, platform
from binascii import a2b_base64, b2a_base64 from binascii import a2b_base64, b2a_base64
from urllib.parse import urlparse, urlencode from urllib.parse import urlparse, urlencode, urlunsplit
from html.parser import HTMLParser from html.parser import HTMLParser
@ -45,30 +52,35 @@ COOKIE_FIELDS = ('prelogin-cookie', 'portal-userauthcookie')
class SAMLLoginView: class SAMLLoginView:
def __init__(self, uri, html=None, verbose=False, cookies=None, verify=True, user_agent=None): def __init__(self, uri, html, args):
Gtk.init(None) Gtk.init(None)
window = Gtk.Window() self.window = window = Gtk.Window()
# API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0 # API reference: https://lazka.github.io/pgi-docs/#WebKit2-4.0
self.closed = False self.closed = False
self.success = False self.success = False
self.saml_result = {} self.saml_result = {}
self.verbose = verbose self.verbose = args.verbose
self.ctx = WebKit2.WebContext.get_default() self.ctx = WebKit2.WebContext.get_default()
if not verify: if not args.verify:
self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE) self.ctx.set_tls_errors_policy(WebKit2.TLSErrorsPolicy.IGNORE)
self.cookies = self.ctx.get_cookie_manager() self.cookies = self.ctx.get_cookie_manager()
if cookies: if args.cookies:
self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS) self.cookies.set_accept_policy(WebKit2.CookieAcceptPolicy.ALWAYS)
self.cookies.set_persistent_storage(cookies, WebKit2.CookiePersistentStorage.TEXT) self.cookies.set_persistent_storage(args.cookies, WebKit2.CookiePersistentStorage.TEXT)
self.wview = WebKit2.WebView() self.wview = WebKit2.WebView()
if user_agent is None: if args.no_proxy:
user_agent = 'PAN GlobalProtect' data_manager = self.ctx.get_website_data_manager()
data_manager.set_network_proxy_settings(WebKit2.NetworkProxyMode.NO_PROXY, None)
if args.user_agent is None:
args.user_agent = 'PAN GlobalProtect'
settings = self.wview.get_settings() settings = self.wview.get_settings()
settings.set_user_agent(user_agent) settings.set_user_agent(args.user_agent)
self.wview.set_settings(settings) self.wview.set_settings(settings)
window.resize(500, 500) window.resize(500, 500)
@ -101,7 +113,8 @@ class SAMLLoginView:
h = rs.get_http_headers() if rs else None h = rs.get_http_headers() if rs else None
if h: if h:
ct, cl = h.get_content_type(), h.get_content_length() ct, cl = h.get_content_type(), h.get_content_length()
content_type, charset = ct[0], ct.params.get('charset') content_type = ct[0]
charset = ct.params.get('charset') if ct.params else None
content_details = '%d bytes of %s%s for ' % (cl, content_type, ('; charset='+charset) if charset else '') content_details = '%d bytes of %s%s for ' % (cl, content_type, ('; charset='+charset) if charset else '')
print('[RECEIVE] %sresource %s %s' % (content_details if h else '', m, uri), file=stderr) print('[RECEIVE] %sresource %s %s' % (content_details if h else '', m, uri), file=stderr)
@ -124,10 +137,17 @@ class SAMLLoginView:
uri = mr.get_uri() uri = mr.get_uri()
rs = mr.get_response() rs = mr.get_response()
h = rs.get_http_headers() if rs else None h = rs.get_http_headers() if rs else None
ct = h.get_content_type() ct = h.get_content_type() if h else None
if self.verbose: if self.verbose:
print('[PAGE ] Finished loading page %s' % uri, file=stderr) print('[PAGE ] Finished loading page %s' % uri, file=stderr)
urip = urlparse(uri)
origin = '%s %s' % ('🔒' if urip.scheme == 'https' else '🔴', urip.netloc)
self.window.set_title("SAML Login (%s)" % origin)
# if no response or no headers (for e.g. about:blank), skip checking this
if not rs or not h:
return
# convert to normal dict # convert to normal dict
d = {} d = {}
@ -212,10 +232,25 @@ class TLSAdapter(requests.adapters.HTTPAdapter):
We have extracted the relevant value from <openssl/ssl.h>. We have extracted the relevant value from <openssl/ssl.h>.
''' '''
def __init__(self, verify=True):
self.verify = verify
super().__init__()
def init_poolmanager(self, connections, maxsize, block=False): def init_poolmanager(self, connections, maxsize, block=False):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.set_ciphers('DEFAULT:@SECLEVEL=1') ssl_context.set_ciphers('DEFAULT:@SECLEVEL=1')
ssl_context.options |= 1<<2 # OP_LEGACY_SERVER_CONNECT ssl_context.options |= 1<<2 # OP_LEGACY_SERVER_CONNECT
if not self.verify:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
if hasattr(ssl_context, "keylog_filename"):
sslkeylogfile = environ.get("SSLKEYLOGFILE")
if sslkeylogfile:
ssl_context.keylog_filename = sslkeylogfile
self.poolmanager = urllib3.PoolManager( self.poolmanager = urllib3.PoolManager(
num_pools=connections, num_pools=connections,
maxsize=maxsize, maxsize=maxsize,
@ -236,6 +271,7 @@ def parse_args(args = None):
x.add_argument('-K', '--no-cookies', dest='cookies', action='store_const', const=None, x.add_argument('-K', '--no-cookies', dest='cookies', action='store_const', const=None,
help="Don't use or store cookies at all") help="Don't use or store cookies at all")
x = p.add_mutually_exclusive_group() x = p.add_mutually_exclusive_group()
p.add_argument('-i', '--ignore-redirects', action='store_true', help='Use specified gateway hostname as server, ignoring redirects')
x.add_argument('-g','--gateway', dest='interface', action='store_const', const='gateway', default='portal', x.add_argument('-g','--gateway', dest='interface', action='store_const', const='gateway', default='portal',
help='SAML auth to gateway') help='SAML auth to gateway')
x.add_argument('-p','--portal', dest='interface', action='store_const', const='portal', x.add_argument('-p','--portal', dest='interface', action='store_const', const='portal',
@ -251,6 +287,7 @@ def parse_args(args = None):
x.add_argument('-x','--external', action='store_true', help='Launch external browser (for debugging)') x.add_argument('-x','--external', action='store_true', help='Launch external browser (for debugging)')
x.add_argument('-P','--pkexec-openconnect', action='store_const', dest='exec', const='pkexec', help='Use PolicyKit to exec openconnect') x.add_argument('-P','--pkexec-openconnect', action='store_const', dest='exec', const='pkexec', help='Use PolicyKit to exec openconnect')
x.add_argument('-S','--sudo-openconnect', action='store_const', dest='exec', const='sudo', help='Use sudo to exec openconnect') x.add_argument('-S','--sudo-openconnect', action='store_const', dest='exec', const='sudo', help='Use sudo to exec openconnect')
x.add_argument('-E','--exec-openconnect', action='store_const', dest='exec', const='exec', help='Execute openconnect directly (advanced users)')
g.add_argument('-u','--uri', action='store_true', help='Treat server as the complete URI of the SAML entry point, rather than GlobalProtect server') g.add_argument('-u','--uri', action='store_true', help='Treat server as the complete URI of the SAML entry point, rather than GlobalProtect server')
g.add_argument('--clientos', choices=set(pf2clientos.values()), default=default_clientos, help="clientos value to send (default is %(default)s)") g.add_argument('--clientos', choices=set(pf2clientos.values()), default=default_clientos, help="clientos value to send (default is %(default)s)")
p.add_argument('-f','--field', dest='extra', action='append', default=[], p.add_argument('-f','--field', dest='extra', action='append', default=[],
@ -259,6 +296,7 @@ def parse_args(args = None):
help='Allow use of insecure renegotiation or ancient 3DES and RC4 ciphers') help='Allow use of insecure renegotiation or ancient 3DES and RC4 ciphers')
p.add_argument('--user-agent', '--useragent', default='PAN GlobalProtect', p.add_argument('--user-agent', '--useragent', default='PAN GlobalProtect',
help='Use the provided string as the HTTP User-Agent header (default is %(default)r, as used by OpenConnect)') help='Use the provided string as the HTTP User-Agent header (default is %(default)r, as used by OpenConnect)')
p.add_argument('--no-proxy', action='store_true', help='Disable system proxy settings')
p.add_argument('openconnect_extra', nargs='*', help="Extra arguments to include in output OpenConnect command-line") p.add_argument('openconnect_extra', nargs='*', help="Extra arguments to include in output OpenConnect command-line")
args = p.parse_args(args) args = p.parse_args(args)
@ -284,7 +322,7 @@ def main(args = None):
s = requests.Session() s = requests.Session()
if args.insecure: if args.insecure:
s.mount('https://', TLSAdapter()) s.mount('https://', TLSAdapter(verify=args.verify))
s.headers['User-Agent'] = 'PAN GlobalProtect' if args.user_agent is None else args.user_agent s.headers['User-Agent'] = 'PAN GlobalProtect' if args.user_agent is None else args.user_agent
s.cert = args.cert s.cert = args.cert
@ -354,7 +392,7 @@ def main(args = None):
# spawn WebKit view to do SAML interactive login # spawn WebKit view to do SAML interactive login
if args.verbose: if args.verbose:
print("Got SAML %s, opening browser..." % sam, file=stderr) print("Got SAML %s, opening browser..." % sam, file=stderr)
slv = SAMLLoginView(uri, html, verbose=args.verbose, cookies=args.cookies, verify=args.verify, user_agent=args.user_agent) slv = SAMLLoginView(uri, html, args)
Gtk.main() Gtk.main()
if slv.closed: if slv.closed:
print("Login window closed by user.", file=stderr) print("Login window closed by user.", file=stderr)
@ -364,7 +402,10 @@ def main(args = None):
# extract response and convert to OpenConnect command-line # extract response and convert to OpenConnect command-line
un = slv.saml_result.get('saml-username') un = slv.saml_result.get('saml-username')
server = slv.saml_result.get('server', args.server) if args.ignore_redirects:
server = args.server
else:
server = slv.saml_result.get('server', args.server)
for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')): for cn, ifh in (('prelogin-cookie','gateway'), ('portal-userauthcookie','portal')):
cv = slv.saml_result.get(cn) cv = slv.saml_result.get(cn)
@ -393,6 +434,8 @@ def main(args = None):
if key: if key:
openconnect_args.insert(1, "--sslkey="+key) openconnect_args.insert(1, "--sslkey="+key)
openconnect_args.insert(1, "--certificate="+cert) openconnect_args.insert(1, "--certificate="+cert)
if args.no_proxy:
openconnect_args.insert(1, "--no-proxy")
openconnect_command = ''' echo {} |\n sudo openconnect {}'''.format( openconnect_command = ''' echo {} |\n sudo openconnect {}'''.format(
quote(cv), " ".join(map(quote, openconnect_args))) quote(cv), " ".join(map(quote, openconnect_args)))
@ -400,9 +443,14 @@ def main(args = None):
if args.verbose: if args.verbose:
# Warn about ambiguities # Warn about ambiguities
if server != args.server and not args.uri: if server != args.server and not args.uri:
print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}. This probably ''' if args.ignore_redirects:
'''means you should specify {1} as the server for final connection, but we're not 100% ''' print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}. This probably '''
'''sure about this. You should probably try both.\n'''.format(args.server, server), file=stderr) '''means you should specify {1} as the server for final connection, but we're not 100% '''
'''sure about this. You should probably try both; if necessary, use the '''
'''--ignore-redirects option to specify desired behavior.\n'''.format(args.server, server), file=stderr)
else:
print('''IMPORTANT: During the SAML auth, you were redirected from {0} to {1}, however the '''
'''redirection was ignored because you specified --ignore-redirects.\n'''.format(args.server, server), file=stderr)
if ifh != args.interface and not args.uri: if ifh != args.interface and not args.uri:
print('''IMPORTANT: We started with SAML auth to the {} interface, but received a cookie ''' print('''IMPORTANT: We started with SAML auth to the {} interface, but received a cookie '''
'''that's often associated with the {} interface. You should probably try both.\n'''.format(args.interface, ifh), '''that's often associated with the {} interface. You should probably try both.\n'''.format(args.interface, ifh),
@ -423,10 +471,11 @@ def main(args = None):
# redirect stdin from this file, before it is closed by the context manager # redirect stdin from this file, before it is closed by the context manager
# (it will remain accessible via the open file descriptor) # (it will remain accessible via the open file descriptor)
dup2(tf.fileno(), 0) dup2(tf.fileno(), 0)
cmd = ["openconnect"] + openconnect_args
if args.exec == 'pkexec': if args.exec == 'pkexec':
cmd = ["pkexec", "--user", "root", "openconnect"] + openconnect_args cmd = ["pkexec", "--user", "root"] + cmd
elif args.exec == 'sudo': elif args.exec == 'sudo':
cmd = ["sudo", "openconnect"] + openconnect_args cmd = ["sudo"] + cmd
execvp(cmd[0], cmd) execvp(cmd[0], cmd)
else: else:

View File

@ -1,13 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
from __future__ import print_function from sys import stderr, platform
from sys import stderr, version_info, platform from urllib.parse import urlparse, urlencode
if (version_info >= (3, 0)):
from urllib.parse import urlparse, urlencode
raw_input = input
else:
from urlparse import urlparse
from urllib import urlencode
import requests import requests
import argparse import argparse
import getpass import getpass
@ -68,7 +62,7 @@ if prelogin:
else: else:
# same request params work for /global-protect/getconfig.esp as for /ssl-vpn/login.esp # same request params work for /global-protect/getconfig.esp as for /ssl-vpn/login.esp
if args.user == None: if args.user == None:
args.user = raw_input('Username: ') args.user = input('Username: ')
if args.password == None: if args.password == None:
args.password = getpass.getpass('Password: ') args.password = getpass.getpass('Password: ')
data=dict(user=args.user, passwd=args.password, data=dict(user=args.user, passwd=args.password,