diff options
author | pg9182 <96569817+pg9182@users.noreply.github.com> | 2022-10-25 07:04:40 -0400 |
---|---|---|
committer | pg9182 <96569817+pg9182@users.noreply.github.com> | 2022-10-25 07:04:40 -0400 |
commit | c56dff0a9701218cc5bb0658c732c7c4ea5e5b21 (patch) | |
tree | 1bdb7706322608c58eeee6211898b4a1047908ec /pkg/origin | |
parent | b88653083bc6fcb8031548dbf4b4c4261de2873a (diff) | |
download | Atlas-c56dff0a9701218cc5bb0658c732c7c4ea5e5b21.tar.gz Atlas-c56dff0a9701218cc5bb0658c732c7c4ea5e5b21.zip |
all: Rewrite Origin auth (#7)
* all: Rewrite juno auth, split into separate packages
* pkg/juno: Implement two-factor auth
* pkg/origin: Add AuthMgr option to save HAR archives
* pkg/atlas: Add config option to save HAR archives
Diffstat (limited to 'pkg/origin')
-rw-r--r-- | pkg/origin/authmgr.go | 37 | ||||
-rw-r--r-- | pkg/origin/login.go | 637 |
2 files changed, 41 insertions, 633 deletions
diff --git a/pkg/origin/authmgr.go b/pkg/origin/authmgr.go index 9fefaab..7e4c06b 100644 --- a/pkg/origin/authmgr.go +++ b/pkg/origin/authmgr.go @@ -2,10 +2,16 @@ package origin import ( "context" + "encoding/json" "errors" "fmt" + "io" + "net/http" "sync" "time" + + "github.com/cardigann/harhar" + "github.com/r2northstar/atlas/pkg/juno" ) var ErrAuthMgrBackoff = errors.New("not refreshing token due to backoff") @@ -27,13 +33,16 @@ type AuthMgr struct { // Credentials, if provided, is called to get credentials when updating the // SID. - Credentials func() (email, password string, err error) + Credentials func() (email, password, otpsecret string, err error) // Backoff, if provided, checks if another refresh is allowed after a // failure. If it returns false, ErrAuthMgrBackoff will be returned // immediately from OriginAuth. Backoff func(err error, time time.Time, count int) bool + // SaveHAR, if provided, is called after every attempt to authenticate. + SaveHAR func(func(w io.Writer) error, error) + authInit sync.Once authPf bool // ensures only one update runs at a time authCv *sync.Cond // allows other goroutines to wait for that update to complete @@ -45,7 +54,7 @@ type AuthMgr struct { // AuthState contains the current authentication tokens. type AuthState struct { - SID SID `json:"sid,omitempty"` + SID juno.SID `json:"sid,omitempty"` NucleusToken NucleusToken `json:"nucleus_token,omitempty"` NucleusTokenExpiry time.Time `json:"nucleus_token_expiry,omitempty"` } @@ -106,6 +115,20 @@ func (a *AuthMgr) OriginAuth(refresh bool) (NucleusToken, bool, error) { } } a.authErr = func() (err error) { + t := http.DefaultClient.Transport + if t == nil { + t = http.DefaultTransport + } + if a.SaveHAR != nil { + rec := harhar.NewRecorder() + rec.RoundTripper, t = t, rec + defer func() { + go a.SaveHAR(func(w io.Writer) error { + return json.NewEncoder(w).Encode(rec.HAR) + }, err) + }() + } + defer func() { if p := recover(); p != nil { err = fmt.Errorf("panic: %v", p) @@ -124,7 +147,7 @@ func (a *AuthMgr) OriginAuth(refresh bool) (NucleusToken, bool, error) { defer cancel() if a.auth.SID != "" { - if tok, exp, aerr := GetNucleusToken(ctx, a.auth.SID); aerr == nil { + if tok, exp, aerr := GetNucleusToken(ctx, t, a.auth.SID); aerr == nil { a.auth.NucleusToken = tok a.auth.NucleusTokenExpiry = exp return @@ -136,16 +159,16 @@ func (a *AuthMgr) OriginAuth(refresh bool) (NucleusToken, bool, error) { if a.Credentials == nil { err = fmt.Errorf("no origin credentials to refresh sid with") return - } else if email, password, aerr := a.Credentials(); aerr != nil { + } else if email, password, otpsecret, aerr := a.Credentials(); aerr != nil { err = fmt.Errorf("get origin credentials: %w", aerr) return - } else if sid, aerr := Login(ctx, email, password); aerr != nil { + } else if res, aerr := juno.Login(ctx, t, email, password, otpsecret); aerr != nil { err = fmt.Errorf("refresh sid: %w", aerr) return } else { - a.auth.SID = sid + a.auth.SID = res.SID } - if tok, exp, aerr := GetNucleusToken(ctx, a.auth.SID); aerr != nil { + if tok, exp, aerr := GetNucleusToken(ctx, t, a.auth.SID); aerr != nil { err = fmt.Errorf("refresh nucleus token with new sid: %w", aerr) } else { a.auth.NucleusToken = tok diff --git a/pkg/origin/login.go b/pkg/origin/login.go index 553fefc..0d3f936 100644 --- a/pkg/origin/login.go +++ b/pkg/origin/login.go @@ -1,641 +1,34 @@ package origin import ( - "bytes" "context" "encoding/json" - "errors" "fmt" "io" - "math/rand" - "mime" "net/http" "net/http/cookiejar" - "net/url" - "regexp" - "strings" "time" - "github.com/andybalholm/cascadia" - "golang.org/x/net/html" - "golang.org/x/net/html/atom" + "github.com/r2northstar/atlas/pkg/juno" ) -var ErrInvalidLogin = errors.New("invalid credentials") - -type SID string type NucleusToken string -// Login logs into an Origin account and returns the SID cookie. -func Login(ctx context.Context, email, password string) (SID, error) { - jar, _ := cookiejar.New(nil) - - c := &http.Client{ - Transport: http.DefaultClient.Transport, - Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - switch host, _, _ := strings.Cut(req.URL.Host, ":"); strings.ToLower(host) { - case "accounts.ea.com", "signin.ea.com", "www.ea.com": - default: - return fmt.Errorf("domain %q is not whitelisted", host) - } - if len(via) >= 5 { - return fmt.Errorf("too many redirects") - } - return nil - }, - } - - r0, err := login0(ctx, c) - if err != nil { - return "", err - } - - r1, err := login1(ctx, c, r0, email, password) - if err != nil { - return "", err - } - - r2, again, err := login2(ctx, c, r1) - if err != nil { - return "", err - } - if !again { - r2, again, err = login2(ctx, c, r2) - if err != nil { - return "", err - } - if again { - return "", fmt.Errorf("looped at request to %q", r2.URL.String()) - } - } - - _, err = login3(ctx, c, r2) - if err != nil { - return "", err - } - - for _, ck := range c.Jar.Cookies(&url.URL{ - Scheme: "https", - Host: "accounts.ea.com", - Path: "/connect", - }) { - if ck.Name == "sid" { - return SID(ck.Value), nil - } - } - return "", fmt.Errorf("missing sid cookie") -} - -// login0 initializes the login flow. -// -// Returns a HTTP request for opening the login form. -func login0(ctx context.Context, c *http.Client) (*http.Request, error) { - // init locale and cookie settings - for _, host := range []string{"www.ea.com", "accounts.ea.com", "signin.ea.com"} { - c.Jar.SetCookies(&url.URL{ - Scheme: "https", - Host: host, - }, []*http.Cookie{ - {Name: "ealocale", Value: "en-us"}, - {Name: "notice_behavior", Value: "implied,us"}, - {Name: "notice_location", Value: "us"}, - }) - } - - // login page (from the www.ea.com homepage) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.ea.com/login", nil) - if err != nil { - return nil, err - } - - req.Header.Set("Referrer", "https://www.ea.com/en-us") - - return req, nil -} - -// login1 starts the login flow. -// -// - GET https://www.ea.com/en-us -// - 303 https://accounts.ea.com/connect/auth?response_type=code&redirect_uri=https://www.ea.com/login_check&state=...&locale=en_US&client_id=EADOTCOM-WEB-SERVER&display=junoWeb/login -// - 302 https://signin.ea.com/p/juno/login?fid=... -// - 302 https://signin.ea.com/p/juno/login?execution=...s1&initref=... -// -// Returns a HTTP request for submitting the login form. -func login1(ctx context.Context, c *http.Client, r0 *http.Request, email, password string) (*http.Request, error) { - resp, err := c.Do(r0) - if err != nil { - return nil, fmt.Errorf("start login flow: %w", err) - } - defer resp.Body.Close() - - buf, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("start login flow: %w", err) - } - - if resp.StatusCode != http.StatusOK { - if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt == "application/json" { - var obj struct { - Error string `json:"error"` - ErrorDescription fmt.Stringer `json:"error_description"` - Code int `json:"code"` - } - if err := json.Unmarshal(buf, &obj); err == nil && obj.Code != 0 { - return nil, fmt.Errorf("start login flow: %w: error %d: %s (%q)", ErrOrigin, obj.Code, obj.Error, obj.ErrorDescription) - } - } - return nil, fmt.Errorf("start login flow: request %q: response status %d (%s)", resp.Request.URL.String(), resp.StatusCode, resp.Status) - } - - if resp.Request.URL.Path != "/p/juno/login" { - return nil, fmt.Errorf("start login flow: unexpected login form path %q (the code probably needs to be updated)", resp.Request.URL.Path) - } - - doc, err := html.ParseWithOptions(bytes.NewReader(buf), html.ParseOptionEnableScripting(true)) - if err != nil { - return nil, fmt.Errorf("start login flow: parse document: %w", err) - } - - form := cascadia.Query(doc, cascadia.MustCompile(`form#login-form`)) - if form == nil { - return nil, fmt.Errorf("start login flow: parse document: failed to find login-form element") - } - - submitURL := &url.URL{ - Scheme: "https", - Host: resp.Request.URL.Host, - Path: resp.Request.URL.Path, - RawPath: resp.Request.URL.RawPath, - RawQuery: resp.Request.URL.RawQuery, - } - for _, a := range form.Attr { - if a.Namespace == "" { - switch strings.ToLower(a.Key) { - case "action": - if v, err := resp.Request.URL.Parse(a.Val); err == nil { - submitURL = v - } else { - return nil, fmt.Errorf("start login flow: parse document: resolve form submit url: %w", err) - } - case "method": - if a.Val != "" && strings.ToLower(a.Val) != "post" { - return nil, fmt.Errorf("start login flow: parse document: unexpected form method %q", a.Val) - } - case "enctype": - if a.Val != "" && strings.ToLower(a.Val) != "application/x-www-form-urlencoded" { - return nil, fmt.Errorf("start login flow: parse document: unexpected form method %q", a.Val) - } - } - } - } - - /* - <form id="login-form" method="post"> - <div class="otkform otkform-inputgroup input-margin-bottom-error-message"> - <div id="email-phone-login-div"> - <div id="toggle-form-email-input"> - <div class="otkform-group"> - <label class="otklabel label-uppercase" for="email">Phone or Email</label> - <div class="otkinput otkinput-grouped otkform-group-field input-margin-bottom-error-message"> - <input type="text" id="email" name="email" value="" placeholder="Enter your phone or email" autocorrect="off" autocapitalize="none" autocomplete="off"> - </div> - <div id="online-input-error-email" class="otkform-group-help"> - <p class="otkinput-errormsg otkc"></p> - </div> - </div> - </div> - <div id="toggle-form-phone-number-input" style="display: none;"> - <div class="otkform-group input-margin-bottom-error-message"> - <label class="otklabel label-uppercase">Phone or Email</label> - <span class="otkselect" style="display: none;"> - <select id="regionCode" name="regionCode"> - ... - </select> - <span class="otkselect-label otkselect-placeholder phone-number-placeholder"></span> - <span class="otkselect-label otkselect-selected phone-number-pad">(+1)</span> - </span> - <div style="display: none;" id="hidden-svg-container"></div> - <div class="otkinput otkinput-grouped otkform-group-field"> - <a href="javascript:void(0);" class="region-select-drop-down-btn"> - <span class="quantum-input-icon"> - <svg> - <use xlink:href="#ca"></use> - </svg> - </span> - <span class="quantum-input-icon-2">+1</span> - </a> - <div class="region-select-drop-down-panel" style="display: none;">... - </div> - <input type="text" id="phoneNumber" name="phoneNumber" value="" placeholder="Enter your phone or email" autocorrect="off" autocapitalize="none" autocomplete="tel"> - </div> - </div> - </div> - </div> - <label class="otklabel label-uppercase" for="password">Password</label> - <div class="otkinput otkinput-grouped input-margin-bottom-error-message"> - <input type="password" id="password" name="password" placeholder="Enter your password" autocorrect="off" autocapitalize="none" autocomplete="off"> - <i class="otkinput-capslock otkicon otkicon-capslock otkicon-capslock-position"></i> - <button role="button" aria-label="Show password" id="passwordShow" class="otkbtn passwordShowBtn"> - <span id="showIcon" class="quantum-input-icon eye-icon"><svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"> - <g fill="none" fill-rule="evenodd"> - <path d="m0 0h16v16h-16z"></path> - <path d="m8 3.66666667c3.2032732 0 6.6666667 2.54318984 6.6666667 4.33333333s-3.4633935 4.3333333-6.6666667 4.3333333c-3.20327316 0-6.66666667-2.54318981-6.66666667-4.3333333s3.46339351-4.33333333 6.66666667-4.33333333zm0 1.33333333c-1.38181706 0-2.74575629.50269607-3.87107038 1.3290207-.87134632.63983463-1.46226295 1.41228951-1.46226295 1.6709793s.59091663 1.03114467 1.46226295 1.6709793c1.12531409.8263246 2.48925332 1.3290207 3.87107038 1.3290207s2.7457563-.5026961 3.8710704-1.3290207c.8713463-.63983463 1.4622629-1.41228951 1.4622629-1.6709793s-.5909166-1.03114467-1.4622629-1.6709793c-1.1253141-.82632463-2.48925334-1.3290207-3.8710704-1.3290207zm0 1c1.1045695 0 2 .8954305 2 2s-.8954305 2-2 2-2-.8954305-2-2 .8954305-2 2-2zm0 1.33333333c-.36818983 0-.66666667.29847684-.66666667.66666667s.29847684.66666667.66666667.66666667.66666667-.29847684.66666667-.66666667-.29847684-.66666667-.66666667-.66666667z" fill="#fff"></path> - </g> - </svg></span><span id="hideIcon" class="quantum-input-icon eye-icon hide-icon"><svg viewBox="0 0 24 24" height="16" width="16" xmlns="http://www.w3.org/2000/svg"> - <g fill-rule="evenodd" fill="none"> - <path d="M14.318 14.404a3 3 0 0 0-4.223-4.223l1.436 1.436a1 1 0 0 1 1.352 1.352l1.435 1.435zm-5.27-2.442a3 3 0 0 0 3.49 3.49l-3.49-3.49z" fill="#fff"></path> - <path d="M17.484 17.57c.546-.294 1.05-.617 1.506-.951.875-.643 1.597-1.348 2.11-2.02a5.54 5.54 0 0 0 .628-1.01c.15-.323.272-.7.272-1.089s-.122-.766-.272-1.088A5.54 5.54 0 0 0 21.1 10.4c-.514-.67-1.235-1.376-2.11-2.019C17.245 7.1 14.793 6 12 6c-1.824 0-3.503.47-4.934 1.152L8.585 8.67A9.309 9.309 0 0 1 12 8c2.27 0 4.318.9 5.807 1.994.742.545 1.321 1.12 1.704 1.621.192.251.323.468.403.64.071.153.083.231.086.244v.001c-.003.014-.015.092-.086.245a3.57 3.57 0 0 1-.403.64c-.383.5-.962 1.076-1.704 1.622a10.73 10.73 0 0 1-1.812 1.073l1.489 1.49zM6.718 9.632a9.89 9.89 0 0 0-.525.362c-.742.545-1.321 1.12-1.704 1.621a3.57 3.57 0 0 0-.403.64c-.071.153-.083.231-.086.244v.001c.003.014.015.092.086.245.08.172.21.389.403.64.383.5.962 1.076 1.704 1.622C7.683 16.1 9.73 17 12 17a8.92 8.92 0 0 0 1.882-.204l1.626 1.627c-1.08.357-2.26.577-3.508.577-2.792 0-5.245-1.1-6.99-2.381-.875-.643-1.597-1.348-2.11-2.02a5.544 5.544 0 0 1-.628-1.01c-.15-.323-.272-.7-.272-1.089s.122-.766.272-1.088A5.61 5.61 0 0 1 2.9 10.4c.513-.67 1.235-1.376 2.11-2.019.087-.064.176-.127.267-.19l1.44 1.441z" fill="#fff"></path> - <path d="M3.543 2.793a1 1 0 0 1 1.414 0l17 17a1 1 0 0 1-1.414 1.414l-17-17a1 1 0 0 1 0-1.414z" fill="#fff"></path> - </g> - </svg></span> - </button> - </div> - </div> - - <div id="online-general-error" class="otkform-group-help"> - <p class="otkinput-errormsg otkc"></p> - </div> - <div id="offline-general-error" class="otkform-group-help"> - <p class="otkinput-errormsg otkc">You must be online when logging in for the first time.</p> - </div> - <div id="offline-auth-error" class="otkform-group-help"> - <p class="otkinput-errormsg otkc">Your credentials are incorrect or have expired. Please try again or reset your password.</p> - </div> - - <div id="captcha-container"> - </div> - - <div class="panel-action-area"> - <input type="hidden" name="_eventId" value="submit" id="_eventId"> - <input type="hidden" id="cid" name="cid" value=""> - - <input type="hidden" id="showAgeUp" name="showAgeUp" value="true"> - - <input type="hidden" id="thirdPartyCaptchaResponse" name="thirdPartyCaptchaResponse" value=""> - - <input type="hidden" id="loginMethod" name="loginMethod" value=""> - - <span class="otkcheckbox checkbox-login-first"> - <input type="hidden" name="_rememberMe" value="on"> - <input type="checkbox" id="rememberMe" name="rememberMe" checked="checked"> - <label for="rememberMe"> - <span id="content" class="link-in-message">Remember me</span> - - </label> - </span> - <div class="button-top-separator"></div> - <a role="button" class="otkbtn otkbtn-primary " href="javascript:void(0);" id="logInBtn">Sign in</a> - <input type="hidden" id="errorCode" value=""> - <input type="hidden" id="errorCodeWithDescription" value=""> - <input type="hidden" id="storeKey" value=""> - <input type="hidden" id="bannerType" value=""> - <input type="hidden" id="bannerText" value=""> - </div> - - </form> - */ - - data := url.Values{} - for _, el := range cascadia.QueryAll(form, cascadia.MustCompile(`[name]`)) { - if el.DataAtom == atom.A { - continue - } - var eName, eValue, eType string - var eChecked bool - for _, a := range el.Attr { - if a.Namespace == "" { - switch strings.ToLower(a.Key) { - case "name": - eName = a.Val - case "value": - eValue = a.Val - case "type": - eType = strings.ToLower(a.Val) - case "checked": - eChecked = true - } - } - } - if el.DataAtom != atom.Input { - if el.DataAtom == atom.Select && eName == "regionCode" { - eValue = "US" - } else { - return nil, fmt.Errorf("start login flow: parse document: unexpected form %s element %s", el.DataAtom, eName) - } - } - if eType == "submit" || eType == "reset" || eType == "image" || eType == "button" { - continue // ignore buttons - } - if eChecked && eValue == "" { - eValue = "on" - } - if eName == "cid" { - eValue = generateCID() // populated by js - } - if (eType == "checkbox" || eType == "radio") && eValue == "" { - continue - } - data.Set(eName, eValue) - } - if !data.Has("loginMethod") || !data.Has("email") || !data.Has("password") { - return nil, fmt.Errorf("start login flow: parse document: missing username or password field (data=%s)", data.Encode()) - } - - data.Set("loginMethod", "emailPassword") - data.Set("email", email) - data.Set("password", password) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, submitURL.String(), strings.NewReader(data.Encode())) - if err == nil { - req.Header.Set("Referrer", resp.Request.URL.String()) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - return req, err -} - -var login2re = regexp.MustCompile(`(?m)window.location\s*=\s*["'](https://[^"'\\]+/auth[^"'\\]+)["']`) - -// login2 submits the login form. -// -// TOS Update (step 1): -// - POST https://signin.ea.com/p/juno/login?execution=...s1&initref=https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER -// (email=...®ionCode=US&phoneNumber=&password=...&_eventId=submit&cid=...&showAgeUp=true&thirdPartyCaptchaResponse=&loginMethod=emailPassword&_rememberMe=on&rememberMe=on) -// - 302 https://signin.ea.com/p/juno/login?execution=...s2&initref=https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER -// -// TOS Update (step 2): -// - POST https://signin.ea.com/p/juno/login?execution=...s2&initref=https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER -// (_readAccept=on&readAccept=on&_eventId=accept) -// window.location = "https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER&fid=..."; -// -// Normal: -// - POST https://signin.ea.com/p/juno/login?execution=...s1&initref=https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER -// (email=...®ionCode=US&phoneNumber=&password=...&_eventId=submit&cid=...&showAgeUp=true&thirdPartyCaptchaResponse=&loginMethod=emailPassword&_rememberMe=on&rememberMe=on) -// window.location = "https://accounts.ea.com:443/connect/auth?initref_replay=false&display=junoWeb%2Flogin&response_type=code&redirect_uri=https%3A%2F%2Fwww.ea.com%2Flogin_check&locale=en_CA&client_id=EADOTCOM-WEB-SERVER&fid=..."; -// -// Returns another request if another submission is needed to accept the TOS before proceeding. -func login2(ctx context.Context, c *http.Client, r1 *http.Request) (*http.Request, bool, error) { - resp, err := c.Do(r1) - if err != nil { - return nil, false, fmt.Errorf("submit login form: %w", err) - } - defer resp.Body.Close() - - buf, err := io.ReadAll(resp.Body) - if err != nil { - return nil, false, fmt.Errorf("submit login form: %w", err) - } - - if resp.StatusCode != http.StatusOK { - if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt == "application/json" { - var obj struct { - Error string `json:"error"` - ErrorDescription fmt.Stringer `json:"error_description"` - Code int `json:"code"` - } - if err := json.Unmarshal(buf, &obj); err == nil && obj.Code != 0 { - return nil, false, fmt.Errorf("submit login form: %w: error %d: %s (%q)", ErrOrigin, obj.Code, obj.Error, obj.ErrorDescription) - } - } - return nil, false, fmt.Errorf("submit login form: request %q: response status %d (%s)", resp.Request.URL.String(), resp.StatusCode, resp.Status) - } - - m := login2re.FindSubmatch(buf) - if m == nil { - if doc, err := html.Parse(bytes.NewReader(buf)); err == nil { - if form := cascadia.Query(doc, cascadia.MustCompile(`form#loginForm #tfa-login`)); form != nil { - return nil, false, fmt.Errorf("submit login form: needs 2fa code") - } - if form := cascadia.Query(doc, cascadia.MustCompile(`form#tosForm`)); form != nil { - submitURL := &url.URL{ - Scheme: "https", - Host: resp.Request.URL.Host, - Path: resp.Request.URL.Path, - RawPath: resp.Request.URL.RawPath, - RawQuery: resp.Request.URL.RawQuery, - } - for _, a := range form.Attr { - if a.Namespace == "" { - switch strings.ToLower(a.Key) { - case "action": - if v, err := resp.Request.URL.Parse(a.Val); err == nil { - submitURL = v - } else { - return nil, false, fmt.Errorf("submit login form: parse document: tos form: resolve form submit url: %w", err) - } - case "method": - if a.Val != "" && strings.ToLower(a.Val) != "post" { - return nil, false, fmt.Errorf("submit login form: parse document: tos form: unexpected form method %q", a.Val) - } - case "enctype": - if a.Val != "" && strings.ToLower(a.Val) != "application/x-www-form-urlencoded" { - return nil, false, fmt.Errorf("submit login form: parse document: tos form: unexpected form method %q", a.Val) - } - } - } - } - - /* - <form method="post" id="tosForm"> - <div id="tos-update" class="views"> - <section id="tosReview"> - <a href=javascript:void(0); role="button" id=back class="back-btn"> - <i class="otkicon"></i> - <span class="quantum-input-icon back-btn-icon"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="injected-svg css-ojkidl" data-src="/static/media/ARROW_LEFT.e40e5952.svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <g id="Icons/SM/Navigation/Arrow-Left" fill="none" fill-rule="evenodd"> - <path id="Bounding-Box" d="M0 0h16v16H0z"></path> - <path id="Primary" fill="#FFF" d="M5.293 4.707a1 1 0 0 1 1.414-1.414l4 4a1 1 0 0 1 0 1.415l-4 4a1 1 0 1 1-1.414-1.415L8.586 8 5.293 4.707z" transform="matrix(-1 0 0 1 16 0)"></path> - </g> - </svg> </span> - Back - </a> - - <!-- logo --> - <img class="header" aria-hidden="true" src="https://eaassets-a.akamaihd.net/resource_signin_ea_com/551.0.220928.064.d05eced/p/statics/juno/img/EALogo-New.svg" /> - - <h1 id="page_header" class="otktitle otktitle-2">Please review our terms</h1> - - <div class="general-error"> - <div> - <div></div> - </div> - </div> - - - <p class="otkc link-in-message">Thank you for choosing EA. Please take a few minutes to review our latest <a href="https://tos.ea.com/legalapp/webterms/CA/en/PC2/" target="_blank">User Agreement</a> and <a href="https://tos.ea.com/legalapp/webprivacy/CA/en/PC2/" target="_blank">Privacy and Cookie Policy</a>.</p> - <span class="otkcheckbox "> - <input type="hidden" name="_readAccept" value="on" /> - <input type="checkbox" id="readAccept" name="readAccept" /> - <label for=readAccept> - <span id="content" class="link-in-message">I have read and accept the <a href="https://tos.ea.com/legalapp/webterms/CA/en/PC2/" target="_blank">User Agreement</a> and <a href="https://tos.ea.com/legalapp/webprivacy/CA/en/PC2/" target="_blank">EA's Privacy and Cookie Policy</a>.</span> - - </label> - </span> - - <a role="button" class='otkbtn otkbtn-primary right' href="javascript:void(0);" id="btnNext">Next</a> - <input type="hidden" name="_eventId" value="accept" id="_eventId" /> - </section> - </div> - </form> - */ - - data := url.Values{} - for _, el := range cascadia.QueryAll(form, cascadia.MustCompile(`[name]`)) { - if el.DataAtom == atom.A { - continue - } - var eName, eValue, eType string - var eChecked bool - for _, a := range el.Attr { - if a.Namespace == "" { - switch strings.ToLower(a.Key) { - case "name": - eName = a.Val - case "value": - eValue = a.Val - case "type": - eType = strings.ToLower(a.Val) - case "checked": - eChecked = true - } - } - } - if el.DataAtom != atom.Input { - return nil, false, fmt.Errorf("submit login form: parse document: tos form: unexpected form %s element %s", el.DataAtom, eName) - } - if eType == "submit" || eType == "reset" || eType == "image" || eType == "button" { - continue // ignore buttons - } - if eChecked && eValue == "" { - eValue = "on" - } - if (eType == "checkbox" || eType == "radio") && eValue == "" { - continue - } - data.Set(eName, eValue) - } - if !data.Has("_readAccept") { - return nil, false, fmt.Errorf("submit login form: parse document: tos form: missing readAccept field (data=%s)", data.Encode()) - } - - data.Set("_readAccept", "on") - data.Set("readAccept", "on") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, submitURL.String(), strings.NewReader(data.Encode())) - if err == nil { - req.Header.Set("Referrer", resp.Request.URL.String()) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - return req, false, err - } - if n := cascadia.Query(doc, cascadia.MustCompile(`#errorCode[value]`)); n != nil { - for _, a := range n.Attr { - // based on juno login js - if a.Namespace == "" && strings.EqualFold(a.Key, "value") { - switch errCode := a.Val; errCode { - case "10001": // try offline auth - return nil, false, fmt.Errorf("submit login form: ea auth error %s: why the fuck does origin think we're offline", errCode) - case "10002": // credentials - return nil, false, fmt.Errorf("submit login form: ea auth error %s: %w", errCode, ErrInvalidLogin) - case "10003": // general error - return nil, false, fmt.Errorf("submit login form: ea auth error %s: login error", errCode) - case "10004": // wtf - return nil, false, fmt.Errorf("submit login form: ea auth error %s: idk wtf this is", errCode) - case "": - // no error, but this shouldn't happen - default: - return nil, false, fmt.Errorf("submit login form: ea auth error %s", errCode) - } - } - } - } - } - return nil, false, fmt.Errorf("submit login form: could not find JS redirect URL") - } - - u, err := resp.Request.URL.Parse(string(m[1])) - if err != nil { - return nil, false, fmt.Errorf("submit login form: could not resolve JS redirect URL %q against %q", string(m[1]), resp.Request.URL.String()) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err == nil { - req.Header.Set("Referrer", resp.Request.URL.String()) - } - return req, true, err -} - -// login3 finishes the login flow. -// -// - GET https://accounts.ea.com/connect/auth?initref_replay=false&display=junoWeb/login&response_type=code&redirect_uri=https://www.ea.com/login_check&locale=en_US&client_id=EADOTCOM-WEB-SERVER&fid=... -// - 302 https://www.ea.com/login_check?code=...&state=... -// - 303 https://www.ea.com/ -// - 302 https://www.ea.com/en-us/ -// - 301 https://www.ea.com/en-us -// -// Returns the token. -func login3(_ context.Context, c *http.Client, r2 *http.Request) (string, error) { - resp, err := c.Do(r2) - if err != nil { - return "", fmt.Errorf("finish login flow: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if resp.Request.URL.Hostname() == r2.URL.Hostname() { - buf, _ := io.ReadAll(resp.Body) - var obj struct { - ErrorCode string `json:"error_code"` - Error string `json:"error"` - ErrorNumber json.Number `json:"error_number"` - } - if obj.ErrorCode == "login_required" { - return "", fmt.Errorf("finish login flow: %w: wants us to login, but we just did that", ErrOrigin) - } - if err := json.Unmarshal(buf, &obj); err == nil && obj.Error != "" { - return "", fmt.Errorf("finish login flow: %w: error %s: %s (%q)", ErrOrigin, obj.ErrorNumber, obj.ErrorCode, obj.Error) - } - return "", fmt.Errorf("finish login flow: request %q: response status %d (%s)", resp.Request.URL.String(), resp.StatusCode, resp.Status) - } - return "", fmt.Errorf("finish login flow: request %q: response status %d (%s)", resp.Request.URL.String(), resp.StatusCode, resp.Status) - } - - // don't waste the connection - _, _ = io.Copy(io.Discard, resp.Body) - - last := resp.Request - for last != nil && last.URL.Hostname() != r2.URL.Hostname() { - code := last.URL.Query().Get("code") - if code != "" { - return code, nil - } - last = last.Response.Request - } - return "", fmt.Errorf("finish login flow: failed to extract token from redirect chain ending in %q", resp.Request.URL.String()) -} - // GetNucleusToken generates a Nucleus AuthToken from the active session. Note // that this token generally lasts ~4h. // // If errors.Is(err, ErrAuthRequired), you need a new SID. -func GetNucleusToken(ctx context.Context, sid SID) (NucleusToken, time.Time, error) { - jar, _ := cookiejar.New(nil) +func GetNucleusToken(ctx context.Context, t http.RoundTripper, sid juno.SID) (NucleusToken, time.Time, error) { + if t == nil { + t = http.DefaultClient.Transport + } + jar, _ := cookiejar.New(nil) c := &http.Client{ - Transport: http.DefaultClient.Transport, + Transport: t, Jar: jar, } - - c.Jar.SetCookies(&url.URL{ - Scheme: "https", - Host: "accounts.ea.com", - Path: "/connect", - }, []*http.Cookie{{ - Name: "sid", - Value: string(sid), - Secure: true, - }}) + sid.AddTo(c.Jar) req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://accounts.ea.com/connect/auth?client_id=ORIGIN_JS_SDK&response_type=token&redirect_uri=nucleus:rest&prompt=none&release_type=prod", nil) if err != nil { @@ -645,6 +38,9 @@ func GetNucleusToken(ctx context.Context, sid SID) (NucleusToken, time.Time, err req.Header.Set("Referrer", "https://www.origin.com/") req.Header.Set("Origin", "https://www.origin.com/") + req.Header.Set("Accept-Language", "en-US;q=0.7,en;q=0.3") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36") + resp, err := c.Do(req) if err != nil { return "", time.Time{}, fmt.Errorf("get nucleus token: %w", err) @@ -691,14 +87,3 @@ func GetNucleusToken(ctx context.Context, sid SID) (NucleusToken, time.Time, err } return NucleusToken(obj.AccessToken), expiry, nil } - -// generateCID generates a login nonce using the algorithm in the Origin login -// js script. -func generateCID() string { - const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" - b := make([]byte, 32) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] - } - return string(b) -} |