From 30c17719bca36f07971f8ae5bcd4ca2706263fea Mon Sep 17 00:00:00 2001 From: "Casale, Robert" Date: Wed, 23 Mar 2022 15:40:44 -0400 Subject: [PATCH] feat: add pingntlm provider support for pre-authentication issue --- .../pingntlm/exmaple/form-redirect.html | 21 ++ pkg/provider/pingntlm/exmaple/login.html | 113 +++++++ pkg/provider/pingntlm/exmaple/login2.html | 158 +++++++++ pkg/provider/pingntlm/exmaple/otp.html | 103 ++++++ pkg/provider/pingntlm/exmaple/swipe.html | 98 ++++++ pkg/provider/pingntlm/exmaple/webauthn.html | 63 ++++ pkg/provider/pingntlm/pingntlm.go | 308 ++++++++++++++++++ pkg/provider/pingntlm/pingntlm_test.go | 151 +++++++++ saml2aws.go | 6 + 9 files changed, 1021 insertions(+) create mode 100644 pkg/provider/pingntlm/exmaple/form-redirect.html create mode 100644 pkg/provider/pingntlm/exmaple/login.html create mode 100644 pkg/provider/pingntlm/exmaple/login2.html create mode 100644 pkg/provider/pingntlm/exmaple/otp.html create mode 100644 pkg/provider/pingntlm/exmaple/swipe.html create mode 100644 pkg/provider/pingntlm/exmaple/webauthn.html create mode 100644 pkg/provider/pingntlm/pingntlm.go create mode 100644 pkg/provider/pingntlm/pingntlm_test.go diff --git a/pkg/provider/pingntlm/exmaple/form-redirect.html b/pkg/provider/pingntlm/exmaple/form-redirect.html new file mode 100644 index 000000000..4bfa36f7d --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/form-redirect.html @@ -0,0 +1,21 @@ + + + + Submit Form + + + + + + +
+ + + +
+ + \ No newline at end of file diff --git a/pkg/provider/pingntlm/exmaple/login.html b/pkg/provider/pingntlm/exmaple/login.html new file mode 100644 index 000000000..3cdac6461 --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/login.html @@ -0,0 +1,113 @@ + + + + + + + + + + + + Login Page + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+ + +
+
+
+
+
+
+

Log in to your account

+
+
+
+
+
+
+
    +
  • + + +

    We couldn't find that. Try again.

    +
  • +
  • + + +
  • + +
+ + + + + + + + Next + Log in + +
+ +
+ +
+
+
+ +
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/pkg/provider/pingntlm/exmaple/login2.html b/pkg/provider/pingntlm/exmaple/login2.html new file mode 100644 index 000000000..8f9643b49 --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/login2.html @@ -0,0 +1,158 @@ + + + + + Sign On + + + + + + + + + +
+ + +
+ + Sign On +
+ + +
+ +
+
+
+ + + +
+ +
+ Username +
+
+ + +
+ +
+ Password +
+
+ +
+ + +
+ + + + + Sign On + +
+ + + + + +
+
+ +
+ + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/pkg/provider/pingntlm/exmaple/otp.html b/pkg/provider/pingntlm/exmaple/otp.html new file mode 100644 index 000000000..32f7d5656 --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/otp.html @@ -0,0 +1,103 @@ + + + + + + + PingID + + + + + + + + + + +
+
+
+
+

+ Authentication +

+
+ Authenticating with Desktop Mac +
+

+ Enter the passcode displayed in PingID desktop. +

+

+
+ + + + +
+
+
+
Corporate MOTD
+ + +
+ +
+ +
+ +
+
+ + +
+
+ + \ No newline at end of file diff --git a/pkg/provider/pingntlm/exmaple/swipe.html b/pkg/provider/pingntlm/exmaple/swipe.html new file mode 100644 index 000000000..27c5ddb43 --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/swipe.html @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ Authenticating on +
+ iPhone X +
+
+ Change Device +
+
+
corporate motd
+ + +
+ + +
+
+ + + +
+
+ +
+
+ + + +
+ + + + +
+ + \ No newline at end of file diff --git a/pkg/provider/pingntlm/exmaple/webauthn.html b/pkg/provider/pingntlm/exmaple/webauthn.html new file mode 100644 index 000000000..3589e91be --- /dev/null +++ b/pkg/provider/pingntlm/exmaple/webauthn.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + \ No newline at end of file diff --git a/pkg/provider/pingntlm/pingntlm.go b/pkg/provider/pingntlm/pingntlm.go new file mode 100644 index 000000000..12218919f --- /dev/null +++ b/pkg/provider/pingntlm/pingntlm.go @@ -0,0 +1,308 @@ +package pingntlm + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/versent/saml2aws/v2/pkg/cfg" + "github.com/versent/saml2aws/v2/pkg/creds" + "github.com/versent/saml2aws/v2/pkg/page" + "github.com/versent/saml2aws/v2/pkg/prompter" + "github.com/versent/saml2aws/v2/pkg/provider" + + "golang.org/x/net/publicsuffix" + + "github.com/Azure/go-ntlmssp" +) + +var logger = logrus.WithField("provider", "pingntlm") + +// Client wrapper around PingFed + PingId enabling authentication and retrieval of assertions with the +// addition of NTLM +type Client struct { + provider.ValidateBase + + client *http.Client + idpAccount *cfg.IDPAccount +} + +// New create a new PingFed client +func New(idpAccount *cfg.IDPAccount) (*Client, error) { + + transport := &ntlmssp.Negotiator{ + RoundTripper: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: idpAccount.SkipVerify, Renegotiation: tls.RenegotiateFreelyAsClient}, + }, + } + + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + return nil, err + } + + client := &http.Client{ + Transport: transport, + Jar: jar, + } + + // assign a response validator to ensure all responses are either success or a redirect + // this is to avoid have explicit checks for every single response + //client.CheckResponseStatus = provider.SuccessOrRedirectResponseValidator + + return &Client{ + client: client, + idpAccount: idpAccount, + }, nil +} + +type ctxKey string + +// Authenticate Authenticate to PingFed and return the data from the body of the SAML assertion. +func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { + ac.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + req.SetBasicAuth(loginDetails.Username, loginDetails.Password) + return nil + } + + ac.client.Transport = ntlmssp.Negotiator{ + RoundTripper: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + url := fmt.Sprintf("%s/idp/startSSO.ping?PartnerSpId=%s", loginDetails.URL, ac.idpAccount.AmazonWebservicesURN) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", errors.Wrap(err, "error building request") + } + + req.Header.Set("User-Agent", "Automation") + req.SetBasicAuth(loginDetails.Username, loginDetails.Password) + + ctx := context.WithValue(context.Background(), ctxKey("login"), loginDetails) + return ac.follow(ctx, req) +} + +func (ac *Client) follow(ctx context.Context, req *http.Request) (string, error) { + res, err := ac.client.Do(req) + if err != nil { + return "", errors.Wrap(err, "error following") + } + + doc, err := goquery.NewDocumentFromReader(res.Body) + if err != nil { + return "", errors.Wrap(err, "failed to build document from response") + } + + var handler func(context.Context, *goquery.Document) (context.Context, *http.Request, error) + + if docIsFormRedirectToAWS(doc) { + logger.WithField("type", "saml-response-to-aws").Debug("doc detect") + if samlResponse, ok := extractSAMLResponse(doc); ok { + decodedSamlResponse, err := base64.StdEncoding.DecodeString(samlResponse) + if err != nil { + return "", errors.Wrap(err, "failed to decode saml-response") + } + logger.WithField("type", "saml-response").WithField("saml-response", string(decodedSamlResponse)).Debug("doc detect") + return samlResponse, nil + } + } else if docIsFormSamlRequest(doc) { + logger.WithField("type", "saml-request").Debug("doc detect") + handler = ac.handleFormRedirect + } else if docIsFormResume(doc) { + logger.WithField("type", "resume").Debug("doc detect") + handler = ac.handleFormRedirect + } else if docIsFormSamlResponse(doc) { + logger.WithField("type", "saml-response").Debug("doc detect") + handler = ac.handleFormRedirect + } else if docIsLogin(doc) { + logger.WithField("type", "login").Debug("doc detect") + handler = ac.handleLogin + } else if docIsOTP(doc) { + logger.WithField("type", "otp").Debug("doc detect") + handler = ac.handleOTP + } else if docIsSwipe(doc) { + logger.WithField("type", "swipe").Debug("doc detect") + handler = ac.handleSwipe + } else if docIsFormRedirect(doc) { + logger.WithField("type", "form-redirect").Debug("doc detect") + handler = ac.handleFormRedirect + } else if docIsWebAuthn(doc) { + logger.WithField("type", "webauthn").Debug("doc detect") + handler = ac.handleWebAuthn + } + if handler == nil { + html, _ := doc.Selection.Html() + logger.WithField("doc", html).Debug("Unknown document type") + return "", fmt.Errorf("Unknown document type") + } + + ctx, req, err = handler(ctx, doc) + if err != nil { + return "", err + } + return ac.follow(ctx, req) +} + +func (ac *Client) handleLogin(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) { + loginDetails, ok := ctx.Value(ctxKey("login")).(*creds.LoginDetails) + if !ok { + return ctx, nil, fmt.Errorf("no context value for 'login'") + } + + form, err := page.NewFormFromDocument(doc, "form") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting login form") + } + + form.Values.Set("pf.username", loginDetails.Username) + form.Values.Set("pf.pass", loginDetails.Password) + form.URL = makeAbsoluteURL(form.URL, loginDetails.URL) + + req, err := form.BuildRequest() + return ctx, req, err +} + +func (ac *Client) handleOTP(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) { + form, err := page.NewFormFromDocument(doc, "#otp-form") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting OTP form") + } + + token := prompter.StringRequired("Enter passcode") + form.Values.Set("otp", token) + req, err := form.BuildRequest() + return ctx, req, err +} + +func (ac *Client) handleSwipe(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) { + form, err := page.NewFormFromDocument(doc, "#form1") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting swipe status form") + } + + // poll status. request must specifically be a GET + form.Method = "GET" + req, err := form.BuildRequest() + if err != nil { + return ctx, nil, err + } + + for { + time.Sleep(3 * time.Second) + + res, err := ac.client.Do(req) + if err != nil { + return ctx, nil, errors.Wrap(err, "error polling swipe status") + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return ctx, nil, errors.Wrap(err, "error parsing body from swipe status response") + } + + resp := string(body) + + pingfedMFAStatusResponse := gjson.Get(resp, "status").String() + + //ASYNC_AUTH_WAIT indicates we keep going + //OK indicates someone swiped + //DEVICE_CLAIM_TIMEOUT indicates nobody swiped + //otherwise loop forever? + + if pingfedMFAStatusResponse == "OK" || pingfedMFAStatusResponse == "DEVICE_CLAIM_TIMEOUT" || pingfedMFAStatusResponse == "TIMEOUT" { + break + } + } + + // now build a request for getting response of MFA + form, err = page.NewFormFromDocument(doc, "#reponseView") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting swipe response form") + } + req, err = form.BuildRequest() + return ctx, req, err +} + +func (ac *Client) handleFormRedirect(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) { + form, err := page.NewFormFromDocument(doc, "") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting redirect form") + } + req, err := form.BuildRequest() + return ctx, req, err +} + +func (ac *Client) handleWebAuthn(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) { + form, err := page.NewFormFromDocument(doc, "") + if err != nil { + return ctx, nil, errors.Wrap(err, "error extracting webauthn form") + } + form.Values.Set("isWebAuthnSupportedByBrowser", "false") + req, err := form.BuildRequest() + return ctx, req, err +} + +func docIsLogin(doc *goquery.Document) bool { + return doc.Has("input[name=\"pf.pass\"]").Size() == 1 +} + +func docIsOTP(doc *goquery.Document) bool { + return doc.Has("form#otp-form").Size() == 1 +} + +func docIsSwipe(doc *goquery.Document) bool { + return doc.Has("form#form1").Size() == 1 && doc.Has("form#reponseView").Size() == 1 +} + +func docIsFormRedirect(doc *goquery.Document) bool { + return doc.Has("input[name=\"ppm_request\"]").Size() == 1 +} + +func docIsWebAuthn(doc *goquery.Document) bool { + return doc.Has("input[name=\"isWebAuthnSupportedByBrowser\"]").Size() == 1 +} + +func docIsFormSamlRequest(doc *goquery.Document) bool { + return doc.Find("input[name=\"SAMLRequest\"]").Size() == 1 +} + +func docIsFormSamlResponse(doc *goquery.Document) bool { + return doc.Find("input[name=\"SAMLResponse\"]").Size() == 1 +} + +func docIsFormResume(doc *goquery.Document) bool { + return doc.Find("input[name=\"RelayState\"]").Size() == 1 +} + +func docIsFormRedirectToAWS(doc *goquery.Document) bool { + return doc.Find("form[action=\"https://signin.aws.amazon.com/saml\"]").Size() == 1 +} + +func extractSAMLResponse(doc *goquery.Document) (v string, ok bool) { + return doc.Find("input[name=\"SAMLResponse\"]").Attr("value") +} + +// ensures given url is an absolute URL. if not, it will be combined with the base URL +func makeAbsoluteURL(v string, base string) string { + if u, err := url.ParseRequestURI(v); err == nil && !u.IsAbs() { + return fmt.Sprintf("%s%s", base, v) + } + return v +} diff --git a/pkg/provider/pingntlm/pingntlm_test.go b/pkg/provider/pingntlm/pingntlm_test.go new file mode 100644 index 000000000..a30d5050e --- /dev/null +++ b/pkg/provider/pingntlm/pingntlm_test.go @@ -0,0 +1,151 @@ +package pingntlm + +import ( + "bytes" + "context" + "io/ioutil" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/require" + "github.com/versent/saml2aws/v2/mocks" + "github.com/versent/saml2aws/v2/pkg/creds" + "github.com/versent/saml2aws/v2/pkg/prompter" +) + +func TestMakeAbsoluteURL(t *testing.T) { + require.Equal(t, makeAbsoluteURL("/a", "https://example.com"), "https://example.com/a") + require.Equal(t, makeAbsoluteURL("https://foo.com/a/b", "https://bar.com"), "https://foo.com/a/b") +} + +var docTests = []struct { + fn func(*goquery.Document) bool + file string + expected bool +}{ + {docIsLogin, "example/login.html", true}, + {docIsLogin, "example/login2.html", true}, + {docIsLogin, "example/otp.html", false}, + {docIsLogin, "example/swipe.html", false}, + {docIsLogin, "example/form-redirect.html", false}, + {docIsLogin, "example/webauthn.html", false}, + {docIsOTP, "example/login.html", false}, + {docIsOTP, "example/otp.html", true}, + {docIsOTP, "example/swipe.html", false}, + {docIsOTP, "example/form-redirect.html", false}, + {docIsOTP, "example/webauthn.html", false}, + {docIsSwipe, "example/login.html", false}, + {docIsSwipe, "example/otp.html", false}, + {docIsSwipe, "example/swipe.html", true}, + {docIsSwipe, "example/form-redirect.html", false}, + {docIsSwipe, "example/webauthn.html", false}, + {docIsFormRedirect, "example/login.html", false}, + {docIsFormRedirect, "example/otp.html", false}, + {docIsFormRedirect, "example/swipe.html", false}, + {docIsFormRedirect, "example/form-redirect.html", true}, + {docIsFormRedirect, "example/webauthn.html", false}, + {docIsWebAuthn, "example/login.html", false}, + {docIsWebAuthn, "example/otp.html", false}, + {docIsWebAuthn, "example/swipe.html", false}, + {docIsWebAuthn, "example/form-redirect.html", false}, + {docIsWebAuthn, "example/webauthn.html", true}, +} + +func TestDocTypes(t *testing.T) { + for _, tt := range docTests { + data, err := ioutil.ReadFile(tt.file) + require.Nil(t, err) + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + require.Nil(t, err) + + if tt.fn(doc) != tt.expected { + t.Errorf("expect doc check of %v to be %v", tt.file, tt.expected) + } + } +} + +func TestHandleLogin(t *testing.T) { + ac := Client{} + loginDetails := creds.LoginDetails{ + Username: "fdsa", + Password: "secret", + URL: "https://example.com/foo", + } + ctx := context.WithValue(context.Background(), ctxKey("login"), &loginDetails) + + data, err := ioutil.ReadFile("example/login.html") + require.Nil(t, err) + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + require.Nil(t, err) + + ctx, req, err := ac.handleLogin(ctx, doc) + require.Nil(t, err) + + b, err := ioutil.ReadAll(req.Body) + require.Nil(t, err) + + s := string(b[:]) + require.Contains(t, s, "pf.username=fdsa") + require.Contains(t, s, "pf.pass=secret") +} + +func TestHandleOTP(t *testing.T) { + pr := &mocks.Prompter{} + prompter.SetPrompter(pr) + pr.Mock.On("StringRequired", "Enter passcode").Return("5309") + + data, err := ioutil.ReadFile("example/otp.html") + require.Nil(t, err) + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + require.Nil(t, err) + + ac := Client{} + _, req, err := ac.handleOTP(context.Background(), doc) + require.Nil(t, err) + + b, err := ioutil.ReadAll(req.Body) + require.Nil(t, err) + + s := string(b[:]) + require.Contains(t, s, "otp=5309") +} + +func TestHandleFormRedirect(t *testing.T) { + data, err := ioutil.ReadFile("example/form-redirect.html") + require.Nil(t, err) + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + require.Nil(t, err) + + ac := Client{} + _, req, err := ac.handleFormRedirect(context.Background(), doc) + require.Nil(t, err) + + b, err := ioutil.ReadAll(req.Body) + require.Nil(t, err) + + s := string(b[:]) + require.Contains(t, s, "ppm_request=secret") + require.Contains(t, s, "idp_account_id=some-uuid") +} + +func TestHandleWebAuthn(t *testing.T) { + data, err := ioutil.ReadFile("example/webauthn.html") + require.Nil(t, err) + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + require.Nil(t, err) + + ac := Client{} + _, req, err := ac.handleWebAuthn(context.Background(), doc) + require.Nil(t, err) + + b, err := ioutil.ReadAll(req.Body) + require.Nil(t, err) + + s := string(b[:]) + require.Contains(t, s, "isWebAuthnSupportedByBrowser=false") +} diff --git a/saml2aws.go b/saml2aws.go index 010c42aca..466108050 100644 --- a/saml2aws.go +++ b/saml2aws.go @@ -20,6 +20,7 @@ import ( "github.com/versent/saml2aws/v2/pkg/provider/okta" "github.com/versent/saml2aws/v2/pkg/provider/onelogin" "github.com/versent/saml2aws/v2/pkg/provider/pingfed" + "github.com/versent/saml2aws/v2/pkg/provider/pingntlm" "github.com/versent/saml2aws/v2/pkg/provider/pingone" "github.com/versent/saml2aws/v2/pkg/provider/shell" "github.com/versent/saml2aws/v2/pkg/provider/shibboleth" @@ -114,6 +115,11 @@ func NewSAMLClient(idpAccount *cfg.IDPAccount) (SAMLClient, error) { return nil, fmt.Errorf("Invalid MFA type: %v for %v provider", idpAccount.MFA, idpAccount.Provider) } return pingfed.New(idpAccount) + case "PingNTLM": + if invalidMFA(idpAccount.Provider, idpAccount.MFA) { + return nil, fmt.Errorf("Invalid MFA type: %v for %v provider", idpAccount.MFA, idpAccount.Provider) + } + return pingntlm.New(idpAccount) case "PingOne": if invalidMFA(idpAccount.Provider, idpAccount.MFA) { return nil, fmt.Errorf("Invalid MFA type: %v for %v provider", idpAccount.MFA, idpAccount.Provider)