]> www.infradead.org Git - users/dwmw2/openconnect.git/commitdiff
Assume that a 'portal-*cookie' will allow us to bypass gateway SAML
authorDaniel Lenski <dlenski@gmail.com>
Fri, 28 May 2021 19:42:57 +0000 (12:42 -0700)
committerDaniel Lenski <dlenski@gmail.com>
Thu, 24 Jun 2021 18:17:53 +0000 (11:17 -0700)
For many GlobalProtect VPNs with SAML, the 'portal-userauthcookie' appears
to be *the* mechanism by which gateway authentication can be bypassed once
portal authentication is complete.

Unfortunately, there are exceptions which will require a more complex
resolution involved a re-entrant SAML flow
(https://gitlab.com/openconnect/openconnect/-/issues/147#note_587163143),
but this patch will at least not make them worse.

This can work in many cases…

- When the user's password is only usable one time (already working as of 008aefd7),
- When the portal requires SAML but the gateway doesn't (already working in 008aefd7),
- When the gateway requires SAML even though the portal doesn't (fixed here)

Additionally, this patch adds tests (tests/{fake-gp-server.py,gp-auth-and-config}) of
OpenConnect's ability to complete the following SAML flows:

- (SAML to portal after acquiring prelogin-cookie externally) → (complete gateway login
  using portal-userauthcookie)
- (SAML to gateway after acquiring prelogin-cookie externally)

Signed-off-by: Daniel Lenski <dlenski@gmail.com>
auth-globalprotect.c
tests/fake-gp-server.py
tests/gp-auth-and-config

index b466bbc699f6a3c9e34a690e7b7001ce54758c7e..eebb09166e529ea7c9b218f9e4e9e3b54edf0023 100644 (file)
@@ -115,7 +115,14 @@ static int parse_prelogin_xml(struct openconnect_info *vpninfo, xmlNode *xml_nod
 
        /* XX: Alt-secret form field must be specified for SAML, because we can't autodetect it */
        if (saml_method || saml_path) {
-               if (!ctx->alt_secret) {
+               if (ctx->portal_userauthcookie)
+                       vpn_progress(vpninfo, PRG_DEBUG, _("SAML authentication required; using portal-userauthcookie to continue SAML.\n"));
+               else if (ctx->portal_prelogonuserauthcookie)
+                       vpn_progress(vpninfo, PRG_DEBUG, _("SAML authentication required; using portal-prelogonuserauthcookie to continue SAML.\n"));
+               else if (ctx->alt_secret)
+                       vpn_progress(vpninfo, PRG_DEBUG, _("Destination form field %s was specified; assuming SAML %s authentication is complete.\n"),
+                                    ctx->alt_secret, saml_method);
+               else {
                        if (saml_method && !strcmp(saml_method, "REDIRECT"))
                                vpn_progress(vpninfo, PRG_ERR,
                                             _("SAML %s authentication is required via %s\n"),
@@ -126,12 +133,10 @@ static int parse_prelogin_xml(struct openconnect_info *vpninfo, xmlNode *xml_nod
                                             saml_method);
                        vpn_progress(vpninfo, PRG_ERR,
                                     _("When SAML authentication is complete, specify destination form field by appending :field_name to login URL.\n"));
-                       /* XX: EINVAL will lead to "failure to parse response", with unnecessary/confusing extra logging output */
+                       /* XX: EINVAL will lead to "failure to parse response", with unnecessary/confusing extra logging output */
                        result = -EPERM;
                        goto out;
-               } else
-                       vpn_progress(vpninfo, PRG_DEBUG, _("Destination form field %s was specified; assuming SAML %s authentication is complete.\n"),
-                                    saml_method, ctx->alt_secret);
+               }
        }
 
        /* Replace old form */
@@ -341,7 +346,7 @@ static int parse_login_xml(struct openconnect_info *vpninfo, xmlNode *xml_node,
                } else if (arg->check && (!value || strcmp(value, arg->check))) {
                        unknown_args++;
                        fatal_args += arg->err_missing;
-            vpn_progress(vpninfo, PRG_ERR,
+                       vpn_progress(vpninfo, PRG_ERR,
                                     _("GlobalProtect login returned %s=%s (expected %s)\n"),
                                     arg->opt, value, arg->check);
                } else if ((arg->err_missing || arg->warn_missing) && !value) {
index fb284a32d6bbcedda3fc11f40433d19c936169e2..dcc13701e7ccbf58593c4d99766019468a5dd48d 100755 (executable)
@@ -60,29 +60,38 @@ def check_form_against_session(*fields, use_query=False, on_failure=None):
 
 ########################################
 
+if_path2name = {'global-protect':'portal', 'ssl-vpn':'gateway'}
 
 # Get parameters into the initial session setup in order to configure:
 #   gateways: list of gateway names for portal to offer (all will point to same HOST:PORT as portal)
 #   portal_2fa: if set, require challenge-based 2FA to complete /global-protect/getconfig.esp request
 #   gateway_2fa: if set, require challenge-based 2FA to complete /ssl-vpn/login.esp request
+#   portal_saml: set to 'portal-userauthcookie' or 'prelogin-cookie' to require SAML on portal (and
+#                expect the named cookie to be provided to signal SAML completion)
+#   gateway_saml: likewise, set to require SAML on gateway
 #   portal_cookie: if set (to 'portal-userauthcookie' or 'portal-prelogonuserauthcookie'), then
 #                  the portal getconfig response will include the named "cookie" field which should
 #                  be used to automatically continue login on the gateway
-@app.route('/global-protect/testconfig.esp', methods=('GET','POST',))
-@app.route('/ssl-vpn/testconfig.esp', methods=('GET','POST',))
-def testconfig():
-    gateways, portal_2fa, gw_2fa, portal_cookie = request.args.get('gateways'), request.args.get('portal_2fa'), request.args.get('gw_2fa'), request.args.get('portal_cookie')
+@app.route('/<any("global-protect", "ssl-vpn"):interface>/testconfig.esp', methods=('GET','POST',))
+def testconfig(interface):
+    gateways, portal_2fa, gw_2fa, portal_cookie, portal_saml, gateway_saml = request.args.get('gateways'), request.args.get('portal_2fa'), request.args.get('gw_2fa'), request.args.get('portal_cookie'), request.args.get('portal_saml'), request.args.get('gateway_saml')
     session.update(gateways=gateways and gateways.split(','), portal_cookie=portal_cookie,
-                   portal_2fa=portal_2fa and bool(portal_2fa), gw_2fa=gw_2fa and bool(gw_2fa))
-    prelogin = '/'.join(request.path.split('/')[:-1] + ['prelogin.esp'])
+                   portal_2fa=portal_2fa and bool(portal_2fa), gw_2fa=gw_2fa and bool(gw_2fa),
+                   portal_saml=portal_saml, gateway_saml=gateway_saml)
+    prelogin = url_for('prelogin', interface=interface)
     return redirect(prelogin)
 
 
 # Respond to initial prelogin requests
-@app.route('/global-protect/prelogin.esp', methods=('GET','POST',))
-@app.route('/ssl-vpn/prelogin.esp', methods=('GET','POST',))
-def prelogin():
-    session.update(step='%s-prelogin' % ('portal' if 'global-protect' in request.path else 'gateway'))
+@app.route('/<any("global-protect", "ssl-vpn"):interface>/prelogin.esp', methods=('GET','POST',))
+def prelogin(interface):
+    ifname = if_path2name[interface]
+    if session.get(ifname + '_saml'):
+        saml = '<saml-auth-method>REDIRECT</saml-auth-method><saml-request>{}</saml-request>'.format(
+            base64.urlsafe_b64encode(url_for('saml_form', interface=interface, _external=True).encode()).decode())
+    else:
+        saml = ''
+    session.update(step='%s-prelogin' % ifname)
     return '''
 <prelogin-response>
 <status>Success</status>
@@ -90,12 +99,18 @@ def prelogin():
 <autosubmit>false</autosubmit>
 <msg/>
 <newmsg/>
-<authentication-message>Please login to this fake VPN</authentication-message>
+<authentication-message>Please login to this fake GP VPN {ifname}</authentication-message>
 <username-label>Username</username-label>
 <password-label>Password</password-label>
-<panos-version>1</panos-version>
+<panos-version>1</panos-version>{saml}
 <region>EARTH</region>
-</prelogin-response>'''.format(request.path)
+</prelogin-response>'''.format(ifname=ifname, saml=saml)
+
+
+# Simple SAML form (not actually hooked up, for now)
+@app.route('/<any("global-protect", "ssl-vpn"):interface>/SAML_FORM')
+def saml_form(interface):
+    abort(503)
 
 
 def challenge_2fa(where):
@@ -115,14 +130,24 @@ def challenge_2fa(where):
 @app.route('/global-protect/getconfig.esp', methods=('POST',))
 def portal_config():
     portal_2fa = session.get('portal_2fa')
+    portal_saml = session.get('portal_saml')
     portal_cookie = session.get('portal_cookie')
     inputStr = request.form.get('inputStr') or None
+
     if portal_2fa and not inputStr:
         return challenge_2fa('portal')
-    if not (request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr')):
+
+    okay = False
+    if portal_saml and request.form.get('user') and request.form.get(portal_saml):
+        okay = True
+    elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'):
+        okay = True
+    if not okay:
         return 'Invalid username or password', 512
 
     session.update(step='portal-config', user=request.form.get('user'), passwd=request.form.get('passwd'),
+                   # clear SAML result fields to ensure failure if blindly retried on gateway
+                   saml_user=None, saml_value=None,
                    # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form:
                    inputStr=None)
     gateways = session.get('gateways') or ('Default gateway',)
@@ -144,14 +169,22 @@ def portal_config():
 @app.route('/ssl-vpn/login.esp', methods=('POST',))
 def gateway_login():
     gw_2fa = session.get('gw_2fa')
+    gateway_saml = session.get('gateway_saml')
     inputStr = request.form.get('inputStr') or None
+
     if session.get('portal_cookie') and request.form.get(session['portal_cookie']) == session.get(session['portal_cookie']):
         # a correct portal_cookie explicitly allows us to bypass other gateway login forms
         pass
     elif gw_2fa and not inputStr:
         return challenge_2fa('gateway')
-    elif not (request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr')):
-        return 'Invalid username or password', 512
+    else:
+        okay = False
+        if gateway_saml and request.form.get('user') and request.form.get(gateway_saml):
+            okay = True
+        elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'):
+            okay = True
+        if not okay:
+            return 'Invalid username or password', 512
     session.update(step='gateway-login', user=request.form.get('user'), passwd=request.form.get('passwd'),
                    # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form:
                    inputStr=None)
index 289af1efb0c40e296f128ac8e6b1b8b3d0e77b7c..2ddef59f49e2dcc0887d2cd909d7908fb1d6fa83 100755 (executable)
@@ -59,6 +59,20 @@ echo -n "Authenticating with username/password/token via portal, then using port
 
 echo ok
 
+echo -n "Simulating completed SAML to portal, then using portal-userauthcookie to continue through SAML-requiring gateway... "
+( echo "prelogin-cookie" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp --disable-ipv6 -q "$ADDRESS:443/global-protect/testconfig.esp?portal_cookie=portal-userauthcookie&portal_saml=prelogin-cookie&gateway_saml=prelogin-cookie:prelogin-cookie" -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+test $? = 2 || # what OpenConnect returns when server rejects cookie upon tunnel connection, as the fake server does
+    fail $PID "Something went wrong in fake GlobalProtect server (other than the expected rejection of cookie)"
+
+echo ok
+
+echo -n "Simulating completed SAML to gateway... "
+( echo "prelogin-cookie" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp --disable-ipv6 -q "$ADDRESS:443/ssl-vpn/testconfig.esp?gateway_saml=prelogin-cookie:prelogin-cookie" -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+test $? = 2 || # what OpenConnect returns when server rejects cookie upon tunnel connection, as the fake server does
+    fail $PID "Something went wrong in fake GlobalProtect server (other than the expected rejection of cookie)"
+
+echo ok
+
 echo -n "Authenticating with username/password via portal, then +token via gateway... "
 ( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q "$ADDRESS:443/global-protect/testconfig.esp?gw_2fa=1" -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
     fail $PID "Could not receive cookie from fake GlobalProtect server"
@@ -69,7 +83,7 @@ echo -n "Authenticating with username/password/token via gateway... "
 ( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q "$ADDRESS:443/ssl-vpn/testconfig.esp?gw_2fa=1" -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
     fail $PID "Could not receive cookie from fake GlobalProtect server"
 
-ok
+echo ok
 
 echo -n "Authenticating with username/password via portal, then proceeding to tunnel stage... "
 echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q $ADDRESS:443/portal -u test $FINGERPRINT >/dev/null 2>&1