From a88b20abf6644f1c4b84ebd509a2766eb56dd070 Mon Sep 17 00:00:00 2001 From: Dan Boitnott Date: Sun, 10 Jul 2022 10:01:22 -0500 Subject: [PATCH 1/3] gh-36: Started on Duo for ADFS The Duo transaction is working but still getting an error when we bring the cookie back to ADFS. --- pkg/duo/duo.go | 294 ++++++++++++++++++++++++++++++++++++++ pkg/provider/adfs/adfs.go | 69 +++++++++ 2 files changed, 363 insertions(+) create mode 100644 pkg/duo/duo.go diff --git a/pkg/duo/duo.go b/pkg/duo/duo.go new file mode 100644 index 000000000..b70f3ed6e --- /dev/null +++ b/pkg/duo/duo.go @@ -0,0 +1,294 @@ +package duo + +import ( + "fmt" + "html" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/tidwall/gjson" + + "github.com/pkg/errors" + "github.com/versent/saml2aws/v2/pkg/creds" + "github.com/versent/saml2aws/v2/pkg/prompter" + "github.com/versent/saml2aws/v2/pkg/provider" +) + +type duoDevice struct { + id string + label string +} + +type duoSession struct { + sid string + devices []duoDevice +} + +type duoTxStatus struct { + result string + resultUrl string +} + +func getDevices(doc *goquery.Document) (devices []duoDevice) { + doc.Find("select[name=\"device\"]").Find("option").Each(func(i int, s *goquery.Selection) { + id, ok := s.Attr("value") + if ok { + lbl := strings.TrimSpace(s.Text()) + if len(lbl) < 1 { + lbl = id + } + devices = append(devices, duoDevice{id: id, label: lbl}) + } + }) + return +} + +func getDuoSession(httpClient *provider.HTTPClient, parent string, duoHost string, duoSignature string) (*duoSession, error) { + duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost) + + duoForm := url.Values{} + duoForm.Add("parent", parent) + duoForm.Add("java_version", "") + duoForm.Add("java_version", "") + duoForm.Add("flash_version", "") + duoForm.Add("screen_resolution_width", "1440") + duoForm.Add("screen_resolution_height", "900") + duoForm.Add("color_depth", "24") + duoForm.Add("tx", duoSignature) + duoForm.Add("is_cef_browser", "false") + duoForm.Add("is_ipad_os", "false") + duoForm.Add("is_user_verifying_platform_authenticator_available", "false") + + req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode())) + if err != nil { + return nil, errors.Wrap(err, "error building duo request") + } + q := req.URL.Query() + q.Add("tx", duoSignature) + q.Add("parent", parent) + q.Add("v", "2.6") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := httpClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error sending duo request") + } + + doc, err := goquery.NewDocumentFromReader(res.Body) + if err != nil { + return nil, errors.Wrap(err, "error parsing document from duo") + } + + // body, _ := doc.Html() + // fmt.Println("body: ", body) + + duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value") + if !ok { + msg := doc.Find("span[class=\"message-text\"]").Text() + if len(msg) > 0 { + return nil, errors.New(fmt.Sprintf("Duo Error: %s", msg)) + } + return nil, errors.New("unable to locate sid in duo response") + } + duoSID = strings.TrimSpace(html.UnescapeString(duoSID)) + + if len(duoSID) < 1 { + return nil, errors.New("empty SID in Duo response") + } + + devices := getDevices(doc) + + return &duoSession{sid: duoSID, devices: devices}, nil +} + +func selectDevice(session *duoSession) string { + cnt := len(session.devices) + if cnt < 1 { + // This shouldn't happen. There should be at least one device. So make a + // wild guess. + return "phone1" + } else if cnt < 2 { + return session.devices[0].id + } + + var ids []string + var labels []string + for _, dev := range session.devices { + ids = append(ids, dev.id) + labels = append(labels, dev.label) + } + return ids[prompter.Choose("Select Duo MFA Device", labels)] +} + +func selectFactorFn(loginDetails *creds.LoginDetails, deviceId string) func(*url.Values) { + var token string + duoMfaOption := 0 + var duoMfaOptions = []string{ + "Duo Push", + "Passcode", + } + + return func(form *url.Values) { + if loginDetails.DuoMFAOption == "Duo Push" { + duoMfaOption = 0 + } else if loginDetails.DuoMFAOption == "Passcode" { + duoMfaOption = 1 + } else { + duoMfaOption = prompter.Choose("Select a Duo MFA Option", duoMfaOptions) + } + + if duoMfaOptions[duoMfaOption] == "Passcode" { + token = prompter.StringRequired("Enter passcode") + } + + form.Add("device", deviceId) + + form.Add("factor", duoMfaOptions[duoMfaOption]) + if duoMfaOptions[duoMfaOption] == "Passcode" { + form.Add("passcode", token) + } + } +} + +func startTx(httpClient *provider.HTTPClient, duoHost string, duoSID string, selectFactor func(*url.Values)) (duoTxId string, err error) { + duoSubmitURL := fmt.Sprintf("https://%s/frame/prompt", duoHost) + + duoForm := url.Values{} + duoForm.Add("sid", duoSID) + duoForm.Add("out_of_date", "false") + + selectFactor(&duoForm) + + req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode())) + if err != nil { + return "", errors.Wrap(err, "error building duo prompt request") + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := httpClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "error retrieving duo prompt request") + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", errors.Wrap(err, "error retrieving duo prompt response") + } + + resp := string(body) + + duoTxStat := gjson.Get(resp, "stat").String() + duoTxId = gjson.Get(resp, "response.txid").String() + if duoTxStat != "OK" { + return "", errors.Wrap(err, "error authenticating duo mfa device") + } + + return duoTxId, nil +} + +func getTxStatus(httpClient *provider.HTTPClient, duoHost string, sid string, duoTxId string) (status *duoTxStatus, err error) { + duoSubmitURL := fmt.Sprintf("https://%s/frame/status", duoHost) + + duoForm := url.Values{} + duoForm.Add("sid", sid) + duoForm.Add("txid", duoTxId) + + req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode())) + if err != nil { + return nil, errors.Wrap(err, "error building duo status request") + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := httpClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error sending duo status request") + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "error retrieving duo status response") + } + + resp := string(body) + + status = &duoTxStatus{ + result: gjson.Get(resp, "response.result").String(), + resultUrl: gjson.Get(resp, "response.result_url").String()} + return +} + +func getTxResultJson(httpClient *provider.HTTPClient, duoHost string, sid string, duoTxId string, resultUrl string) (string, error) { + duoRequestURL := fmt.Sprintf("https://%s%s", duoHost, resultUrl) + + duoForm := url.Values{} + duoForm.Add("sid", sid) + duoForm.Add("txid", duoTxId) + + req, err := http.NewRequest("POST", duoRequestURL, strings.NewReader(duoForm.Encode())) + if err != nil { + return "", errors.Wrap(err, "error constructing request object to result url") + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := httpClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "error retrieving duo result response") + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", errors.Wrap(err, "duoResultSubmit: error retrieving body from response") + } + + return string(body), nil +} + +func VerifyDuoMfa(httpClient *provider.HTTPClient, loginDetails *creds.LoginDetails, parent string, duoHost string, duoSignature string) (string, error) { + duoSignature = strings.Split(duoSignature, ":")[0] + + session, err := getDuoSession(httpClient, parent, duoHost, duoSignature) + if err != nil { + return "", errors.Wrap(err, "error fetching Duo SID") + } + + deviceId := selectDevice(session) + factorFn := selectFactorFn(loginDetails, deviceId) + + duoTxId, err := startTx(httpClient, duoHost, session.sid, factorFn) + if err != nil { + return "", errors.Wrap(err, "error starting Duo Tx") + } + + var status *duoTxStatus + for { + status, err = getTxStatus(httpClient, duoHost, session.sid, duoTxId) + if err != nil { + return "", errors.Wrap(err, "error checking Duo tx status") + } + + if status.result == "FAILURE" { + return "", errors.Wrap(err, "failed to authenticate device") + } + if status.result == "SUCCESS" { + break + } + + time.Sleep(3 * time.Second) + } + + resultJson, err := getTxResultJson(httpClient, duoHost, session.sid, duoTxId, status.resultUrl) + if err != nil { + return "", errors.Wrap(err, "error getting Duo result json") + } + + return resultJson, nil +} diff --git a/pkg/provider/adfs/adfs.go b/pkg/provider/adfs/adfs.go index a150299af..662cf39c8 100644 --- a/pkg/provider/adfs/adfs.go +++ b/pkg/provider/adfs/adfs.go @@ -11,8 +11,11 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/pkg/errors" + "github.com/tidwall/gjson" + "github.com/versent/saml2aws/v2/pkg/cfg" "github.com/versent/saml2aws/v2/pkg/creds" + "github.com/versent/saml2aws/v2/pkg/duo" "github.com/versent/saml2aws/v2/pkg/prompter" "github.com/versent/saml2aws/v2/pkg/provider" ) @@ -33,6 +36,7 @@ const ( MFA_PROMPT AZURE_MFA_WAIT AZURE_MFA_SERVER_WAIT + DUO_MFA ) // New create a new ADFS client @@ -97,6 +101,9 @@ func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) for { responseType, samlAssertion, err := checkResponse(doc) + if err != nil { + return samlAssertion, err + } switch responseType { case SAML_RESPONSE: @@ -140,6 +147,31 @@ func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) return samlAssertion, errors.New(sel.Text()) } } + case DUO_MFA: + duoHost, ok := doc.Find("input[name=\"duo_host\"]").Attr("value") + if !ok { + return samlAssertion, errors.New("duo_host field not found") + } + duoSigRequest, ok := doc.Find("input[name=\"duo_sig_request\"]").Attr("value") + if !ok { + return samlAssertion, errors.New("duo_sig_request field not found") + } + duoContext, ok := doc.Find("input[name=\"Context\"]").Attr("value") + if !ok { + return samlAssertion, errors.New("context field not found") + } + + duoJson, err := duo.VerifyDuoMfa(ac.client, loginDetails, authSubmitURL, duoHost, duoSigRequest) + if err != nil { + return samlAssertion, errors.Wrap(err, "error in Duo MFA process") + } + + duoForm := url.Values{} + duoForm.Add("Context", duoContext) + duoForm.Add("AuthMethod", "DuoAdfsAdapter") + duoForm.Add("sig_response", gjson.Get(duoJson, "response.cookie").String()) + + doc, err = ac.submit(authSubmitURL, duoForm) case UNKNOWN: return samlAssertion, errors.New("unable to classify response from auth server") } @@ -183,6 +215,30 @@ func (ac *Client) submit(url string, form url.Values) (*goquery.Document, error) return doc, nil } +func getResponseError(doc *goquery.Document) error { + msg := "" + + finders := []func(){ + func() { + doc.Find("div[id=\"errorDescription\"]").Find("li").Each(func(i int, s *goquery.Selection) { + msg += s.Text() + "\n" + }) + }, + func() { + msg = doc.Find("span[id=\"errorTextPassword\"]").Text() + }} + + for _, f := range finders { + f() + if len(msg) > 0 { + msg = strings.ReplaceAll(msg, "\n", "\n ") + return errors.New(fmt.Sprintf("Login Error:\n %s\n", msg)) + } + } + + return nil +} + func checkResponse(doc *goquery.Document) (AuthResponseType, string, error) { samlAssertion := "" responseType := UNKNOWN @@ -192,6 +248,7 @@ func checkResponse(doc *goquery.Document) (AuthResponseType, string, error) { if !ok { log.Fatalf("unable to locate IDP authentication form submit URL") } + if name == "SAMLResponse" { val, ok := s.Attr("value") if !ok { @@ -212,12 +269,24 @@ func checkResponse(doc *goquery.Document) (AuthResponseType, string, error) { responseType = AZURE_MFA_WAIT case "AzureMfaServerAuthentication": responseType = AZURE_MFA_SERVER_WAIT + case "DuoAdfsAdapter": + responseType = DUO_MFA } } if name == "VerificationCode" { responseType = MFA_PROMPT } }) + + if responseType == UNKNOWN { + err := getResponseError(doc) + if err != nil { + return responseType, samlAssertion, err + } + html, _ := doc.Html() + fmt.Printf("\n\nUNKNOWN RESPONSE BODY:\n%s\n-----------\n", html) + } + return responseType, samlAssertion, nil } From 8077598e2a2b16e6b770456d4970cf74c1f4aa69 Mon Sep 17 00:00:00 2001 From: Dan Boitnott Date: Sun, 10 Jul 2022 10:32:54 -0500 Subject: [PATCH 2/3] gh-36: ADFS + Duo works --- pkg/duo/duo.go | 8 +++++--- pkg/provider/adfs/adfs.go | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/duo/duo.go b/pkg/duo/duo.go index b70f3ed6e..bbf02bd60 100644 --- a/pkg/duo/duo.go +++ b/pkg/duo/duo.go @@ -253,9 +253,9 @@ func getTxResultJson(httpClient *provider.HTTPClient, duoHost string, sid string } func VerifyDuoMfa(httpClient *provider.HTTPClient, loginDetails *creds.LoginDetails, parent string, duoHost string, duoSignature string) (string, error) { - duoSignature = strings.Split(duoSignature, ":")[0] + sigParts := strings.Split(duoSignature, ":") - session, err := getDuoSession(httpClient, parent, duoHost, duoSignature) + session, err := getDuoSession(httpClient, parent, duoHost, sigParts[0]) if err != nil { return "", errors.Wrap(err, "error fetching Duo SID") } @@ -290,5 +290,7 @@ func VerifyDuoMfa(httpClient *provider.HTTPClient, loginDetails *creds.LoginDeta return "", errors.Wrap(err, "error getting Duo result json") } - return resultJson, nil + cookie := gjson.Get(resultJson, "response.cookie").String() + + return fmt.Sprintf("%s:%s", cookie, sigParts[1]), nil } diff --git a/pkg/provider/adfs/adfs.go b/pkg/provider/adfs/adfs.go index 662cf39c8..4e02948a4 100644 --- a/pkg/provider/adfs/adfs.go +++ b/pkg/provider/adfs/adfs.go @@ -11,7 +11,6 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/pkg/errors" - "github.com/tidwall/gjson" "github.com/versent/saml2aws/v2/pkg/cfg" "github.com/versent/saml2aws/v2/pkg/creds" @@ -161,7 +160,7 @@ func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) return samlAssertion, errors.New("context field not found") } - duoJson, err := duo.VerifyDuoMfa(ac.client, loginDetails, authSubmitURL, duoHost, duoSigRequest) + duoCookie, err := duo.VerifyDuoMfa(ac.client, loginDetails, authSubmitURL, duoHost, duoSigRequest) if err != nil { return samlAssertion, errors.Wrap(err, "error in Duo MFA process") } @@ -169,7 +168,7 @@ func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) duoForm := url.Values{} duoForm.Add("Context", duoContext) duoForm.Add("AuthMethod", "DuoAdfsAdapter") - duoForm.Add("sig_response", gjson.Get(duoJson, "response.cookie").String()) + duoForm.Add("sig_response", duoCookie) doc, err = ac.submit(authSubmitURL, duoForm) case UNKNOWN: From c8dd879bde7c0e70bfed4359d365dff24481ad54 Mon Sep 17 00:00:00 2001 From: Dan Boitnott Date: Fri, 22 Jul 2022 10:56:29 -0500 Subject: [PATCH 3/3] fix(gh-36): Added missing error check --- pkg/provider/adfs/adfs.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/provider/adfs/adfs.go b/pkg/provider/adfs/adfs.go index c9b3bbb00..4474d793f 100644 --- a/pkg/provider/adfs/adfs.go +++ b/pkg/provider/adfs/adfs.go @@ -184,6 +184,9 @@ func (ac *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) duoForm.Add("sig_response", duoCookie) doc, err = ac.submit(authSubmitURL, duoForm) + if err != nil { + return samlAssertion, errors.Wrap(err, "error in Duo MFA process") + } case UNKNOWN: return samlAssertion, errors.New("unable to classify response from auth server") }