github.com/versent/saml2aws@v2.17.0+incompatible/pkg/provider/googleapps/googleapps.go (about)

     1  package googleapps
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/http"
     8  	"net/url"
     9  	"strings"
    10  
    11  	"github.com/PuerkitoBio/goquery"
    12  	"github.com/pkg/errors"
    13  	"github.com/sirupsen/logrus"
    14  	"github.com/versent/saml2aws/pkg/cfg"
    15  	"github.com/versent/saml2aws/pkg/creds"
    16  	"github.com/versent/saml2aws/pkg/prompter"
    17  	"github.com/versent/saml2aws/pkg/provider"
    18  )
    19  
    20  var logger = logrus.WithField("provider", "googleapps")
    21  
    22  // Client wrapper around Google Apps.
    23  type Client struct {
    24  	client *provider.HTTPClient
    25  }
    26  
    27  // New create a new Google Apps Client
    28  func New(idpAccount *cfg.IDPAccount) (*Client, error) {
    29  
    30  	tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
    31  
    32  	client, err := provider.NewHTTPClient(tr)
    33  	if err != nil {
    34  		return nil, errors.Wrap(err, "error building http client")
    35  	}
    36  
    37  	return &Client{
    38  		client: client,
    39  	}, nil
    40  }
    41  
    42  // Authenticate logs into Google Apps and returns a SAML response
    43  func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
    44  
    45  	// Get the first page
    46  	authURL, authForm, err := kc.loadFirstPage(loginDetails)
    47  	if err != nil {
    48  		return "", errors.Wrap(err, "error loading first page")
    49  	}
    50  
    51  	authForm.Set("Email", loginDetails.Username)
    52  
    53  	passwordURL, _, err := kc.loadLoginPage(authURL+"?hl=en&loc=US", loginDetails.URL+"&hl=en&loc=US", authForm)
    54  	if err != nil {
    55  		return "", errors.Wrap(err, "error loading login page")
    56  	}
    57  
    58  	logger.Debugf("loginURL: %s", passwordURL)
    59  
    60  	authForm.Set("Passwd", loginDetails.Password)
    61  	authForm.Set("rawidentifier", loginDetails.Username)
    62  
    63  	responseDoc, err := kc.loadChallengePage(passwordURL+"?hl=en&loc=US", authURL, authForm)
    64  	if err != nil {
    65  		return "", errors.Wrap(err, "error loading challenge page")
    66  	}
    67  
    68  	captchaFound := responseDoc.Find("#logincaptcha")
    69  
    70  	for captchaFound != nil && captchaFound.Length() > 0 {
    71  
    72  		captchaImgDiv := responseDoc.Find(".captcha-img")
    73  		captchaPictureURL, found := goquery.NewDocumentFromNode(captchaImgDiv.Children().Nodes[0]).Attr("src")
    74  
    75  		if !found {
    76  			return "", errors.New("captcha image not found but requested")
    77  		}
    78  
    79  		fmt.Println("Open this link in a browser:\n", captchaPictureURL)
    80  
    81  		captcha := prompter.String("Captcha", "")
    82  
    83  		captchaForm, captchaURL, err := extractInputsByFormID(responseDoc, "gaia_loginform")
    84  
    85  		logger.Debugf("captchaURL: %s", captchaURL)
    86  
    87  		captchaForm.Set("Passwd", loginDetails.Password)
    88  		captchaForm.Set("logincaptcha", captcha)
    89  
    90  		responseDoc, err = kc.loadChallengePage(captchaURL+"?hl=en&loc=US", captchaURL, captchaForm)
    91  		if err != nil {
    92  			return "", errors.Wrap(err, "error loading challenge page")
    93  		}
    94  
    95  		captchaFound = responseDoc.Find("#logincaptcha")
    96  	}
    97  
    98  	samlAssertion := mustFindInputByName(responseDoc, "SAMLResponse")
    99  	if samlAssertion == "" {
   100  		return "", errors.New("page is missing saml assertion")
   101  	}
   102  
   103  	return samlAssertion, nil
   104  }
   105  
   106  func (kc *Client) loadFirstPage(loginDetails *creds.LoginDetails) (string, url.Values, error) {
   107  
   108  	req, err := http.NewRequest("GET", loginDetails.URL+"&hl=en&loc=US", nil)
   109  	if err != nil {
   110  		return "", nil, errors.Wrap(err, "error retrieving login form from idp")
   111  	}
   112  
   113  	res, err := kc.client.Do(req)
   114  	if err != nil {
   115  		return "", nil, errors.Wrap(err, "failed to make request to login form")
   116  	}
   117  
   118  	doc, err := goquery.NewDocumentFromReader(res.Body)
   119  	if err != nil {
   120  		return "", nil, errors.Wrap(err, "error parsing first page html document")
   121  	}
   122  
   123  	authForm, submitURL, err := extractInputsByFormID(doc, "gaia_loginform")
   124  	if err != nil {
   125  		return "", nil, errors.Wrap(err, "failed to build login form data")
   126  	}
   127  
   128  	postForm := url.Values{
   129  		"bgresponse":      []string{"js_disabled"},
   130  		"checkConnection": []string{""},
   131  		"checkedDomains":  []string{"youtube"},
   132  		"continue":        []string{authForm.Get("continue")},
   133  		"gxf":             []string{authForm.Get("gxf")},
   134  		"identifier-captcha-input": []string{""},
   135  		"identifiertoken":          []string{""},
   136  		"identifiertoken_audio":    []string{""},
   137  		"ltmpl":                    []string{"popup"},
   138  		"oauth":                    []string{"1"},
   139  		"Page":                     []string{authForm.Get("Page")},
   140  		"Passwd":                   []string{""},
   141  		"PersistentCookie":         []string{"yes"},
   142  		"ProfileInformation":       []string{""},
   143  		"pstMsg":                   []string{"0"},
   144  		"sarp":                     []string{"1"},
   145  		"scc":                      []string{"1"},
   146  		"SessionState":             []string{authForm.Get("SessionState")},
   147  		"signIn":                   []string{authForm.Get("signIn")},
   148  		"_utf8":                    []string{authForm.Get("_utf8")},
   149  		"GALX":                     []string{authForm.Get("GALX")},
   150  	}
   151  
   152  	return submitURL, postForm, err
   153  }
   154  
   155  func (kc *Client) loadLoginPage(submitURL string, referer string, authForm url.Values) (string, url.Values, error) {
   156  
   157  	req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode()))
   158  	if err != nil {
   159  		return "", nil, errors.Wrap(err, "error retrieving login form")
   160  	}
   161  
   162  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   163  	req.Header.Set("Accept-Language", "en-US")
   164  	req.Header.Set("Content-Language", "en-US")
   165  	req.Header.Set("Referer", referer)
   166  
   167  	res, err := kc.client.Do(req)
   168  	if err != nil {
   169  		return "", nil, errors.Wrap(err, "failed to make request to login form")
   170  	}
   171  
   172  	doc, err := goquery.NewDocumentFromReader(res.Body)
   173  	if err != nil {
   174  		return "", nil, errors.Wrap(err, "error parsing login page html document")
   175  	}
   176  
   177  	loginForm, loginURL, err := extractInputsByFormID(doc, "gaia_loginform")
   178  	if err != nil {
   179  		return "", nil, errors.Wrap(err, "failed to build login form data")
   180  	}
   181  
   182  	return loginURL, loginForm, err
   183  }
   184  
   185  func (kc *Client) loadChallengePage(submitURL string, referer string, authForm url.Values) (*goquery.Document, error) {
   186  
   187  	req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode()))
   188  	if err != nil {
   189  		return nil, errors.Wrap(err, "error retrieving login form")
   190  	}
   191  
   192  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   193  	req.Header.Set("Accept-Language", "en-US")
   194  	req.Header.Set("Content-Language", "en-US")
   195  	req.Header.Set("Referer", referer)
   196  
   197  	res, err := kc.client.Do(req)
   198  	if err != nil {
   199  		return nil, errors.Wrap(err, "failed to make request to login form")
   200  	}
   201  
   202  	doc, err := goquery.NewDocumentFromReader(res.Body)
   203  	if err != nil {
   204  		return nil, errors.Wrap(err, "error parsing login page html document")
   205  	}
   206  
   207  	errMsg := mustFindErrorMsg(doc)
   208  
   209  	if errMsg != "" {
   210  		return nil, errors.New("Invalid username or password")
   211  	}
   212  
   213  	secondFactorHeader := "This extra step shows it’s really you trying to sign in"
   214  	secondFactorHeader2 := "This extra step shows that it’s really you trying to sign in"
   215  	secondFactorHeaderJp := "2 段階認証プロセス"
   216  
   217  	// have we been asked for 2-Step Verification
   218  	if extractNodeText(doc, "h2", secondFactorHeader) != "" ||
   219  		extractNodeText(doc, "h2", secondFactorHeader2) != "" ||
   220  		extractNodeText(doc, "h1", secondFactorHeaderJp) != "" {
   221  
   222  		responseForm, secondActionURL, err := extractInputsByFormID(doc, "challenge")
   223  		if err != nil {
   224  			return nil, errors.Wrap(err, "unable to extract challenge form")
   225  		}
   226  
   227  		logger.Debugf("secondActionURL: %s", secondActionURL)
   228  
   229  		u, _ := url.Parse(submitURL)
   230  		u.Path = secondActionURL // we are just updating the path with the action as it is a relative path
   231  
   232  		switch {
   233  		case strings.Contains(secondActionURL, "challenge/totp/"): // handle TOTP challenge
   234  
   235  			var token = prompter.RequestSecurityCode("000000")
   236  
   237  			responseForm.Set("Pin", token)
   238  			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
   239  
   240  			return kc.loadResponsePage(u.String(), submitURL, responseForm)
   241  		case strings.Contains(secondActionURL, "challenge/ipp/"): // handle SMS challenge
   242  
   243  			var token = prompter.StringRequired("Enter SMS token: G-")
   244  
   245  			responseForm.Set("Pin", token)
   246  			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
   247  
   248  			return kc.loadResponsePage(u.String(), submitURL, responseForm)
   249  
   250  		case strings.Contains(secondActionURL, "challenge/az/"): // handle phone challenge
   251  
   252  			dataAttrs := extractDataAttributes(doc, "div[data-context]", []string{"data-context", "data-gapi-url", "data-tx-id", "data-api-key", "data-tx-lifetime"})
   253  
   254  			logger.Debugf("prompt with data values: %+v", dataAttrs)
   255  
   256  			waitValues := map[string]string{
   257  				"txId": dataAttrs["data-tx-id"],
   258  			}
   259  
   260  			fmt.Println("Open the Google App, and tap 'Yes' on the prompt to sign in")
   261  
   262  			_, err := kc.postJSON(fmt.Sprintf("https://content.googleapis.com/cryptauth/v1/authzen/awaittx?alt=json&key=%s", dataAttrs["data-api-key"]), waitValues, submitURL)
   263  			if err != nil {
   264  				return nil, errors.Wrap(err, "unable to extract post wait tx form")
   265  			}
   266  
   267  			// responseForm.Set("Pin", token)
   268  			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
   269  
   270  			return kc.loadResponsePage(u.String(), submitURL, responseForm)
   271  		}
   272  
   273  		skipResponseForm, skipActionURL, err := extractInputsByFormQuery(doc, `[action$="skip"]`)
   274  		if err != nil {
   275  			return nil, errors.Wrap(err, "unable to extract skip form")
   276  		}
   277  
   278  		if skipActionURL == "" {
   279  			return nil, errors.Errorf("unsupported second factor: %s", secondActionURL)
   280  		}
   281  
   282  		u.Path = skipActionURL
   283  
   284  		return kc.loadAlternateChallengePage(u.String(), submitURL, skipResponseForm)
   285  
   286  	}
   287  
   288  	return doc, nil
   289  
   290  }
   291  
   292  func (kc *Client) loadAlternateChallengePage(submitURL string, referer string, authForm url.Values) (*goquery.Document, error) {
   293  
   294  	req, err := http.NewRequest("POST", submitURL, strings.NewReader(authForm.Encode()))
   295  	if err != nil {
   296  		return nil, errors.Wrap(err, "error retrieving login form")
   297  	}
   298  
   299  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   300  	req.Header.Set("Accept-Language", "en-US")
   301  	req.Header.Set("Content-Language", "en-US")
   302  	req.Header.Set("Referer", referer)
   303  
   304  	res, err := kc.client.Do(req)
   305  	if err != nil {
   306  		return nil, errors.Wrap(err, "failed to make request to login form")
   307  	}
   308  
   309  	doc, err := goquery.NewDocumentFromReader(res.Body)
   310  	if err != nil {
   311  		return nil, errors.Wrap(err, "error parsing login page html document")
   312  	}
   313  
   314  	var challengeEntry string
   315  
   316  	doc.Find("form[data-challengeentry]").EachWithBreak(func(i int, s *goquery.Selection) bool {
   317  		action, ok := s.Attr("action")
   318  		if !ok {
   319  			return true
   320  		}
   321  
   322  		if strings.Contains(action, "challenge/totp/") ||
   323  			strings.Contains(action, "challenge/ipp/") ||
   324  			strings.Contains(action, "challenge/az/") {
   325  
   326  			challengeEntry, _ = s.Attr("data-challengeentry")
   327  			return false
   328  		}
   329  
   330  		return true
   331  	})
   332  
   333  	if challengeEntry == "" {
   334  		return nil, errors.New("unable to find supported second factor")
   335  	}
   336  
   337  	query := fmt.Sprintf(`[data-challengeentry="%s"]`, challengeEntry)
   338  	responseForm, newActionURL, err := extractInputsByFormQuery(doc, query)
   339  	if err != nil {
   340  		return nil, errors.Wrap(err, "unable to extract challenge form")
   341  	}
   342  
   343  	u, _ := url.Parse(submitURL)
   344  	u.Path = newActionURL
   345  
   346  	return kc.loadChallengePage(u.String(), submitURL, responseForm)
   347  }
   348  
   349  func (kc *Client) postJSON(submitURL string, values map[string]string, referer string) (*http.Response, error) {
   350  
   351  	data, _ := json.Marshal(values)
   352  
   353  	req, err := http.NewRequest("POST", submitURL, bytes.NewReader(data))
   354  	if err != nil {
   355  		return nil, errors.Wrap(err, "error retrieving login form")
   356  	}
   357  
   358  	req.Header.Set("Content-Type", "application/json")
   359  	req.Header.Set("Accept-Language", "en-US")
   360  	req.Header.Set("Content-Language", "en-US")
   361  	req.Header.Set("Referer", referer)
   362  
   363  	res, err := kc.client.Do(req)
   364  	if err != nil {
   365  		return nil, errors.Wrap(err, "failed to post JSON")
   366  	}
   367  
   368  	return res, nil
   369  }
   370  
   371  func (kc *Client) loadResponsePage(submitURL string, referer string, responseForm url.Values) (*goquery.Document, error) {
   372  
   373  	req, err := http.NewRequest("POST", submitURL, strings.NewReader(responseForm.Encode()))
   374  	if err != nil {
   375  		return nil, errors.Wrap(err, "error retrieving response page")
   376  	}
   377  
   378  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   379  	req.Header.Set("Accept-Language", "en")
   380  	req.Header.Set("Content-Language", "en-US")
   381  	req.Header.Set("Referer", submitURL)
   382  
   383  	res, err := kc.client.Do(req)
   384  	if err != nil {
   385  		return nil, errors.Wrap(err, "failed to make request to login form")
   386  	}
   387  
   388  	doc, err := goquery.NewDocumentFromReader(res.Body)
   389  	if err != nil {
   390  		return nil, errors.Wrap(err, "error parsing login page html document")
   391  	}
   392  
   393  	return doc, nil
   394  }
   395  
   396  func mustFindInputByName(doc *goquery.Document, name string) string {
   397  
   398  	var fieldValue string
   399  
   400  	q := fmt.Sprintf(`input[name="%s"]`, name)
   401  
   402  	doc.Find(q).Each(func(i int, s *goquery.Selection) {
   403  		val, ok := s.Attr("value")
   404  		if !ok {
   405  			logger.Fatal("unable to locate field value")
   406  		}
   407  		fieldValue = val
   408  	})
   409  
   410  	return fieldValue
   411  }
   412  
   413  func mustFindErrorMsg(doc *goquery.Document) string {
   414  	var fieldValue string
   415  	doc.Find(".error-msg").Each(func(i int, s *goquery.Selection) {
   416  		fieldValue = s.Text()
   417  
   418  	})
   419  	return fieldValue
   420  }
   421  
   422  func extractInputsByFormID(doc *goquery.Document, formID string) (url.Values, string, error) {
   423  	return extractInputsByFormQuery(doc, fmt.Sprintf("#%s", formID))
   424  }
   425  
   426  func extractInputsByFormQuery(doc *goquery.Document, formQuery string) (url.Values, string, error) {
   427  	formData := url.Values{}
   428  	var actionURL string
   429  
   430  	query := fmt.Sprintf("form%s", formQuery)
   431  
   432  	//get action url
   433  	doc.Find(query).Each(func(i int, s *goquery.Selection) {
   434  		action, ok := s.Attr("action")
   435  		if !ok {
   436  			return
   437  		}
   438  		actionURL = action
   439  	})
   440  
   441  	query = fmt.Sprintf("form%s", formQuery)
   442  
   443  	// extract form data to passthrough
   444  	doc.Find(query).Find("input").Each(func(i int, s *goquery.Selection) {
   445  		name, ok := s.Attr("name")
   446  		if !ok {
   447  			return
   448  		}
   449  		val, ok := s.Attr("value")
   450  		if !ok {
   451  			return
   452  		}
   453  		logger.Debugf("name: %s value: %s", name, val)
   454  		formData.Add(name, val)
   455  	})
   456  
   457  	return formData, actionURL, nil
   458  }
   459  
   460  func extractNodeText(doc *goquery.Document, tag, txt string) string {
   461  
   462  	var res string
   463  
   464  	doc.Find(tag).Each(func(i int, s *goquery.Selection) {
   465  		if s.Text() == txt {
   466  			res = s.Text()
   467  		}
   468  	})
   469  
   470  	return res
   471  }
   472  
   473  func extractDataAttributes(doc *goquery.Document, query string, attrsToSelect []string) map[string]string {
   474  
   475  	dataAttrs := make(map[string]string)
   476  
   477  	doc.Find(query).Each(func(_ int, sel *goquery.Selection) {
   478  		for _, f := range attrsToSelect {
   479  			if val, ok := sel.Attr(f); ok {
   480  				dataAttrs[f] = val
   481  			}
   482  		}
   483  	})
   484  
   485  	return dataAttrs
   486  }