Skip to content

Commit

Permalink
Merge branch 'GP_portal_to_gateway_auth_with_cookies' into 'master'
Browse files Browse the repository at this point in the history
Pass the `portal-*cookie` values received in the portal config to the gateway login

Closes #147

See merge request openconnect/openconnect!199
  • Loading branch information
dwmw2 committed Jun 29, 2021
2 parents be34339 + 2c8960f commit b732fff
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 37 deletions.
52 changes: 41 additions & 11 deletions auth-globalprotect.c
Expand Up @@ -28,6 +28,8 @@
struct login_context {
char *username; /* Username that has already succeeded in some form */
char *alt_secret; /* Alternative secret (DO NOT FREE) */
char *portal_userauthcookie; /* portal-userauthcookie (from global-protect/getconfig.esp) */
char *portal_prelogonuserauthcookie; /* portal-prelogonuserauthcookie (from global-protect/getconfig.esp) */
struct oc_auth_form *form;
};

Expand Down Expand Up @@ -113,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"),
Expand All @@ -124,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 */
Expand Down Expand Up @@ -339,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) {
Expand Down Expand Up @@ -392,6 +399,7 @@ static int parse_login_xml(struct openconnect_info *vpninfo, xmlNode *xml_node,
*/
static int parse_portal_xml(struct openconnect_info *vpninfo, xmlNode *xml_node, void *cb_data)
{
struct login_context *ctx = cb_data;
struct oc_auth_form *form;
xmlNode *x, *x2, *x3, *gateways = NULL;
struct oc_form_opt_select *opt;
Expand Down Expand Up @@ -446,8 +454,21 @@ static int parse_portal_xml(struct openconnect_info *vpninfo, xmlNode *xml_node,
}
}
}
} else
} else {
xmlnode_get_val(x, "portal-name", &portal);
if (!xmlnode_get_val(x, "portal-userauthcookie", &ctx->portal_userauthcookie)) {
if (!*ctx->portal_userauthcookie || !strcmp(ctx->portal_userauthcookie, "empty")) {
free(ctx->portal_userauthcookie);
ctx->portal_userauthcookie = NULL;
}
}
if (!xmlnode_get_val(x, "portal-prelogonuserauthcookie", &ctx->portal_prelogonuserauthcookie)) {
if (!*ctx->portal_prelogonuserauthcookie || !strcmp(ctx->portal_prelogonuserauthcookie, "empty")) {
free(ctx->portal_prelogonuserauthcookie);
ctx->portal_prelogonuserauthcookie = NULL;
}
}
}
}
}

Expand Down Expand Up @@ -609,6 +630,11 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, struct login
append_opt(request_body, "os-version", vpninfo->platname);
append_opt(request_body, "server", vpninfo->hostname);
append_opt(request_body, "computer", vpninfo->localname);
if (ctx->portal_userauthcookie)
append_opt(request_body, "portal-userauthcookie", ctx->portal_userauthcookie);
if (ctx->portal_prelogonuserauthcookie)
append_opt(request_body, "portal-prelogonuserauthcookie", ctx->portal_prelogonuserauthcookie);

if (vpninfo->ip_info.addr)
append_opt(request_body, "preferred-ip", vpninfo->ip_info.addr);
if (vpninfo->ip_info.addr6)
Expand Down Expand Up @@ -646,11 +672,13 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, struct login
/* New form is already populated from the challenge */
goto got_form;
} else if (portal && result == 0) {
/* Portal login succeeded; blindly retry same credentials on gateway,
* unless it was a challenge auth form or alt-secret form.
/* Portal login succeeded; blindly retry same credentials on gateway if:
* (a) we received a cookie that should allow automatic retry
* OR (b) portal form was neither challenge auth nor alt-secret (SAML)
*/
portal = 0;
if (strcmp(ctx->form->auth_id, "_challenge") && !ctx->alt_secret) {
if (ctx->portal_userauthcookie || ctx->portal_prelogonuserauthcookie ||
(strcmp(ctx->form->auth_id, "_challenge") && !ctx->alt_secret)) {
blind_retry = 1;
goto replay_form;
}
Expand All @@ -667,7 +695,7 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, struct login

int gpst_obtain_cookie(struct openconnect_info *vpninfo)
{
struct login_context ctx = { .username=NULL, .alt_secret=NULL, .form=NULL };
struct login_context ctx = { .username=NULL, .alt_secret=NULL, .portal_userauthcookie=NULL, .portal_prelogonuserauthcookie=NULL, .form=NULL };
int result;

/* An alternate password/secret field may be specified in the "URL path" (or --usergroup).
Expand Down Expand Up @@ -698,6 +726,8 @@ int gpst_obtain_cookie(struct openconnect_info *vpninfo)
}
free(ctx.username);
free(ctx.alt_secret);
free(ctx.portal_userauthcookie);
free(ctx.portal_prelogonuserauthcookie);
free_auth_form(ctx.form);
return result;
}
Expand Down
104 changes: 79 additions & 25 deletions tests/fake-gp-server.py
Expand Up @@ -60,36 +60,57 @@ def wrapped(*args, **kwargs):

########################################


# Get parameters into the initial session setup in order to configure gateways, 2FA requirement
@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 = request.args.get('gateways'), request.args.get('portal_2fa'), request.args.get('gw_2fa')
session.update(gateways=gateways and gateways.split(','),
portal_2fa=portal_2fa and bool(portal_2fa), gw_2fa=gw_2fa and bool(gw_2fa))
prelogin = '/'.join(request.path.split('/')[:-1] + ['prelogin.esp'])
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('/<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),
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>
<ccusername/>
<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):
Expand All @@ -109,33 +130,64 @@ 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'), inputStr=None)
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',)
gwlist = ''.join('<entry name="{}:{}"><description>{}</description></entry>'.format(app.config['HOST'], app.config['PORT'], gw)
for gw in gateways)
if portal_cookie:
val = session[portal_cookie] = 'portal-cookie-%d' % randint(1, 10)
pc = '<{0}>{1}</{0}>'.format(portal_cookie, val)
else:
pc = ''

return '''<?xml version="1.0" encoding="UTF-8" ?>
<policy><gateways><external><list>{}</list></external></gateways>
<hip-collection><hip-report-interval>600</hip-report-interval></hip-collection>
</policy>'''.format(gwlist)
{}</policy>'''.format(gwlist, pc)


# Respond to gateway login request
@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 gw_2fa and not inputStr:

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')
if not (request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr')):
return 'Invalid username or password', 512
session.update(step='gateway-login', user=request.form.get('user'), passwd=request.form.get('passwd'), inputStr=None)
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)

for k, v in (('jnlpReady', 'jnlpReady'), ('ok', 'Login'), ('direct', 'yes'), ('clientVer', '4100'), ('prot', 'https:')):
if request.form.get(k) != v:
Expand All @@ -154,6 +206,8 @@ def gateway_login():
preferred_ipv6 = None
session.update(preferred_ip=preferred_ip, portal=portal, auth=auth, domain=domain, computer=request.form.get('computer'),
ipv6_support=request.form.get('ipv6-support'), preferred_ipv6=preferred_ipv6)
session.setdefault('portal-prelogonuserauthcookie', '')
session.setdefault('portal-userauthcookie', '')
session['authcookie'] = cookify(dict(session)).decode()

return '''<?xml version="1.0" encoding="utf-8"?> <jnlp> <application-desc>
Expand All @@ -173,8 +227,8 @@ def gateway_login():
<argument>-1</argument>
<argument>4100</argument>
<argument>{preferred_ip}</argument>
<argument/>
<argument/>
<argument>{portal-userauthcookie}</argument>
<argument>{portal-prelogonuserauthcookie}</argument>
<argument>{ipv6}</argument>
</application-desc></jnlp>'''.format(ipv6=preferred_ipv6 or '', **session)

Expand Down
22 changes: 21 additions & 1 deletion tests/gp-auth-and-config
Expand Up @@ -53,6 +53,26 @@ echo -n "Authenticating with username/password via gateway... "

echo ok

echo -n "Authenticating with username/password/token via portal, then using portal-userauthcookie to continue through gateway... "
( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q "$ADDRESS:443/global-protect/testconfig.esp?portal_2fa=1&gateway_2fa=1&portal_cookie=portal-userauthcookie" -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
fail $PID "Could not receive cookie from fake GlobalProtect server"

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"
Expand All @@ -63,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
Expand Down
1 change: 1 addition & 0 deletions www/changelog.xml
Expand Up @@ -46,6 +46,7 @@
<a href="https://gitlab.gnome.org/GNOME/NetworkManager-openconnect/-/issues/46">#46</a>,
<a href="https://gitlab.gnome.org/GNOME/NetworkManager-openconnect/-/issues/53">#53</a>)</i></li>
<li>Disable brittle "system policy" enforcement where it cannot be gracefully overridden at user request. <a href="https://bugzilla.redhat.com/show_bug.cgi?id=1960763"><i>(RH#1960763)</i></a>.</li>
<li>Pass "portal cookie" fields from GlobalProtect portal to gateway to avoid repetition of password- or SAML-based login (<a href="https://gitlab.com/openconnect/openconnect/-/merge_requests/199">!199</a>)</li>
</ul><br/>
</li>
<li><b><a href="https://www.infradead.org/openconnect/download/openconnect-8.10.tar.gz">OpenConnect v8.10</a></b>
Expand Down

0 comments on commit b732fff

Please sign in to comment.