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 }