pkg/provider/jumpcloud/jumpcloud.go (157 lines of code) (raw):

package jumpcloud import ( "encoding/json" "fmt" "io/ioutil" "log" "net/http" "regexp" "strings" "github.com/PuerkitoBio/goquery" "github.com/aliyun/saml2alibabacloud/pkg/cfg" "github.com/aliyun/saml2alibabacloud/pkg/creds" "github.com/aliyun/saml2alibabacloud/pkg/prompter" "github.com/aliyun/saml2alibabacloud/pkg/provider" "github.com/pkg/errors" ) const ( jcSSOBaseURL = "https://sso.jumpcloud.com/" xsrfURL = "https://console.jumpcloud.com/userconsole/xsrf" authSubmitURL = "https://console.jumpcloud.com/userconsole/auth" ) // Client is a wrapper representing a JumpCloud SAML client type Client struct { client *provider.HTTPClient } // XSRF is for unmarshalling the xsrf token in the response type XSRF struct { Token string `json:"xsrf"` } // AuthRequest is to be sent to JumpCloud as the auth req body type AuthRequest struct { Context string RedirectTo string Email string Password string OTP string } // JCRedirect is for unmarshalling the redirect address from the response after the auth type JCRedirect struct { Address string `json:"redirectTo"` } type JCMessage struct { Message string `json:"message"` } // New creates a new JumpCloud client func New(idpAccount *cfg.IDPAccount) (*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 } // Authenticate logs into JumpCloud and returns a SAML response func (jc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { var samlAssertion string var a AuthRequest re := regexp.MustCompile(jcSSOBaseURL) // Start by getting the XSRF Token res, err := jc.client.Get(xsrfURL) if err != nil { return samlAssertion, errors.Wrap(err, "error retieving XSRF Token") } // Grab the web response that has the xsrf in it xsrfBody, err := ioutil.ReadAll(res.Body) if err != nil { return samlAssertion, errors.Wrap(err, "error reading body of XSRF response") } // Unmarshall the answer and store the token var x = new(XSRF) err = json.Unmarshal(xsrfBody, &x) if err != nil { log.Fatalf("Error unmarshalling xsrf response! %v", err) } // Populate our Auth body for the POST a.Context = "sso" a.RedirectTo = re.ReplaceAllString(loginDetails.URL, "") a.Email = loginDetails.Username a.Password = loginDetails.Password authBody, err := json.Marshal(a) if err != nil { return samlAssertion, errors.Wrap(err, "failed to build auth request body") } // Generate our auth request req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(string(authBody))) if err != nil { return samlAssertion, errors.Wrap(err, "error building authentication request") } // Add the necessary headers to the auth request req.Header.Add("X-Xsrftoken", x.Token) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") res, err = jc.client.Do(req) if err != nil { return samlAssertion, errors.Wrap(err, "error retrieving login form") } // Check if we get a 401. If we did, and MFA is required, get the OTP and resubmit. // Otherwise log the authentication message as a fatal error. if res.StatusCode == 401 { // Grab the body from the response that has the message in it. messageBody, err := ioutil.ReadAll(res.Body) if err != nil { return samlAssertion, errors.Wrap(err, "Error reading body") } // Unmarshall the body to get the message. var jcmsg = new(JCMessage) err = json.Unmarshal(messageBody, &jcmsg) if err != nil { log.Fatalf("Error unmarshalling message response! %v", err) } // If the error indicates something other than missing MFA, then it's fatal. if jcmsg.Message != "MFA required." { errMsg := fmt.Sprintf("Jumpcloud error: %s", jcmsg.Message) return samlAssertion, errors.Wrap(err, errMsg) } // Get the user's MFA token and re-build the body a.OTP = loginDetails.MFAToken if a.OTP == "" { a.OTP = prompter.StringRequired("MFA Token") } authBody, err = json.Marshal(a) if err != nil { return samlAssertion, errors.Wrap(err, "error building authentication req body after getting MFA Token") } // Re-request with our OTP req, err = http.NewRequest("POST", authSubmitURL, strings.NewReader(string(authBody))) if err != nil { return samlAssertion, errors.Wrap(err, "error building MFA authentication request") } // Re-add the necessary headers to our remade auth request req.Header.Add("X-Xsrftoken", x.Token) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") // Resubmit res, err = jc.client.Do(req) if err != nil { return samlAssertion, errors.Wrap(err, "error submitting MFA login form") } } // Check if our auth was successful if res.StatusCode == 200 { // Grab the body from the response that has the redirect in it. reDirBody, err := ioutil.ReadAll(res.Body) if err != nil { return samlAssertion, errors.Wrap(err, "Error reading body") } // Unmarshall the body to get the redirect address var jcrd = new(JCRedirect) err = json.Unmarshal(reDirBody, &jcrd) if err != nil { log.Fatalf("Error unmarshalling redirectTo response! %v", err) } // Send the final GET for our SAML response res, err = jc.client.Get(jcrd.Address) if err != nil { return samlAssertion, errors.Wrap(err, "error submitting request for SAML value") } //try to extract SAMLResponse doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return samlAssertion, errors.Wrap(err, "error parsing document") } doc.Find("input").Each(func(i int, s *goquery.Selection) { name, ok := s.Attr("name") if !ok { log.Fatalf("unable to locate IDP authentication form submit URL") } if name == "SAMLResponse" { val, ok := s.Attr("value") if !ok { log.Fatalf("unable to locate saml assertion value") } samlAssertion = val } }) } else { errMsg := fmt.Sprintf("error when trying to auth, status code %d", res.StatusCode) return samlAssertion, errors.Wrap(err, errMsg) } return samlAssertion, nil }