Repository: Versent/saml2aws
Pull request #490 (2020-04-22 03:29:37)

add Privileged authentication option in NetIQ
Privileged authentication is skipping MFA whereas default authentation uses MFA, hence it's implemented as a MFA option
Privileged authentication uses a different login path than the default one.


Patch:
diff --git a/pkg/provider/netiq/README.md b/pkg/provider/netiq/README.md
new file mode 100644
index 0000000..52bedf2
--- /dev/null
+++ b/pkg/provider/netiq/README.md
@@ -0,0 +1,11 @@
+# MFA
+
+2 MFA options are supported: Auto and Privileged
+
+# Auto
+This is the default MFA option of NetIQ.
+
+# Privileged
+This corresponds to the privilege account authentication which skips MFA.
+MFA is actually skipped on server side.
+On client side, a different login URL is used for the privileged account.
diff --git a/pkg/provider/netiq/netiq.go b/pkg/provider/netiq/netiq.go
index f82792c..9a309bb 100644
--- a/pkg/provider/netiq/netiq.go
+++ b/pkg/provider/netiq/netiq.go
@@ -20,16 +20,17 @@ var logger = logrus.WithField("provider", "NetIQ")
 
 type Client struct {
 	client *provider.HTTPClient
+	MFA    string
 }
 
 // New creates a new external client
-func New(idpAccount *cfg.IDPAccount) (*Client, error) {
+func New(idpAccount *cfg.IDPAccount, mfa string) (*Client, error) {
 	tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
 	client, err := provider.NewHTTPClient(tr, provider.BuildHttpClientOpts(idpAccount))
 	if err != nil {
 		return nil, errors.Wrap(err, "Error building HTTP client")
 	}
-	return &Client{client: client}, nil
+	return &Client{client: client, MFA: mfa}, nil
 
 }
 
@@ -55,7 +56,11 @@ func (nc *Client) follow(req *http.Request, loginDetails *creds.LoginDetails) (s
 	if isSAMLResponse(doc) {
 		return extractSAMLAssertion(doc)
 	} else if resourcePath, isGetToContext := extractGetToContentUrl(doc); isGetToContext {
-		newReq, err := buildGetToContentRequest(loginDetails.URL + resourcePath + "&uiDestination=contentDiv")
+		loginUrl, err := getLoginUrl(nc.MFA, loginDetails.URL, resourcePath)
+		if err != nil {
+			return "", errors.Wrap(err, "MFA option unsupported. Valid MFA options are: Auto or Privileged")
+		}
+		newReq, err := buildGetToContentRequest(loginUrl + "&uiDestination=contentDiv")
 		if err != nil {
 			return "", errors.Wrap(err, "Error building request")
 		}
@@ -181,3 +186,16 @@ func logDocDetected(docType string, data string) {
 	}
 	logDetect.Debug("doc detect")
 }
+
+func getLoginUrl(mfa string, baseUrl string, defaultResourcePath string) (string, error) {
+	var loginUrl string
+	if mfa == "Auto" {
+		loginUrl = baseUrl + defaultResourcePath
+	} else if mfa == "Privileged" {
+		// Privileged account skip MFA and have different login URL
+		loginUrl = baseUrl + "/nidp/app/login?id=privacc&sid=0&option=credential"
+	} else {
+		return "", errors.New("Unsupported MFA")
+	}
+	return loginUrl, nil
+}
diff --git a/pkg/provider/netiq/responses/privileged_flow/idpLoginPass.html b/pkg/provider/netiq/responses/privileged_flow/idpLoginPass.html
new file mode 100755
index 0000000..6fc70d5
--- /dev/null
+++ b/pkg/provider/netiq/responses/privileged_flow/idpLoginPass.html
@@ -0,0 +1,234 @@
+<!DOCTYPE HTML>
+<html lang="en">
+
+<head>
+	<meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1">
+	<meta name="apple-itunes-app" content="app-id=686273579">
+	<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,user-scalable=yes">
+
+	<title>Access Manager</title>
+	<meta http-equiv="content-type" content="text/html; charset=UTF-8">
+
+	<link href="/nidp/css/ux_access.css" rel="stylesheet" type="text/css" media="screen" />
+	<script type="text/javascript" src="/nidp/javascript/jquery.min.js"></script>
+
+	<script type="text/javascript">
+		function isEnterKey(event)
+        {
+            return isKey(event, 13);
+        }
+
+        function isSpaceKey(event)
+        {
+            return isKey(event, 32);
+        }
+
+        function isKey(event, targetKeyCode)
+        {
+            var candidateKeyCode = (event.which) ? event.which : event.keyCode;                             
+            if (candidateKeyCode !=targetKeyCode)
+            {
+                return false;
+            } 
+            return true;
+        }
+	</script>
+
+	<script>
+		function safeSetGlobalMessage(strMessage)
+    {
+        var proofOfEndUserEnvironment= document.getElementById('runningInEndUserLoginEnvironment');
+        if (proofOfEndUserEnvironment)
+        {
+            setGlobalMessage(strMessage);
+        }
+    }
+
+    function submitForm()
+    {      	
+        var userNameEditBox = document.getElementById('Ecom_User_ID');
+        var passwordEditBox = document.getElementById('Ecom_Password');
+        if ((null != userNameEditBox) && (null != passwordEditBox))
+        {
+            if ((0 == userNameEditBox.value.length) || (0 == passwordEditBox.value.length))
+            {
+                safeSetGlobalMessage("Please enter values for the user name and password.");
+                userNameEditBox.focus();
+                return;
+            }
+
+          //if recaptcha is enabled just let the recaptcha code submit the document after verification 
+        	
+            
+            // Get some values from elements on the page:
+            var form = document.getElementById('IDPLogin');
+            var url = form.action;
+
+            safeSetGlobalMessage("Validating credentials...");
+
+            var proofOfEndUserEnvironment= document.getElementById('runningInEndUserLoginEnvironment');
+            if (proofOfEndUserEnvironment)
+            {
+                url = updateQueryString("uiDestination", "contentDiv", url);
+
+                // Send the data using post
+                var posting = $.post( url, $("#IDPLogin" ).serialize());
+ 
+                // Put the results in a div
+                posting.done(function( data )
+                {
+                    safeSetGlobalMessage("");
+                    $('#theNidpContent').html(data);
+                });
+
+                posting.fail(function()
+                {
+                    safeSetGlobalMessage("Error communicating with the identity server");
+                });
+            }
+            else
+            {
+                document.IDPLogin.submit();
+            }
+        }
+    }
+    
+    
+    function submitForm2() {
+    	// Get some values from elements on the page:
+        var form = document.getElementById('IDPLogin');
+        var url = form.action;
+
+        safeSetGlobalMessage("Validating credentials...");
+
+        var proofOfEndUserEnvironment= document.getElementById('runningInEndUserLoginEnvironment');
+        if (proofOfEndUserEnvironment)
+        {
+            url = updateQueryString("uiDestination", "contentDiv", url);
+
+            // Send the data using post
+            var posting = $.post( url, $("#IDPLogin" ).serialize());
+
+            // Put the results in a div
+            posting.done(function( data )
+            {
+                safeSetGlobalMessage("");
+                $('#theNidpContent').html(data);
+            });
+
+            posting.fail(function()
+            {
+                safeSetGlobalMessage("Error communicating with the identity server");
+            });
+        }
+        else
+        {
+            document.IDPLogin.submit();
+        }
+    }
+
+    function onLoadFocus()
+    {
+        if (document.IDPLogin.Ecom_User_ID.value)
+        {
+            document.IDPLogin.Ecom_Password.focus();
+        }
+        else
+        {
+            document.IDPLogin.Ecom_User_ID.focus();
+        }
+    }
+
+    $(document).ready(function(){
+    	
+        onLoadFocus();
+        
+        $( "#loginButton2" ).click(function()
+	    {
+    		submitForm();
+ 	    });
+	
+	    $( "#loginButton2" ).keyup(function(event)
+	    {
+	        if (isEnterKey(event) || isSpaceKey(event))
+	        {
+	            submitForm();
+	        }
+	    });
+	
+	    $("#Ecom_User_ID").keyup(function(event)
+	    {
+	        if (isEnterKey(event))
+	        {
+	            if (document.IDPLogin.Ecom_User_ID.value)
+	            {
+	                $("#loginButton2").click();
+	                return false;
+	            }
+	        }
+	        return true;
+	    });
+	
+	    $("#Ecom_Password").keyup(function(event)
+	    {
+	        if (isEnterKey(event))
+	        {
+	            if (document.IDPLogin.Ecom_User_ID.value)
+	            {
+	                $("#loginButton2").click();
+	                return false;
+	            }
+	        }
+	        return true;
+	    });
+    });
+
+	</script>
+
+</head>
+
+<body>
+
+
+
+	<form id="IDPLogin" name="IDPLogin" enctype="application/x-www-form-urlencoded" method="POST"
+		action="https://login.authbridge.somegroup.com/nidp/app/login?sid=0&sid=0" AUTOCOMPLETE="off">
+		<input type="hidden" name="option" value="credential">
+
+
+		<div id="namepassword" class="content login-page" style="margin: 25px; display: block;">
+			<div class="signin-div">
+				<div class="input-signin-username">
+					<input type="text" id="Ecom_User_ID" name="Ecom_User_ID" value="" tabindex="1" placeholder="Username"></div>
+				</div>
+				<div class="signin-div">
+					<div class="input-signin-password">
+						<input type="password" id="Ecom_Password" name="Ecom_Password" tabindex="2" placeholder="Password"></div>
+					</div>
+
+
+
+
+
+					<div class="closure-but signin-div">
+						<span class="nam-signin-but action-but" name="loginButton2" id="loginButton2" tabindex="3">Sign in</span>
+					</div>
+				</div>
+
+				<table border=0 style="margin-top: 1em" width="100%" cellspacing="0" cellpadding="0">
+
+
+
+				</table>
+	</form>
+
+
+
+
+
+
+
+
+</body>
+
+</html>
diff --git a/saml2aws.go b/saml2aws.go
index 5a4949f..6498986 100644
--- a/saml2aws.go
+++ b/saml2aws.go
@@ -36,14 +36,14 @@ var MFAsByProvider = ProviderList{
 	"PingOne":       []string{"Auto"},        // automatically detects PingID
 	"JumpCloud":     []string{"Auto"},
 	"Okta":          []string{"Auto", "PUSH", "DUO", "SMS", "TOTP", "OKTA", "FIDO", "YUBICO TOKEN:HARDWARE"}, // automatically detects DUO, SMS, ToTP, and FIDO
-	"OneLogin":      []string{"Auto", "OLP", "SMS", "TOTP" ,"YUBIKEY"},                                       // automatically detects OneLogin Protect, SMS and ToTP
+	"OneLogin":      []string{"Auto", "OLP", "SMS", "TOTP", "YUBIKEY"},                                       // automatically detects OneLogin Protect, SMS and ToTP
 	"KeyCloak":      []string{"Auto"},                                                                        // automatically detects ToTP
 	"GoogleApps":    []string{"Auto"},                                                                        // automatically detects ToTP
 	"Shibboleth":    []string{"Auto"},
 	"F5APM":         []string{"Auto"},
 	"Akamai":        []string{"Auto", "DUO", "SMS", "EMAIL", "TOTP"},
 	"ShibbolethECP": []string{"auto", "phone", "push", "passcode"},
-	"NetIQ":         []string{"Auto"},
+	"NetIQ":         []string{"Auto", "Privileged"},
 }
 
 // Names get a list of provider names
@@ -162,7 +162,10 @@ func NewSAMLClient(idpAccount *cfg.IDPAccount) (SAMLClient, error) {
 	case "Shell":
 		return shell.New(idpAccount)
 	case "NetIQ":
-		return netiq.New(idpAccount)
+		if invalidMFA(idpAccount.Provider, idpAccount.MFA) {
+			return nil, fmt.Errorf("Invalid MFA type: %v for %v provider", idpAccount.MFA, idpAccount.Provider)
+		}
+		return netiq.New(idpAccount, idpAccount.MFA)
 	default:
 		return nil, fmt.Errorf("Invalid provider: %v", idpAccount.Provider)
 	}


Test patch:
diff --git a/pkg/provider/netiq/netiq_test.go b/pkg/provider/netiq/netiq_test.go
index e37473d..dd86366 100644
--- a/pkg/provider/netiq/netiq_test.go
+++ b/pkg/provider/netiq/netiq_test.go
@@ -165,6 +165,26 @@ func TestExtractIDPLoginPassNegative(t *testing.T) {
 	require.Nil(t, actualResourceUrl)
 }
 
+func TestExtractPrivilegedIDPLoginPassPositive(t *testing.T) {
+	//given
+	idpLoginPassData, err := ioutil.ReadFile("responses/privileged_flow/idpLoginPass.html")
+	require.Nil(t, err)
+	expectedForm := &page.Form{
+		URL:    "https://login.authbridge.somegroup.com/nidp/app/login?sid=0&sid=0",
+		Method: "POST",
+		Values: &url.Values{},
+	}
+
+	//when
+	idpLoginPassDoc, err := goquery.NewDocumentFromReader(bytes.NewReader(idpLoginPassData))
+	require.Nil(t, err)
+	actualForm, ok := extractIDPLoginPass(idpLoginPassDoc)
+
+	//then
+	require.True(t, ok)
+	require.Equal(t, expectedForm, actualForm)
+}
+
 func TestExtractIDPLoginRsaPositive(t *testing.T) {
 	//given
 	idpLoginRsaData, err := ioutil.ReadFile("responses/idpLoginRsa.html")
@@ -199,3 +219,62 @@ func TestExtractIDPLoginRsaNegative(t *testing.T) {
 	require.False(t, ok)
 	require.Nil(t, actualResourceUrl)
 }
+
+func TestExtractPrivilegedIDPLoginRsaNegative(t *testing.T) {
+	//given
+	idpLoginPassData, err := ioutil.ReadFile("responses/privileged_flow/idpLoginPass.html")
+	require.Nil(t, err)
+
+	//when
+	idpLoginPassDoc, err := goquery.NewDocumentFromReader(bytes.NewReader(idpLoginPassData))
+	require.Nil(t, err)
+	actualResourceUrl, ok := extractIDPLoginRsa(idpLoginPassDoc)
+
+	//then
+	require.False(t, ok)
+	require.Nil(t, actualResourceUrl)
+}
+
+func TestPrivilegedLoginUrl(t *testing.T) {
+	//given
+	mfa := "Privileged"
+	baseUrl := "https://abc.com"
+	defaultResourcePath := "/login.html"
+	expectedLoginUrl := baseUrl + "/nidp/app/login?id=privacc&sid=0&option=credential"
+
+	//when
+	loginUrl, err := getLoginUrl(mfa, baseUrl, defaultResourcePath)
+
+	//then
+	require.Nil(t, err)
+	require.Equal(t, loginUrl, expectedLoginUrl)
+}
+
+func TestDefaultLoginUrl(t *testing.T) {
+	//given
+	mfa := "Auto"
+	baseUrl := "https://abc.com"
+	defaultResourcePath := "/login.html"
+	expectedLoginUrl := baseUrl + defaultResourcePath
+
+	//when
+	loginUrl, err := getLoginUrl(mfa, baseUrl, defaultResourcePath)
+
+	//then
+	require.Nil(t, err)
+	require.Equal(t, loginUrl, expectedLoginUrl)
+}
+
+func TestUnsupportedMFA(t *testing.T) {
+	//given
+	mfa := "None"
+	baseUrl := "https://abc.com"
+	defaultResourcePath := "/login.html"
+	expectedErrorString := "Unsupported MFA"
+
+	//when
+	_, err := getLoginUrl(mfa, baseUrl, defaultResourcePath)
+
+	//then
+	require.EqualError(t, err, expectedErrorString)
+}