Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
GlobalProtect: query and parse prelogin.esp and use it to build auth …
…forms, including preliminary SAML support

Until recently, I've believed the prelogin.esp to be useless, because the
initial GlobalProtect login form always contains the same two fields:
username and password.

However, the prelogin response is also important for signalling when SAML
login is required.  When the VPN uses SAML login, the official GP clients
redirect the user to a web-based authentication flow (e.g.  Okta,
dlenski/openconnect#116).

That auth flow eventually sends the official client back to the GP VPN,
armed with a special cookie field, `portal-userauthcookie` or
`prelogin-cookie`, that needs to be submitted in place of the password
(already supported by openconnect as of 8b2bc5f).

This preliminary SAML support simply includes the SAML method and URL in the
form banner, and fails with an error message if the cookie field name was
not specified (since it cannot be autodetected).

Signed-off-by: Daniel Lenski <dlenski@gmail.com>
  • Loading branch information
dlenski committed Sep 30, 2018
1 parent 227526e commit 3e91f7b
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 66 deletions.
237 changes: 171 additions & 66 deletions auth-globalprotect.c
Expand Up @@ -24,6 +24,12 @@

#include "openconnect-internal.h"

struct login_context {
char *username; /* Username that has already succeeded in some form */
char *alt_secret; /* Alternative secret (DO NOT FREE) */
struct oc_auth_form *form;
};

void gpst_common_headers(struct openconnect_info *vpninfo,
struct oc_text_buf *buf)
{
Expand All @@ -35,45 +41,98 @@ void gpst_common_headers(struct openconnect_info *vpninfo,
vpninfo->useragent = orig_ua;
}

/* The GlobalProtect auth form always has two visible fields:
/* Parse pre-login response ({POST,GET} /{global-protect,ssl-vpn}/pre-login.esp)
*
* Extracts the relevant arguments from the XML (username-label, password-label)
* and uses them to build an auth form, which always has two visible fields:
*
* 1) username
* 2) one secret value:
* - normal account password
* - "challenge" (2FA) password, along with form name in auth_id
* - cookie from external authentication flow (INSTEAD OF password)
* - cookie from external authentication flow ("alternative secret" INSTEAD OF password)
*
* This function steals the value of username for
* use in the auth form; pw_or_cookie_field is NOT stolen.
*/
static struct oc_auth_form *new_auth_form(struct openconnect_info *vpninfo,
char *prompt, char *auth_id, char *username, char *pw_or_cookie_field)
static int parse_prelogin_xml(struct openconnect_info *vpninfo, xmlNode *xml_node, void *cb_data)
{
struct oc_auth_form *form;
struct login_context *ctx = cb_data;
struct oc_auth_form *form = ctx->form;
struct oc_form_opt *opt, *opt2;
char *prompt = NULL, *username_label = NULL, *password_label = NULL;
char *saml_method = NULL, *saml_path = NULL;
int result = 0;

form = calloc(1, sizeof(*form));
if (!form)
return NULL;
form->message = strdup(prompt ? : _("Please enter your username and password"));
form->auth_id = strdup(auth_id ? : "_login");
if (!xmlnode_is_named(xml_node, "prelogin-response"))
goto out;

opt = form->opts = calloc(1, sizeof(*opt));
if (!opt) {
for (xml_node = xml_node->children; xml_node; xml_node = xml_node->next) {
char *s = NULL;
if (!xmlnode_get_val(xml_node, "saml-request", &s)) {
int len;
saml_path = openconnect_base64_decode(&len, s);
if (len < 0) {
vpn_progress(vpninfo, PRG_ERR, "Could not decode SAML request as base64: %s\n", s);
free(s);
result = -EINVAL;
goto out;
}
free(s);
saml_path = realloc(saml_path, len+1);
saml_path[len] = '\0';
} else {
xmlnode_get_val(xml_node, "saml-auth-method", &saml_method);
xmlnode_get_val(xml_node, "authentication-message", &prompt);
xmlnode_get_val(xml_node, "username-label", &username_label);
xmlnode_get_val(xml_node, "password-label", &password_label);
/* XX: should we save the certificate username from <ccusername/> ? */
}
}

/* XX: Alt-secret form field must be specified for SAML, because we can't autodetect it */
if ((saml_method || saml_path) && !ctx->alt_secret) {
vpn_progress(vpninfo, PRG_ERR, "SAML authentication via %s to %s is required.\n"
"Must specify destination form field by appending :field_name to login URL.\n",
saml_method, saml_path);
result = -EINVAL;
}

/* Replace old form */
free_auth_form(ctx->form);
form = ctx->form = calloc(1, sizeof(*form));
if (!form) {
nomem:
free_auth_form(form);
return NULL;
result = -ENOMEM;
goto out;
}
if (saml_path && asprintf(&form->banner, _("SAML login is required via %s to this URL:\n\t%s"), saml_method, saml_path) == 0)
goto nomem;
form->message = prompt ? : strdup(_("Please enter your username and password"));
prompt = NULL;
form->auth_id = strdup("_login");

/* First field (username) */
opt = form->opts = calloc(1, sizeof(*opt));
if (!opt)
goto nomem;
opt->name = strdup("user");
opt->label = strdup(_("Username: "));
opt->type = username ? OC_FORM_OPT_HIDDEN : OC_FORM_OPT_TEXT;
opt->_value = username;
if (asprintf(&opt->label, "%s: ", username_label ? : _("Username")) == 0)
goto nomem;
if (!ctx->username)
opt->type = OC_FORM_OPT_TEXT;
else {
opt->type = OC_FORM_OPT_HIDDEN;
opt->_value = ctx->username;
ctx->username = NULL;
}

/* Second field (secret) */
opt2 = opt->next = calloc(1, sizeof(*opt));
if (!opt2)
goto nomem;
opt2->name = strdup(pw_or_cookie_field ? : "passwd");
if (asprintf(&opt2->label, "%s: ", auth_id ? _("Challenge") : (pw_or_cookie_field ? : _("Password"))) == 0)
return NULL;
opt2->name = strdup(ctx->alt_secret ? : "passwd");
if (asprintf(&opt2->label, "%s: ", ctx->alt_secret ? : password_label ? : _("Password")) == 0)
goto nomem;

/* XX: Some VPNs use a password in the first form, followed by a
* a token in the second ("challenge") form. Others use only a
Expand All @@ -83,26 +142,51 @@ static struct oc_auth_form *new_auth_form(struct openconnect_info *vpninfo,
else
opt2->type = OC_FORM_OPT_PASSWORD;

return form;
vpn_progress(vpninfo, PRG_TRACE, "%s%s: \"%s\" %s(%s)=%s, \"%s\" %s(%s)\n",
form->auth_id[0] == '_' ? "Login form" : "Challenge form ",
form->auth_id[0] != '_' ? form->auth_id : "",
opt->label, opt->name, opt->type == OC_FORM_OPT_TEXT ? "TEXT" : "HIDDEN", opt->_value,
opt2->label, opt2->name, opt2->type == OC_FORM_OPT_PASSWORD ? "PASSWORD" : "TOKEN");

out:
free(prompt);
free(username_label);
free(password_label);
free(saml_method);
free(saml_path);
return result;
}

/* Callback function to create a new form from a challenge
*
*/
static int challenge_cb(struct openconnect_info *vpninfo, char *prompt, char *inputStr, void *cb_data)
{
struct oc_auth_form **out_form = cb_data;
struct oc_auth_form *form = *out_form;
char *username;
struct login_context *ctx = cb_data;
struct oc_auth_form *form = ctx->form;
struct oc_form_opt *opt = form->opts, *opt2 = form->opts->next;

/* Steal and reuse username from existing form */
username = form->opts ? form->opts->_value : NULL;
form->opts->_value = NULL;
free_auth_form(form);
form = *out_form = new_auth_form(vpninfo, prompt, inputStr, username, NULL);
if (!form)
/* Replace prompt, inputStr, and password prompt;
* clear password field, and make user field hidden.
*/
free(form->message);
free(form->auth_id);
free(opt2->label);
free(opt2->_value);
opt2->_value = NULL;
opt->type = OC_FORM_OPT_HIDDEN;

if ( !(form->message = strdup(prompt))
|| !(form->auth_id = strdup(inputStr))
|| !(opt2->label = strdup(_("Challenge: "))) )
return -ENOMEM;

vpn_progress(vpninfo, PRG_TRACE, "%s%s: \"%s\" %s(%s)=%s, \"%s\" %s(%s)\n",
form->auth_id[0] == '_' ? "Login form" : "Challenge form ",
form->auth_id[0] != '_' ? form->auth_id : "",
opt->label, opt->name, opt->type == OC_FORM_OPT_TEXT ? "TEXT" : "HIDDEN", opt->_value,
opt2->label, opt2->name, opt2->type == OC_FORM_OPT_PASSWORD ? "PASSWORD" : "TOKEN");

return -EAGAIN;
}

Expand Down Expand Up @@ -349,14 +433,12 @@ static int parse_portal_xml(struct openconnect_info *vpninfo, xmlNode *xml_node,
/* Main login entry point
*
* portal: 0 for gateway login, 1 for portal login
* pw_or_cookie_field: "alternate secret" field (see new_auth_form)
* alt_secret: "alternate secret" field (see new_auth_form)
*
*/
static int gpst_login(struct openconnect_info *vpninfo, int portal, char *pw_or_cookie_field)
static int gpst_login(struct openconnect_info *vpninfo, int portal, struct login_context *ctx)
{
int result;

struct oc_auth_form *form = NULL;
int result, blind_retry = 0;
struct oc_text_buf *request_body = buf_alloc();
const char *request_body_type = "application/x-www-form-urlencoded";
char *xml_buf = NULL, *orig_path;
Expand All @@ -370,21 +452,29 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, char *pw_or_
}
#endif


/* Ask the user to fill in the auth form; repeat as necessary */
for (;;) {
if (!form)
form = new_auth_form(vpninfo, NULL, NULL, NULL, pw_or_cookie_field);
if (!form)
return -ENOMEM;
/* submit prelogin request to get form */
orig_path = vpninfo->urlpath;
vpninfo->urlpath = strdup(portal ? "global-protect/prelogin.esp?tmp=tmp&clientVer=4100&clientos=Windows" : "ssl-vpn/prelogin.esp");
result = do_https_request(vpninfo, "POST", NULL, NULL, &xml_buf, 0);
free(vpninfo->urlpath);
vpninfo->urlpath = orig_path;

if (result >= 0)
result = gpst_xml_or_error(vpninfo, xml_buf, parse_prelogin_xml, NULL, ctx);
if (result)
goto out;

got_form:
/* process auth form */
result = process_auth_form(vpninfo, form);
result = process_auth_form(vpninfo, ctx->form);
if (result)
goto out;

replay_form:
/* generate token code if specified */
result = do_gen_tokencode(vpninfo, form);
result = do_gen_tokencode(vpninfo, ctx->form);
if (result) {
vpn_progress(vpninfo, PRG_ERR, _("Failed to generate OTP tokencode; disabling token\n"));
vpninfo->token_bypassed = 1;
Expand All @@ -399,9 +489,9 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, char *pw_or_
append_opt(request_body, "computer", vpninfo->localname);
if (vpninfo->ip_info.addr)
append_opt(request_body, "preferred-ip", vpninfo->ip_info.addr);
if (form->auth_id && form->auth_id[0]!='_')
append_opt(request_body, "inputStr", form->auth_id);
append_form_opts(vpninfo, form, request_body);
if (ctx->form->auth_id && ctx->form->auth_id[0]!='_')
append_opt(request_body, "inputStr", ctx->form->auth_id);
append_form_opts(vpninfo, ctx->form, request_body);
if ((result = buf_error(request_body)))
goto out;

Expand All @@ -415,32 +505,46 @@ static int gpst_login(struct openconnect_info *vpninfo, int portal, char *pw_or_
/* Result could be either a JavaScript challenge or XML */
if (result >= 0)
result = gpst_xml_or_error(vpninfo, xml_buf, portal ? parse_portal_xml : parse_login_xml,
challenge_cb, &form);
challenge_cb, ctx);
if (result == -EACCES) {
/* Invalid username/password; reuse same form, but blank,
* unless we just did a blind retry.
*/
nuke_opt_values(ctx->form->opts);
if (!blind_retry)
goto got_form;
else
blind_retry = 0;
} else {
/* Save successful username */
if (!ctx->username)
ctx->username = strdup(ctx->form->opts->_value);
if (result == -EAGAIN) {
/* New form is already populated from the challenge */
continue;
} else if (result == -EACCES) {
/* Invalid username/password; reuse same form, but blank */
nuke_opt_values(form->opts);
goto got_form;
} else if (portal && result == 0) {
/* Portal login succeeded; now login to gateway */
/* Portal login succeeded; blindly retry same credentials on gateway,
* unless it was a challenge auth form or alt-secret form.
*/
portal = 0;
free_auth_form(form);
form = NULL;
if (ctx->form->auth_id[0] == '_' && ctx->alt_secret) {
blind_retry = 1;
goto replay_form;
}
} else
break;
}
}

out:
free_auth_form(form);
buf_free(request_body);
free(xml_buf);
return result;
}

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

/* An alternate password/secret field may be specified in the "URL path" (or --usergroup).
Expand All @@ -449,29 +553,30 @@ int gpst_obtain_cookie(struct openconnect_info *vpninfo)
* /gateway:prelogin-cookie
*/
if (vpninfo->urlpath
&& (pw_or_cookie_field = strrchr(vpninfo->urlpath, ':'))!=NULL) {
*pw_or_cookie_field = '\0';
pw_or_cookie_field++;
&& (ctx.alt_secret = strrchr(vpninfo->urlpath, ':')) != NULL) {
*(ctx.alt_secret) = '\0';
ctx.alt_secret = strdup(ctx.alt_secret+1);
}

if (vpninfo->urlpath && (!strcmp(vpninfo->urlpath, "portal") || !strncmp(vpninfo->urlpath, "global-protect", 14))) {
/* assume the server is a portal */
return gpst_login(vpninfo, 1, pw_or_cookie_field);
result = gpst_login(vpninfo, 1, &ctx);
} else if (vpninfo->urlpath && (!strcmp(vpninfo->urlpath, "gateway") || !strncmp(vpninfo->urlpath, "ssl-vpn", 7))) {
/* assume the server is a gateway */
return gpst_login(vpninfo, 0, pw_or_cookie_field);
result = gpst_login(vpninfo, 0, &ctx);
} else {
/* first try handling it as a gateway, then a portal */
result = gpst_login(vpninfo, 0, pw_or_cookie_field);
result = gpst_login(vpninfo, 0, &ctx);
if (result == -EEXIST) {
/* XX: Don't we want to start by trying the same username/password the user just
entered for the 'gateway' attempt? */
result = gpst_login(vpninfo, 1, pw_or_cookie_field);
result = gpst_login(vpninfo, 1, &ctx);
if (result == -EEXIST)
vpn_progress(vpninfo, PRG_ERR, _("Server is neither a GlobalProtect portal nor a gateway.\n"));
}
return result;
}
free(ctx.username);
free(ctx.alt_secret);
free_auth_form(ctx.form);
return result;
}

int gpst_bye(struct openconnect_info *vpninfo, const char *reason)
Expand Down
18 changes: 18 additions & 0 deletions gpst.c
Expand Up @@ -248,6 +248,24 @@ int gpst_xml_or_error(struct openconnect_info *vpninfo, char *response,
goto bad_xml;
}

/* Is it <prelogin-response><status>Error</status><msg>..</msg></prelogin-response> ? */
if (xmlnode_is_named(xml_node, "prelogin-response")) {
char *s = NULL;
int has_err = 0;
xmlNode *x;
for (x=xml_node->children; x; x=x->next) {
if (!xmlnode_get_val(x, "status", &s))
has_err = strcmp(s, "Success");
else
xmlnode_get_val(x, "msg", &err);
}
free(s);
if (has_err)
goto out;
free(err);
err = NULL;
}

/* is it <challenge><user>user.name</user><inputstr>...</inputstr><respmsg>...</respmsg></challenge> */
if (xmlnode_is_named(xml_node, "challenge")) {
for (xml_node=xml_node->children; xml_node; xml_node=xml_node->next) {
Expand Down

0 comments on commit 3e91f7b

Please sign in to comment.