From 1ffd06b33701c82d8f50e2605a83c92a48dd40fe Mon Sep 17 00:00:00 2001 From: Murat Mukhtarov Date: Wed, 25 Aug 2021 19:50:42 +1000 Subject: [PATCH 1/2] Add JumpCloud Protect (PUSH) MFA support JumpCloud have released a new app that they call [JumpCloud Protect](https://jumpcloud.com/press/introducing-jumpcloud-protect-free-mobile-multi-factor-authentication). It is a free alternative to DUO for JumpCoud users that provides "push" second factor. It would be useful to add this in if possible (we are planning to use it). --- pkg/provider/jumpcloud/jumpcloud.go | 31 +++--- pkg/provider/jumpcloud/jumpcloud_protect.go | 103 ++++++++++++++++++++ pkg/provider/jumpcloud/utils.go | 17 ++++ saml2aws.go | 2 +- 4 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 pkg/provider/jumpcloud/jumpcloud_protect.go create mode 100644 pkg/provider/jumpcloud/utils.go diff --git a/pkg/provider/jumpcloud/jumpcloud.go b/pkg/provider/jumpcloud/jumpcloud.go index 5f1b9abbd..a53df4e23 100644 --- a/pkg/provider/jumpcloud/jumpcloud.go +++ b/pkg/provider/jumpcloud/jumpcloud.go @@ -23,22 +23,25 @@ import ( ) const ( - jcSSOBaseURL = "https://sso.jumpcloud.com/" - xsrfURL = "https://console.jumpcloud.com/userconsole/xsrf" - authSubmitURL = "https://console.jumpcloud.com/userconsole/auth" - webauthnSubmitURL = "https://console.jumpcloud.com/userconsole/auth/webauthn" - duoAuthSubmitURL = "https://console.jumpcloud.com/userconsole/auth/duo" - - IdentifierTotpMfa = "totp" - IdentifierDuoMfa = "duo" - IdentifierU2F = "webauthn" + jcSSOBaseURL = "https://sso.jumpcloud.com/" + xsrfURL = "https://console.jumpcloud.com/userconsole/xsrf" + authSubmitURL = "https://console.jumpcloud.com/userconsole/auth" + webauthnSubmitURL = "https://console.jumpcloud.com/userconsole/auth/webauthn" + duoAuthSubmitURL = "https://console.jumpcloud.com/userconsole/auth/duo" + jumpCloudProtectSubmitURL = "https://console.jumpcloud.com/userconsole/auth/push" + + IdentifierTotpMfa = "totp" + IdentifierDuoMfa = "duo" + IdentifierU2F = "webauthn" + IdentifierJumpCloudProtect = "push" ) var ( supportedMfaOptions = map[string]string{ - IdentifierTotpMfa: "TOTP MFA authentication", - IdentifierDuoMfa: "DUO MFA authentication", - IdentifierU2F: "FIDO WebAuthn authentication", + IdentifierTotpMfa: "TOTP MFA authentication", + IdentifierDuoMfa: "DUO MFA authentication", + IdentifierU2F: "FIDO WebAuthn authentication", + IdentifierJumpCloudProtect: "PUSH MFA authentication (JumpCloud Protect)", } ) @@ -310,6 +313,9 @@ func (jc *Client) verifyMFA(jumpCloudOrgHost string, loginDetails *creds.LoginDe req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") return jc.client.Do(req) + + case IdentifierJumpCloudProtect: + return jc.jumpCloudProtectAuth(xsrfToken) case IdentifierDuoMfa: // Get Duo config req, err := http.NewRequest("GET", duoAuthSubmitURL, nil) @@ -577,7 +583,6 @@ func (jc *Client) verifyMFA(jumpCloudOrgHost string, loginDetails *creds.LoginDe } func (jc *Client) getUserOption(body []byte) (string, error) { - // data =>map[factors:[map[status:available type:totp] map[status:available type:webauthn]] message:MFA required.] mfaConfigData := gjson.GetBytes(body, "factors") if mfaConfigData.Index == 0 { log.Fatalln("Mfa Config option not found") diff --git a/pkg/provider/jumpcloud/jumpcloud_protect.go b/pkg/provider/jumpcloud/jumpcloud_protect.go new file mode 100644 index 000000000..082069488 --- /dev/null +++ b/pkg/provider/jumpcloud/jumpcloud_protect.go @@ -0,0 +1,103 @@ +package jumpcloud + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "time" + + "github.com/pkg/errors" +) + +func (jc *Client) jumpCloudProtectAuth(xsrfToken string) (*http.Response, error) { + jumpCloudParsedURL, err := url.Parse(jumpCloudProtectSubmitURL) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("unable to parse submit url, url=%s", jumpCloudProtectSubmitURL)) + } + + req, err := http.NewRequest("POST", jumpCloudParsedURL.String(), emptyJSONIOReader()) + if err != nil { + return nil, errors.Wrap(err, "error building jumpcloud protect auth request") + } + ensureHeaders(xsrfToken, req) + + res, err := jc.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH configuration") + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, errors.New("error retrieving JumpCloud PUSH configuration, non 200 status returned") + } + + jpResp, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "error retrieving Duo configuration") + } + + type JP struct { + ID string `json:"id"` + ExpiresAt time.Time `json:"expiresAt"` + InitiatedAt time.Time `json:"initiatedAt"` + Status string `json:"status"` + UserId string `json:"userId"` + } + + jp := JP{} + if err := json.Unmarshal(jpResp, &jp); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal JumpCloud Protect configuration") + } + + jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, jp.ID) + req, err = http.NewRequest("GET", jumpCloudParsedURL.String(), nil) + + if err != nil { + return nil, errors.Wrap(err, "failed to build JumpCoud push polling request") + } + + ensureHeaders(xsrfToken, req) + + // Stay in the loop until we get something else other than "pending". + // jp.Status can be: + // * accepted + // * expired + // * denied + + for jp.Status == "pending" { + time.Sleep(100 * time.Millisecond) + + resp, err := jc.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error retrieving verify response") + } + defer resp.Body.Close() + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal JumpCloud Protect body") + } + + if err := json.Unmarshal(bytes, &jp); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal poll result json into struct") + } + } + + if jp.Status != "accepted" { + return nil, errors.New(fmt.Sprintf("didn't receive accepted, status=%s\n", jp.Status)) + } + + jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, "login") + req, err = http.NewRequest("POST", jumpCloudParsedURL.String(), emptyJSONIOReader()) + if err != nil { + return nil, errors.Wrap(err, "failed to build JumpCoud login request") + } + + ensureHeaders(xsrfToken, req) + + return jc.client.Do(req) +} diff --git a/pkg/provider/jumpcloud/utils.go b/pkg/provider/jumpcloud/utils.go new file mode 100644 index 000000000..ffd075725 --- /dev/null +++ b/pkg/provider/jumpcloud/utils.go @@ -0,0 +1,17 @@ +package jumpcloud + +import ( + "bytes" + "io" + "net/http" +) + +func ensureHeaders(xsrfToken string, req *http.Request) { + req.Header.Add("X-Xsrftoken", xsrfToken) + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") +} + +func emptyJSONIOReader() io.Reader { + return bytes.NewReader([]byte(`{}`)) +} diff --git a/saml2aws.go b/saml2aws.go index 861f8c104..010c42aca 100644 --- a/saml2aws.go +++ b/saml2aws.go @@ -36,7 +36,7 @@ var MFAsByProvider = ProviderList{ "ADFS2": []string{"Auto", "RSA"}, // nothing automatic about ADFS 2.x "Ping": []string{"Auto"}, // automatically detects PingID "PingOne": []string{"Auto"}, // automatically detects PingID - "JumpCloud": []string{"Auto", "TOTP", "WEBAUTHN", "DUO"}, + "JumpCloud": []string{"Auto", "TOTP", "WEBAUTHN", "DUO", "PUSH"}, "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 "KeyCloak": []string{"Auto"}, // automatically detects ToTP From 30b40cbfb2e69a5f6241afd6155b76f647c941d4 Mon Sep 17 00:00:00 2001 From: Murat Mukhtarov Date: Wed, 25 Aug 2021 22:10:53 +1000 Subject: [PATCH 2/2] Make it testable and basic tests for different cases --- pkg/provider/jumpcloud/jumpcloud.go | 2 +- pkg/provider/jumpcloud/jumpcloud_protect.go | 50 ++++--- .../jumpcloud/jumpcloud_protect_test.go | 139 ++++++++++++++++++ 3 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 pkg/provider/jumpcloud/jumpcloud_protect_test.go diff --git a/pkg/provider/jumpcloud/jumpcloud.go b/pkg/provider/jumpcloud/jumpcloud.go index a53df4e23..d9fd95502 100644 --- a/pkg/provider/jumpcloud/jumpcloud.go +++ b/pkg/provider/jumpcloud/jumpcloud.go @@ -315,7 +315,7 @@ func (jc *Client) verifyMFA(jumpCloudOrgHost string, loginDetails *creds.LoginDe return jc.client.Do(req) case IdentifierJumpCloudProtect: - return jc.jumpCloudProtectAuth(xsrfToken) + return jc.jumpCloudProtectAuth(jumpCloudProtectSubmitURL, xsrfToken) case IdentifierDuoMfa: // Get Duo config req, err := http.NewRequest("GET", duoAuthSubmitURL, nil) diff --git a/pkg/provider/jumpcloud/jumpcloud_protect.go b/pkg/provider/jumpcloud/jumpcloud_protect.go index 082069488..7becc0185 100644 --- a/pkg/provider/jumpcloud/jumpcloud_protect.go +++ b/pkg/provider/jumpcloud/jumpcloud_protect.go @@ -13,8 +13,16 @@ import ( "github.com/pkg/errors" ) -func (jc *Client) jumpCloudProtectAuth(xsrfToken string) (*http.Response, error) { - jumpCloudParsedURL, err := url.Parse(jumpCloudProtectSubmitURL) +type JumpCloudPushResponse struct { + ID string `json:"id"` + ExpiresAt time.Time `json:"expiresAt"` + InitiatedAt time.Time `json:"initiatedAt"` + Status string `json:"status"` + UserId string `json:"userId"` +} + +func (jc *Client) jumpCloudProtectAuth(submitUrl string, xsrfToken string) (*http.Response, error) { + jumpCloudParsedURL, err := url.Parse(submitUrl) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("unable to parse submit url, url=%s", jumpCloudProtectSubmitURL)) } @@ -27,41 +35,32 @@ func (jc *Client) jumpCloudProtectAuth(xsrfToken string) (*http.Response, error) res, err := jc.client.Do(req) if err != nil { - return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH configuration") + return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH payload") } defer res.Body.Close() if res.StatusCode != 200 { - return nil, errors.New("error retrieving JumpCloud PUSH configuration, non 200 status returned") + return nil, errors.New("error retrieving JumpCloud PUSH payload, non 200 status returned") } jpResp, err := ioutil.ReadAll(res.Body) if err != nil { - return nil, errors.Wrap(err, "error retrieving Duo configuration") - } - - type JP struct { - ID string `json:"id"` - ExpiresAt time.Time `json:"expiresAt"` - InitiatedAt time.Time `json:"initiatedAt"` - Status string `json:"status"` - UserId string `json:"userId"` + return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH payload") } - jp := JP{} + jp := JumpCloudPushResponse{} if err := json.Unmarshal(jpResp, &jp); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal JumpCloud Protect configuration") + return nil, errors.Wrap(err, "failed to unmarshal JumpCloud PUSH payload to struct") } jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, jp.ID) req, err = http.NewRequest("GET", jumpCloudParsedURL.String(), nil) + ensureHeaders(xsrfToken, req) if err != nil { - return nil, errors.Wrap(err, "failed to build JumpCoud push polling request") + return nil, errors.Wrap(err, "failed to build JumpCoud PUSH polling request") } - ensureHeaders(xsrfToken, req) - // Stay in the loop until we get something else other than "pending". // jp.Status can be: // * accepted @@ -69,26 +68,34 @@ func (jc *Client) jumpCloudProtectAuth(xsrfToken string) (*http.Response, error) // * denied for jp.Status == "pending" { - time.Sleep(100 * time.Millisecond) + if time.Now().UTC().After(jp.ExpiresAt) { + return nil, errors.New("the session is expired try again") + } resp, err := jc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "error retrieving verify response") } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.New(fmt.Sprintf("received non 200 http code, http code = %d", resp.StatusCode)) + } bytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrap(err, "failed to unmarshal JumpCloud Protect body") + return nil, errors.Wrap(err, "failed to unmarshal JumpCloud PUSH body") } if err := json.Unmarshal(bytes, &jp); err != nil { return nil, errors.Wrap(err, "failed to unmarshal poll result json into struct") } + + // sleep for 500ms before next request + time.Sleep(500 * time.Millisecond) } if jp.Status != "accepted" { - return nil, errors.New(fmt.Sprintf("didn't receive accepted, status=%s\n", jp.Status)) + return nil, errors.New(fmt.Sprintf("didn't receive accepted, status=%s", jp.Status)) } jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, "login") @@ -98,6 +105,5 @@ func (jc *Client) jumpCloudProtectAuth(xsrfToken string) (*http.Response, error) } ensureHeaders(xsrfToken, req) - return jc.client.Do(req) } diff --git a/pkg/provider/jumpcloud/jumpcloud_protect_test.go b/pkg/provider/jumpcloud/jumpcloud_protect_test.go new file mode 100644 index 000000000..53aac4c32 --- /dev/null +++ b/pkg/provider/jumpcloud/jumpcloud_protect_test.go @@ -0,0 +1,139 @@ +package jumpcloud + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/versent/saml2aws/v2/pkg/cfg" +) + +type test struct { + code int + err string + testCase string +} + +func Test_jumpCloudProtectAuth(t *testing.T) { + jumpCloudPushResp := JumpCloudPushResponse{ + ExpiresAt: time.Now().Add(1 * time.Minute).UTC(), + ID: "foo", + } + + pendingCnt := 1 + maxPending := 2 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // using token here as a clue to mock responses + switch token := r.Header.Get("X-Xsrftoken"); { + case token == "happy": + switch r.URL.Path { + case "/": + returnResp(t, "pending", 200, &jumpCloudPushResp, w) + case fmt.Sprintf("/%s", jumpCloudPushResp.ID): + returnResp(t, "accepted", 200, &jumpCloudPushResp, w) + case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID): + _, err := w.Write([]byte(`{}`)) + require.Nil(t, err) + } + + case token == "loop twice until accepted": + switch r.URL.Path { + case "/": + returnResp(t, "pending", 200, &jumpCloudPushResp, w) + case fmt.Sprintf("/%s", jumpCloudPushResp.ID): + pendingCnt += 1 + if pendingCnt == maxPending { + returnResp(t, "accepted", 200, &jumpCloudPushResp, w) + } else { + returnResp(t, "pending", 200, &jumpCloudPushResp, w) + } + case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID): + _, err := w.Write([]byte(`{}`)) + require.Nil(t, err) + } + + case token == "payload error": + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`{}`)) + require.Nil(t, err) + + case token == "received expired": + switch r.URL.Path { + case "/": + jumpCloudPushResp.Status = "pending" + bytes, err := json.Marshal(&jumpCloudPushResp) + require.Nil(t, err) + _, _ = w.Write(bytes) + case fmt.Sprintf("/%s", jumpCloudPushResp.ID): + returnResp(t, "expired", http.StatusOK, &jumpCloudPushResp, w) + } + + case token == "received denied": + switch r.URL.Path { + case "/": + jumpCloudPushResp.Status = "pending" + bytes, err := json.Marshal(&jumpCloudPushResp) + require.Nil(t, err) + _, _ = w.Write(bytes) + case fmt.Sprintf("/%s", jumpCloudPushResp.ID): + returnResp(t, "denied", http.StatusUnauthorized, &jumpCloudPushResp, w) + } + + case token == "login error": + switch r.URL.Path { + case "/": + jumpCloudPushResp.Status = "pending" + bytes, err := json.Marshal(&jumpCloudPushResp) + require.Nil(t, err) + _, _ = w.Write(bytes) + case fmt.Sprintf("/%s", jumpCloudPushResp.ID): + jumpCloudPushResp.Status = "accepted" + bytes, err := json.Marshal(&jumpCloudPushResp) + require.Nil(t, err) + _, _ = w.Write(bytes) + case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID): + w.WriteHeader(http.StatusInternalServerError) + } + } + })) + defer ts.Close() + + client, err := New(&cfg.IDPAccount{Provider: "JumpCloud", MFA: "PUSH"}) + require.Nil(t, err) + + tests := []test{ + {testCase: "happy", code: http.StatusOK}, + {testCase: "loop twice until accepted", code: http.StatusOK}, + {testCase: "login error", code: http.StatusInternalServerError}, + {testCase: "payload error", code: http.StatusInternalServerError, err: "error retrieving JumpCloud PUSH payload, non 200 status returned"}, + {testCase: "received expired", err: "didn't receive accepted, status=expired"}, + {testCase: "received denied", err: "received non 200 http code, http code = 401"}, + } + + for _, test := range tests { + t.Run(test.testCase, func(t *testing.T) { + resp, err := client.jumpCloudProtectAuth(ts.URL, test.testCase) + if test.err == "" { + require.Nil(t, err) + require.Equal(t, test.code, resp.StatusCode) + } else { + require.EqualError(t, err, test.err) + } + }) + } + + require.Equal(t, pendingCnt, maxPending) +} + +func returnResp(t *testing.T, status string, statusCode int, j *JumpCloudPushResponse, w http.ResponseWriter) { + j.Status = status + bytes, err := json.Marshal(j) + require.Nil(t, err) + w.WriteHeader(statusCode) + _, err = w.Write(bytes) + require.Nil(t, err) +}