pkg/provider/googleapps/googleapps.go (608 lines of code) (raw):

package googleapps import ( "bytes" b64 "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "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" "github.com/sirupsen/logrus" ) var logger = logrus.WithField("provider", "googleapps") // Client wrapper around Google Apps. type Client struct { client *provider.HTTPClient } // New create a new Google Apps 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 Google Apps and returns a SAML response func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { // Get the first page authURL, authForm, err := kc.loadFirstPage(loginDetails) if err != nil { return "", errors.Wrap(err, "error loading first page") } authForm.Set("Email", loginDetails.Username) passwordURL, passwordForm, err := kc.loadLoginPage(authURL+"?hl=en&loc=US", loginDetails.URL+"&hl=en&loc=US", authForm) if err != nil { return "", errors.Wrap(err, "error loading login page") } logger.Debugf("loginURL: %s", passwordURL) authForm.Set("Passwd", loginDetails.Password) referingURL := passwordURL if _, rawIdPresent := passwordForm["rawidentifier"]; rawIdPresent { authForm.Set("rawidentifier", loginDetails.Username) referingURL = authURL } if v, tlPresent := passwordForm["TL"]; tlPresent { authForm.Set("TL", v[0]) } if v, gxfPresent := passwordForm["gxf"]; gxfPresent { authForm.Set("gxf", v[0]) } responseDoc, err := kc.loadChallengePage(passwordURL+"?hl=en&loc=US", referingURL, authForm, loginDetails) if err != nil { return "", errors.Wrap(err, "error loading challenge page") } captchaInputIds := []string{ "logincaptcha", "identifier-captcha-input", } var captchaFound *goquery.Selection var captchaInputId string for _, v := range captchaInputIds { captchaFound = responseDoc.Find(fmt.Sprintf("#%s", v)) if captchaFound != nil && captchaFound.Length() > 0 { captchaInputId = v break } } for captchaFound != nil && captchaFound.Length() > 0 { captchaImgDiv := responseDoc.Find(".captcha-img") captchaPictureSrc, found := goquery.NewDocumentFromNode(captchaImgDiv.Children().Nodes[0]).Attr("src") if !found { return "", errors.New("captcha image not found but requested") } captchaPictureURL, err := generateFullURLIfRelative(captchaPictureSrc, passwordURL) if err != nil { return "", errors.Wrap(err, "error generating captcha image URL") } captcha, err := kc.tryDisplayCaptcha(captchaPictureURL) if err != nil { return "", err } captchaForm, captchaURL, err := extractInputsByFormID(responseDoc, "gaia_loginform", "challenge") if err != nil { return "", errors.Wrap(err, "error extracting captcha") } logger.Debugf("captchaURL: %s", captchaURL) _, captchaV1 := captchaForm["Passwd"] if captchaV1 { captchaForm.Set("Passwd", loginDetails.Password) } captchaForm.Set(captchaInputId, captcha) responseDoc, err = kc.loadChallengePage(captchaURL+"?hl=en&loc=US", captchaURL, captchaForm, loginDetails) if err != nil { return "", errors.Wrap(err, "error loading challenge page") } captchaFound = responseDoc.Find(fmt.Sprintf("#%s", captchaInputId)) } // New Captcha proceeds back to password page passworddBeingRequested := responseDoc.Find("#password") if passworddBeingRequested != nil && passworddBeingRequested.Length() > 0 { loginForm, loginURL, err := extractInputsByFormID(responseDoc, "challenge") if err != nil { return "", errors.Wrap(err, "error parsing password page after captcha") } loginForm.Set("Passwd", loginDetails.Password) responseDoc, err = kc.loadChallengePage(loginURL+"?hl=en&loc=US", loginURL, loginForm, loginDetails) if err != nil { return "", errors.Wrap(err, "error loading challenge page") } } samlAssertion := mustFindInputByName(responseDoc, "SAMLResponse") if samlAssertion == "" { return "", errors.New("page is missing saml assertion") } return samlAssertion, nil } func (kc *Client) tryDisplayCaptcha(captchaPictureURL string) (string, error) { // TODO: check for user flag for easy captcha presentation if os.Getenv("TERM_PROGRAM") == "iTerm.app" { // Use iTerm to show the image if available return kc.iTermCaptchaPrompt(captchaPictureURL) } else { return simpleCaptchaPrompt(captchaPictureURL), nil } } func (kc *Client) iTermCaptchaPrompt(captchaPictureURL string) (string, error) { fmt.Printf("Detected iTerm, displaying URL: %s\n", captchaPictureURL) imgResp, err := kc.client.Get(captchaPictureURL) if err != nil { return "", errors.Wrap(err, "unable to fetch captcha image") } var buf bytes.Buffer b64Encoder := b64.NewEncoder(b64.StdEncoding, &buf) _, _ = io.Copy(b64Encoder, imgResp.Body) _ = b64Encoder.Close() if os.Getenv("TERM") == "screen" { fmt.Println("Detected tmux, using specific workaround...") fmt.Printf("\033Ptmux;\033\033]1337;File=width=40;preserveAspectRatio=1;inline=1;:%s\a\033\\\n", buf.String()) } else { fmt.Printf("\033]1337;File=width=40;preserveAspectRatio=1;inline=1;:%s\a\n", buf.String()) } return prompter.String("Captcha", ""), nil } func simpleCaptchaPrompt(captchaPictureURL string) string { log.Println("Open this link in a browser:\n", captchaPictureURL) return prompter.String("Captcha", "") } func (kc *Client) loadFirstPage(loginDetails *creds.LoginDetails) (string, url.Values, error) { firstPageURL := loginDetails.URL + "&hl=en&loc=US" req, err := http.NewRequest("GET", firstPageURL, nil) if err != nil { return "", nil, errors.Wrap(err, "error retrieving login form from idp") } res, err := kc.client.Do(req) if err != nil { return "", nil, errors.Wrap(err, "failed to make request to login form") } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return "", nil, errors.Wrap(err, "error parsing first page html document") } doc.Url, err = url.Parse(firstPageURL) if err != nil { return "", url.Values{}, errors.Wrap(err, "failed to define URL for html doc") } authForm, submitURL, err := extractInputsByFormID(doc, "gaia_loginform", "challenge") if err != nil { return "", nil, errors.Wrap(err, "failed to build login form data") } _, loginPageV1 := authForm["GALX"] var postForm url.Values // using a field which is known to be in the original login page if loginPageV1 { // Login page v1 postForm = url.Values{ "bgresponse": []string{"js_disabled"}, "checkConnection": []string{""}, "checkedDomains": []string{"youtube"}, "continue": []string{authForm.Get("continue")}, "gxf": []string{authForm.Get("gxf")}, "identifier-captcha-input": []string{""}, "identifiertoken": []string{""}, "identifiertoken_audio": []string{""}, "ltmpl": []string{"popup"}, "oauth": []string{"1"}, "Page": []string{authForm.Get("Page")}, "Passwd": []string{""}, "PersistentCookie": []string{"yes"}, "ProfileInformation": []string{""}, "pstMsg": []string{"0"}, "sarp": []string{"1"}, "scc": []string{"1"}, "SessionState": []string{authForm.Get("SessionState")}, "signIn": []string{authForm.Get("signIn")}, "_utf8": []string{authForm.Get("_utf8")}, "GALX": []string{authForm.Get("GALX")}, } } else { // Login page v2 postForm = url.Values{ "challengeId": []string{"1"}, "challengeType": []string{"1"}, "continue": []string{authForm.Get("continue")}, "scc": []string{"1"}, "sarp": []string{"1"}, "checkeddomains": []string{"youtube"}, "checkConnection": []string{"youtube:930:1"}, "pstMessage": []string{"1"}, "oauth": []string{authForm.Get("oauth")}, "flowName": []string{authForm.Get("flowName")}, "faa": []string{"1"}, "Email": []string{""}, "Passwd": []string{""}, "TrustDevice": []string{"on"}, "bgresponse": []string{"js_disabled"}, } for _, k := range []string{"TL", "gxf"} { if v, ok := authForm[k]; ok { postForm.Set(k, v[0]) } } } return submitURL, postForm, err } func (kc *Client) loadLoginPage(submitURL string, referer string, authForm url.Values) (string, url.Values, error) { req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode())) if err != nil { return "", nil, errors.Wrap(err, "error retrieving login form") } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept-Language", "en-US") req.Header.Set("Content-Language", "en-US") req.Header.Set("Referer", referer) res, err := kc.client.Do(req) if err != nil { return "", nil, errors.Wrap(err, "failed to make request to login form") } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return "", nil, errors.Wrap(err, "error parsing login page html document") } doc.Url, err = url.Parse(submitURL) if err != nil { return "", url.Values{}, errors.Wrap(err, "failed to define URL for html doc") } loginForm, loginURL, err := extractInputsByFormID(doc, "gaia_loginform", "challenge") if err != nil { return "", nil, errors.Wrap(err, "failed to build login form data") } return loginURL, loginForm, err } func (kc *Client) loadChallengePage(submitURL string, referer string, authForm url.Values, loginDetails *creds.LoginDetails) (*goquery.Document, error) { req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode())) if err != nil { return nil, errors.Wrap(err, "error retrieving login form") } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept-Language", "en-US") req.Header.Set("Content-Language", "en-US") req.Header.Set("Referer", referer) res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "failed to make request to login form") } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return nil, errors.Wrap(err, "error parsing login page html document") } doc.Url, err = url.Parse(submitURL) if err != nil { return nil, errors.Wrap(err, "failed to define URL for html doc") } errMsg := mustFindErrorMsg(doc) if errMsg != "" { return nil, errors.New("Invalid username or password") } secondFactorHeader := "This extra step shows it’s really you trying to sign in" secondFactorHeader2 := "This extra step shows that it’s really you trying to sign in" secondFactorHeaderJp := "2 段階認証プロセス" // have we been asked for 2-Step Verification if extractNodeText(doc, "h2", secondFactorHeader) != "" || extractNodeText(doc, "h2", secondFactorHeader2) != "" || extractNodeText(doc, "h1", secondFactorHeaderJp) != "" { responseForm, secondActionURL, err := extractInputsByFormID(doc, "challenge") if err != nil { return nil, errors.Wrap(err, "unable to extract challenge form") } logger.Debugf("secondActionURL: %s", secondActionURL) switch { case strings.Contains(secondActionURL, "challenge/totp/"): // handle TOTP challenge var token = loginDetails.MFAToken if token == "" { token = prompter.RequestSecurityCode("000000") } responseForm.Set("Pin", token) responseForm.Set("TrustDevice", "on") // Don't ask again on this computer return kc.loadResponsePage(secondActionURL, submitURL, responseForm) case strings.Contains(secondActionURL, "challenge/ipp/"): // handle SMS challenge var token = prompter.StringRequired("Enter SMS token: G-") responseForm.Set("Pin", token) responseForm.Set("TrustDevice", "on") // Don't ask again on this computer return kc.loadResponsePage(secondActionURL, submitURL, responseForm) case strings.Contains(secondActionURL, "challenge/sk/"): // handle u2f challenge facetComponents, err := url.Parse(secondActionURL) if err != nil { return nil, errors.Wrap(err, "unable to parse action URL for U2F challenge") } facet := facetComponents.Scheme + "://" + facetComponents.Host challengeNonce := responseForm.Get("id-challenge") appID, data := extractKeyHandles(doc, challengeNonce) u2fClient, err := NewU2FClient(challengeNonce, appID, facet, data[0], &U2FDeviceFinder{}) if err != nil { return nil, errors.Wrap(err, "Failed to prompt for second factor.") } response, err := u2fClient.ChallengeU2F() if err != nil { errors.Wrap(err, "Second factor failed.") return kc.skipChallengePage(doc, submitURL, secondActionURL, loginDetails) } responseForm.Set("id-assertion", response) responseForm.Set("TrustDevice", "on") return kc.loadResponsePage(secondActionURL, submitURL, responseForm) case strings.Contains(secondActionURL, "challenge/az/"): // handle phone challenge dataAttrs := extractDataAttributes(doc, "div[data-context]", []string{"data-context", "data-gapi-url", "data-tx-id", "data-api-key", "data-tx-lifetime"}) logger.Debugf("prompt with data values: %+v", dataAttrs) waitValues := map[string]string{ "txId": dataAttrs["data-tx-id"], } fmt.Println("Open the Google App, and tap 'Yes' on the prompt to sign in") _, err := kc.postJSON(fmt.Sprintf("https://content.googleapis.com/cryptauth/v1/authzen/awaittx?alt=json&key=%s", dataAttrs["data-api-key"]), waitValues, submitURL) if err != nil { return nil, errors.Wrap(err, "unable to extract post wait tx form") } // responseForm.Set("Pin", token) responseForm.Set("TrustDevice", "on") // Don't ask again on this computer return kc.loadResponsePage(secondActionURL, submitURL, responseForm) case strings.Contains(secondActionURL, "challenge/skotp/"): // handle one-time HOTP challenge fmt.Println("Get a one-time code by visiting https://g.co/sc on another device where you can use your security key") var token = prompter.RequestSecurityCode("000 000") responseForm.Set("Pin", token) responseForm.Set("TrustDevice", "on") // Don't ask again on this computer return kc.loadResponsePage(secondActionURL, submitURL, responseForm) } return kc.skipChallengePage(doc, submitURL, secondActionURL, loginDetails) } return doc, nil } func (kc *Client) skipChallengePage(doc *goquery.Document, submitURL string, secondActionURL string, loginDetails *creds.LoginDetails) (*goquery.Document, error) { skipResponseForm, skipActionURL, err := extractInputsByFormQuery(doc, `[action$="skip"]`) if err != nil { return nil, errors.Wrap(err, "unable to extract skip form") } if skipActionURL == "" { return nil, errors.Errorf("unsupported second factor: %s", secondActionURL) } return kc.loadAlternateChallengePage(skipActionURL, submitURL, skipResponseForm, loginDetails) } func (kc *Client) loadAlternateChallengePage(submitURL string, referer string, authForm url.Values, loginDetails *creds.LoginDetails) (*goquery.Document, error) { req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode())) if err != nil { return nil, errors.Wrap(err, "error retrieving login form") } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept-Language", "en-US") req.Header.Set("Content-Language", "en-US") req.Header.Set("Referer", referer) res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "failed to make request to login form") } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return nil, errors.Wrap(err, "error parsing login page html document") } doc.Url, err = url.Parse(submitURL) if err != nil { return nil, errors.Wrap(err, "failed to define URL for html doc") } var challengeEntry string doc.Find("form[data-challengeentry]").EachWithBreak(func(i int, s *goquery.Selection) bool { action, ok := s.Attr("action") if !ok { return true } if strings.Contains(action, "challenge/totp/") || strings.Contains(action, "challenge/ipp/") || strings.Contains(action, "challenge/az/") || strings.Contains(action, "challenge/skotp/") { challengeEntry, _ = s.Attr("data-challengeentry") return false } return true }) if challengeEntry == "" { return nil, errors.New("unable to find supported second factor") } query := fmt.Sprintf(`[data-challengeentry="%s"]`, challengeEntry) responseForm, newActionURL, err := extractInputsByFormQuery(doc, query) if err != nil { return nil, errors.Wrap(err, "unable to extract challenge form") } return kc.loadChallengePage(newActionURL, submitURL, responseForm, loginDetails) } func (kc *Client) postJSON(submitURL string, values map[string]string, referer string) (*http.Response, error) { data, _ := json.Marshal(values) req, err := http.NewRequest("POST", submitURL, bytes.NewReader(data)) if err != nil { return nil, errors.Wrap(err, "error retrieving login form") } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept-Language", "en-US") req.Header.Set("Content-Language", "en-US") req.Header.Set("Referer", referer) res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "failed to post JSON") } return res, nil } func (kc *Client) loadResponsePage(submitURL string, referer string, responseForm url.Values) (*goquery.Document, error) { req, err := http.NewRequest("POST", submitURL, strings.NewReader(responseForm.Encode())) if err != nil { return nil, errors.Wrap(err, "error retrieving response page") } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept-Language", "en") req.Header.Set("Content-Language", "en-US") req.Header.Set("Referer", submitURL) res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "failed to make request to login form") } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { return nil, errors.Wrap(err, "error parsing login page html document") } return doc, nil } func mustFindInputByName(doc *goquery.Document, name string) string { var fieldValue string q := fmt.Sprintf(`input[name="%s"]`, name) doc.Find(q).Each(func(i int, s *goquery.Selection) { val, ok := s.Attr("value") if !ok { logger.Fatal("unable to locate field value") } fieldValue = val }) return fieldValue } func mustFindErrorMsg(doc *goquery.Document) string { var fieldValue string doc.Find(".error-msg").Each(func(i int, s *goquery.Selection) { fieldValue = s.Text() }) return fieldValue } func extractInputsByFormID(doc *goquery.Document, formID ...string) (url.Values, string, error) { for _, id := range formID { formData, actionURL, err := extractInputsByFormQuery(doc, fmt.Sprintf("#%s", id)) if err != nil && strings.HasPrefix(err.Error(), "could not find form with query ") { continue } return formData, actionURL, err } return url.Values{}, "", errors.New("could not find any forms matching the provided IDs") } func extractInputsByFormQuery(doc *goquery.Document, formQuery string) (url.Values, string, error) { formData := url.Values{} var actionAttr string query := fmt.Sprintf("form%s", formQuery) currentURL := doc.Url.String() //get action url foundForms := doc.Find(query) if len(foundForms.Nodes) == 0 { return formData, "", fmt.Errorf("could not find form with query %q", query) } foundForms.Each(func(i int, s *goquery.Selection) { action, ok := s.Attr("action") if !ok { return } actionAttr = action }) actionURL, err := generateFullURLIfRelative(actionAttr, currentURL) if err != nil { return formData, "", errors.Wrap(err, "error getting action URL") } query = fmt.Sprintf("form%s", formQuery) // extract form data to passthrough doc.Find(query).Find("input").Each(func(i int, s *goquery.Selection) { name, ok := s.Attr("name") if !ok { return } val, ok := s.Attr("value") if !ok { return } logger.Debugf("name: %s value: %s", name, val) formData.Add(name, val) }) return formData, actionURL, nil } func extractNodeText(doc *goquery.Document, tag, txt string) string { var res string doc.Find(tag).Each(func(i int, s *goquery.Selection) { if s.Text() == txt { res = s.Text() } }) return res } func extractDataAttributes(doc *goquery.Document, query string, attrsToSelect []string) map[string]string { dataAttrs := make(map[string]string) doc.Find(query).Each(func(_ int, sel *goquery.Selection) { for _, f := range attrsToSelect { if val, ok := sel.Attr(f); ok { dataAttrs[f] = val } } }) return dataAttrs } func extractKeyHandles(doc *goquery.Document, challengeTxt string) (string, []string) { appId := "" keyHandles := []string{} result := map[string]interface{}{} doc.Find("div[jsname=C0oDBd]").Each(func(_ int, sel *goquery.Selection) { val, ok := sel.Attr("data-challenge-ui") if ok { firstIdx := strings.Index(val, "{") lastIdx := strings.LastIndex(val, "}") obj := []byte(val[firstIdx : lastIdx+1]) json.Unmarshal(obj, &result) // Key handles for _, val := range result { list, ok := val.([]interface{}) if !ok { continue } tmpId, stringList := filterKeyHandleList(list, challengeTxt) if tmpId != "" { appId = tmpId } if len(stringList) != 0 { keyHandles = append(keyHandles, stringList...) } } } }) return appId, keyHandles } func filterKeyHandleList(list []interface{}, challengeTxt string) (string, []string) { appId := "" newList := []string{} for _, entry := range list { if entry == nil { continue } moreList, ok := entry.([]interface{}) if ok { id, l := filterKeyHandleList(moreList, challengeTxt) if id != "" { appId = id } newList = append(newList, l...) continue } str, ok := entry.(string) if !ok { continue } if appId == "" { appId = isAppId(str) } if isKeyHandle(str, challengeTxt) { newList = append(newList, str) } } return appId, newList } func generateFullURLIfRelative(destination, currentPageURL string) (string, error) { if string(destination[0]) == "/" { currentURLParsed, err := url.Parse(currentPageURL) if err != nil { return "", errors.Wrap(err, "error generating full URL") } return fmt.Sprintf("%s://%s%s", currentURLParsed.Scheme, currentURLParsed.Host, destination), nil } else { return destination, nil } } func isKeyHandle(key, challengeTxt string) bool { _, err := b64.StdEncoding.DecodeString(key) if err != nil { return false } return key != challengeTxt } func isAppId(val string) string { obj := map[string]interface{}{} err := json.Unmarshal([]byte(val), &obj) if err != nil { return "" } appId, ok := obj["appid"].(string) if !ok { return "" } return appId }