diff options
-rw-r--r-- | cmd/origin-login-test/main.go | 12 | ||||
-rw-r--r-- | pkg/atlas/config.go | 14 | ||||
-rw-r--r-- | pkg/atlas/server.go | 91 | ||||
-rw-r--r-- | pkg/juno/login.go | 716 | ||||
-rw-r--r-- | pkg/origin/authmgr.go | 37 | ||||
-rw-r--r-- | pkg/origin/login.go | 637 |
6 files changed, 863 insertions, 644 deletions
diff --git a/cmd/origin-login-test/main.go b/cmd/origin-login-test/main.go index 8a51168..32634d7 100644 --- a/cmd/origin-login-test/main.go +++ b/cmd/origin-login-test/main.go @@ -9,6 +9,7 @@ import ( "time" "github.com/cardigann/harhar" + "github.com/r2northstar/atlas/pkg/juno" "github.com/r2northstar/atlas/pkg/origin" "github.com/spf13/pflag" ) @@ -28,8 +29,8 @@ func init() { func main() { pflag.Parse() - if pflag.NArg() != 2 || opt.Help { - fmt.Printf("usage: %s [options] email password\n\noptions:\n%s\nwarning: do not use this tool repeatedly, or you may trigger additional verification, which will break login\n", os.Args[0], pflag.CommandLine.FlagUsages()) + if pflag.NArg() < 2 || pflag.NArg() > 3 || opt.Help { + fmt.Printf("usage: %s [options] email password [totp_secret]\n\noptions:\n%s\nwarning: do not use this tool repeatedly, or you may trigger additional verification, which will break login\n", os.Args[0], pflag.CommandLine.FlagUsages()) if opt.Help { os.Exit(2) } @@ -68,16 +69,17 @@ func main() { var fail bool ctx := context.Background() - sid, err := origin.Login(ctx, pflag.Arg(0), pflag.Arg(1)) + r, err := juno.Login(ctx, nil, pflag.Arg(0), pflag.Arg(1), pflag.Arg(2)) if err != nil { fmt.Fprintf(os.Stderr, "origin: error: %v\n", err) fail = true } else { - fmt.Printf("SID=%s\n", sid) + fmt.Printf("SID=%s\n", r.SID) + fmt.Printf("JunoCode=%s\n", r.Code) } if !fail { - token, expiry, err := origin.GetNucleusToken(ctx, sid) + token, expiry, err := origin.GetNucleusToken(ctx, nil, r.SID) if err != nil { fmt.Fprintf(os.Stderr, "origin: error: %v\n", err) fail = true diff --git a/pkg/atlas/config.go b/pkg/atlas/config.go index 74b667c..5785d39 100644 --- a/pkg/atlas/config.go +++ b/pkg/atlas/config.go @@ -138,6 +138,20 @@ type Config struct { // The password for Origin login. OriginPassword string `env:"ATLAS_ORIGIN_PASSWORD"` + // The base32 TOTP secret for Origin login. + OriginTOTP string `env:"ATLAS_ORIGIN_TOTP"` + + // OriginHARGzip controls whether to compress saved HAR archives. + OriginHARGzip bool `env:"ATLAS_ORIGIN_HAR_GZIP"` + + // OriginHARSuccess is the path to a directory to save HAR archives of + // successful Origin auth attempts. + OriginHARSuccess string `env:"ATLAS_ORIGIN_HAR_SUCCESS"` + + // OriginHARError is the path to a directory to save HAR archives of + // successful Origin auth attempts. + OriginHARError string `env:"ATLAS_ORIGIN_HAR_ERROR"` + // The JSON file to save Origin login info to so tokens are preserved across // restarts. Highly recommended. OriginPersist string `env:"ATLAS_ORIGIN_PERSIST"` diff --git a/pkg/atlas/server.go b/pkg/atlas/server.go index c7b3e2e..fc7f9be 100644 --- a/pkg/atlas/server.go +++ b/pkg/atlas/server.go @@ -20,6 +20,7 @@ import ( "time" "github.com/VictoriaMetrics/metrics" + "github.com/klauspost/compress/gzip" "github.com/r2northstar/atlas/db/atlasdb" "github.com/r2northstar/atlas/db/pdatadb" "github.com/r2northstar/atlas/pkg/api/api0" @@ -208,7 +209,6 @@ func NewServer(c *Config) (*Server, error) { ServerList: api0.NewServerList(c.API0_ServerList_DeadTime, c.API0_ServerList_GhostTime, c.API0_ServerList_VerifyTime, api0.ServerListConfig{ ExperimentalDeterministicServerIDSecret: c.API0_ServerList_ExperimentalDeterministicServerIDSecret, }), - OriginAuthMgr: configureOrigin(c, s.Logger.With().Str("component", "origin").Logger()), MaxServers: c.API0_MaxServers, MaxServersPerIP: c.API0_MaxServersPerIP, InsecureDevNoCheckPlayerAuth: c.API0_InsecureDevNoCheckPlayerAuth, @@ -222,6 +222,11 @@ func NewServer(c *Config) (*Server, error) { Add(hlog.RequestIDHandler("rid", "")). Then(http.HandlerFunc(s.serveRest)) + if org, err := configureOrigin(c, s.Logger.With().Str("component", "origin").Logger()); err == nil { + s.API0.OriginAuthMgr = org + } else { + return nil, fmt.Errorf("initialize origin auth: %w", err) + } if astore, err := configureAccountStorage(c); err == nil { s.API0.AccountStorage = astore } else { @@ -385,14 +390,14 @@ func configureLogging(c *Config) (l zerolog.Logger, reopen func(), err error) { return } -func configureOrigin(c *Config, l zerolog.Logger) *origin.AuthMgr { +func configureOrigin(c *Config, l zerolog.Logger) (*origin.AuthMgr, error) { if c.OriginEmail == "" { - return nil + return nil, nil } var mu sync.Mutex mgr := &origin.AuthMgr{ - Credentials: func() (email string, password string, err error) { - return c.OriginEmail, c.OriginPassword, nil + Credentials: func() (email, password, otpsecret string, err error) { + return c.OriginEmail, c.OriginPassword, c.OriginTOTP, nil }, Backoff: func(_ error, last time.Time, count int) bool { var hmax, hmaxat, hrate float64 = 24, 8, 2.3 @@ -436,7 +441,81 @@ func configureOrigin(c *Config, l zerolog.Logger) *origin.AuthMgr { mgr.SetAuth(as) } } - return mgr + if c.OriginHARError != "" || c.OriginHARSuccess != "" { + var errPath, successPath string + if v := c.OriginHARError; v != "" { + if p, err := filepath.Abs(v); err != nil { + return nil, fmt.Errorf("resolve error har path: %w", err) + } else if err := os.MkdirAll(v, 0777); err != nil { + return nil, fmt.Errorf("mkdir error har path: %w", err) + } else { + errPath = p + } + } + if v := c.OriginHARSuccess; v != "" { + if p, err := filepath.Abs(v); err != nil { + return nil, fmt.Errorf("resolve success har path: %w", err) + } else if err := os.MkdirAll(v, 0777); err != nil { + return nil, fmt.Errorf("mkdir success har path: %w", err) + } else { + successPath = p + } + } + var harMu sync.Mutex + harZ := gzip.NewWriter(io.Discard) + mgr.SaveHAR = func(write func(w io.Writer) error, err error) { + harMu.Lock() + defer harMu.Unlock() + + var p string + if err != nil { + if errPath != "" { + p = filepath.Join(errPath, "origin-auth-error-") + } + } else { + if successPath != "" { + p = filepath.Join(successPath, "origin-auth-success-") + } + } + if p != "" { + p = p + strconv.FormatInt(time.Now().Unix(), 10) + ".har" + + if c.OriginHARGzip { + p += ".gz" + } + + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + l.Err(err).Msg("failed to save origin auth har") + return + } + defer f.Close() + + if c.OriginHARGzip { + harZ.Reset(f) + if err := write(harZ); err != nil { + l.Err(err).Msg("failed to save origin auth har") + return + } + if err := harZ.Close(); err != nil { + l.Err(err).Msg("failed to save origin auth har") + return + } + } else { + if err := write(f); err != nil { + l.Err(err).Msg("failed to save origin auth har") + return + } + } + + if err := f.Close(); err != nil { + l.Err(err).Msg("failed to save origin auth har") + return + } + } + } + } + return mgr, nil } func configureAccountStorage(c *Config) (api0.AccountStorage, error) { diff --git a/pkg/juno/login.go b/pkg/juno/login.go new file mode 100644 index 0000000..d9a10ef --- /dev/null +++ b/pkg/juno/login.go @@ -0,0 +1,716 @@ +// Package juno implements a client for the EA juno login flow. +package juno + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "hash" + "io" + "math" + "math/rand" + "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" +) + +var ( + ErrCaptchaRequired = errors.New("captcha required") + ErrInvalidTwoFactor = errors.New("invalid two factor code") + + ErrJuno = junoLoginError{} + ErrOnlineLoginNotAvailable = junoLoginError{Code: "10001"} + ErrInvalidCredentials = junoLoginError{Code: "10002"} // note: triggering this too many times will result in a captcha + ErrJunoInternalError = junoLoginError{Code: "10003"} +) + +// AuthResult contains authentication tokens. +type AuthResult struct { + Code string + SID SID +} + +// SID is a persistent EA login session ID. +type SID string + +// AddTo adds the SID cookie to j. +func (s SID) AddTo(j http.CookieJar) { + j.SetCookies(&url.URL{ + Scheme: "https", + Host: "accounts.ea.com", + Path: "/connect", + }, []*http.Cookie{{ + Name: "sid", + Value: string(s), + Secure: true, + }}) +} + +// Login gets the SID fo +func Login(ctx context.Context, rt http.RoundTripper, email, password, otpsecret string) (AuthResult, error) { + if rt == nil { + rt = http.DefaultClient.Transport + } + + s := &junoLoginState{ + Email: email, + Password: password, + } + + if otpsecret != "" { + b, err := base32.StdEncoding.DecodeString(strings.ToUpper(strings.ReplaceAll(otpsecret, " ", ""))) + if err != nil { + return AuthResult{}, fmt.Errorf("parse totp secret: %w", err) + } + s.TOTP = func(t time.Time) string { + return hotp(totp(t, 0), b, 0, nil) + } + } + + j, _ := cookiejar.New(nil) + c := &http.Client{ + Transport: rt, + Jar: j, + } + + 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"}, + }) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.ea.com/login", nil) + if err != nil { + return AuthResult{}, err + } + req.Header.Set("Referrer", "https://www.ea.com/en-us/") + + var reqs []string + for { + 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") + + if reqs = append(reqs, req.Method+" "+req.URL.String()); len(reqs) > 10 { + return AuthResult{}, fmt.Errorf("too many requests (%q)", reqs) + } + + resp, err := c.Do(req) + if err != nil { + return AuthResult{}, fmt.Errorf("do %s %q: %w", req.Method, req.URL.String(), err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return AuthResult{}, fmt.Errorf("do %s %q: response status %d (%s)", req.Method, req.URL.String(), resp.StatusCode, resp.Status) + } + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return AuthResult{}, fmt.Errorf("do %s %q: read response: %w", req.Method, req.URL.String(), err) + } + + var via []string + for last := resp.Request; last != nil; { + if via = append(via, last.URL.String()); last.Response != nil { + last = last.Response.Request + } else { + last = nil + } + } + + switch { + case asciiEqualFold(resp.Request.URL.Hostname(), "www.ea.com"): + for last := resp.Request; last != nil; last = last.Response.Request { + if code := last.URL.Query().Get("code"); code != "" { + for _, ck := range c.Jar.Cookies(&url.URL{ + Scheme: "https", + Host: "accounts.ea.com", + Path: "/connect", + }) { + if ck.Name == "sid" { + return AuthResult{ + Code: code, + SID: SID(ck.Value), + }, nil + } + } + return AuthResult{}, fmt.Errorf("missing sid cookie") + } + } + return AuthResult{}, fmt.Errorf("do %s %q: unhandled response url (%q): back to homepage, but could not find auth code", req.Method, req.URL.String(), via) + + case asciiEqualFold(resp.Request.URL.Hostname(), "signin.ea.com"): + if !strings.HasPrefix(resp.Request.URL.Path, "/p/juno/") { + return AuthResult{}, fmt.Errorf("do %s %q: unhandled response url (%q): not juno", req.Method, req.URL.String(), via) + } + + default: + return AuthResult{}, fmt.Errorf("do %s %q: unhandled response url (%q)", req.Method, req.URL.String(), via) + } + + doc, err := html.ParseWithOptions(bytes.NewReader(buf), html.ParseOptionEnableScripting(true)) + if err != nil { + return AuthResult{}, fmt.Errorf("do %s %q: parse document: %w", req.Method, req.URL.String(), err) + } + resp.Body.Close() + + req, err = s.junoLoginStep(resp.Request.URL, doc) + if err != nil { + return AuthResult{}, err + } + req = req.WithContext(ctx) + } +} + +type junoLoginState struct { + Email string + Password string + TOTP func(time.Time) string + + seenLogin bool + seenTOS bool + seenTwoFactor bool + seenTwoFactorCode bool + seenEnd bool +} + +type junoLoginError struct { + Code string + Desc string +} + +func (err junoLoginError) Error() string { + var codeDesc string + switch err.Code { + case "10001": + codeDesc = ": online login not available" + case "10002": + codeDesc = ": invalid credentials" + case "10003": + codeDesc = ": internal error" + case "10004": + codeDesc = ": wtf" // idk what this is + case "": + return fmt.Sprintf("juno error (%q)", err.Desc) + } + if err.Desc == "" { + return fmt.Sprintf("juno error %s%s (%q)", err.Code, codeDesc, err.Desc) + } + return fmt.Sprintf("juno error %s%s (%q)", err.Code, codeDesc, err.Desc) +} + +func (err junoLoginError) Is(other error) bool { + if v, ok := other.(junoLoginError); ok { + return err.Code == "" || v.Code == err.Code + } + return false +} + +func (s *junoLoginState) junoLoginStep(u *url.URL, doc *html.Node) (*http.Request, error) { + if n := qs(doc, "form#login-form"); n != nil { + r, err := s.junoLoginStepLogin(u, doc, n) + if err != nil { + err = fmt.Errorf("handle login: %w", err) + } + return r, err + } + if n := qs(doc, "form#loginForm:has(#tfa-login)"); n != nil { + r, err := s.junoStepTwoFactor(u, doc, n) + if err != nil { + err = fmt.Errorf("handle two factor auth: %w", err) + } + return r, err + } + if n := qs(doc, "form#tosForm"); n != nil { + r, err := s.junoStepTOSUpdate(u, doc, n) + if err != nil { + err = fmt.Errorf("handle tos update: %w", err) + } + return r, err + } + if n := qs(doc, "#login-container-end"); n != nil { + r, err := s.junoStepEnd(u, doc) + if err != nil { + err = fmt.Errorf("handle login end: %w", err) + } + return r, err + } + var fs []string + for _, f := range qsa(doc, "form") { + var ( + id, _ = htmlAttr(f, "id", "") + name, _ = htmlAttr(f, "name", "") + ) + fs = append(fs, fmt.Sprintf("form[id=%s][name=%s]", id, name)) + } + return nil, fmt.Errorf("handle login step (url: %s): unhandled step (forms: %s)", u.String(), strings.Join(fs, ", ")) +} + +func (s *junoLoginState) junoLoginStepLogin(u *url.URL, doc, form *html.Node) (*http.Request, error) { + var ( + errorCode, _ = htmlAttr(qs(form, "#errorCode[value]"), "value", "") + errorDesc = htmlText(qs(form, "#online-general-error > p")) + ) + if errorCode != "" || errorDesc != "" { + return nil, junoLoginError{Code: errorCode, Desc: errorDesc} + } + if qs(doc, "#g-recaptcha-response") != nil { + return nil, fmt.Errorf("%w (recapcha)", ErrCaptchaRequired) + } + if qs(doc, "#funcaptcha-solved") != nil { + return nil, fmt.Errorf("%w (funcaptcha)", ErrCaptchaRequired) + } + if s.seenLogin { + return nil, fmt.Errorf("already seen (and could not find an error)") + } else { + s.seenLogin = true + } + return junoFillForm(u, form, junoFormData{ + Fill: func(name, defvalue string) (string, error) { + switch name { + case "loginMethod": + return "emailPassword", nil + case "cid": + const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz" + b := make([]byte, 32) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b), nil + case "email": + if s.Email == "" { + return "", fmt.Errorf("%w: no email provided", ErrInvalidCredentials) + } + return s.Email, nil + case "password": + if s.Password == "" { + return "", fmt.Errorf("%w: no email provided", ErrInvalidCredentials) + } + return s.Password, nil + default: + return defvalue, nil + } + }, + Expect: map[string]bool{ + "email": true, + "password": true, + }, + }) +} + +func (s *junoLoginState) junoStepTOSUpdate(u *url.URL, doc, form *html.Node) (*http.Request, error) { + if s.seenTOS { + return nil, fmt.Errorf("already seen") + } else { + s.seenTOS = true + } + return junoFillForm(u, form, junoFormData{ + Fill: func(name, defvalue string) (string, error) { + switch name { + case "readAccept", "_readAccept": + return "on", nil + default: + return defvalue, nil + } + }, + Expect: map[string]bool{ + "readAccept": true, + "_readAccept": true, + }, + }) +} + +func (s *junoLoginState) junoStepTwoFactor(u *url.URL, doc, form *html.Node) (*http.Request, error) { + if qs(form, "#btnSendCode") != nil { + if s.seenTwoFactor { + return nil, fmt.Errorf("already seen send code page") + } else { + s.seenTwoFactor = true + } + return junoFillForm(u, form, junoFormData{ + Fill: func(name, defvalue string) (string, error) { + switch name { + case "codeType": + return "APP", nil + default: + return defvalue, nil + } + }, + Expect: map[string]bool{ + "codeType": true, + "oneTimeCode": false, + }, + }) + } + req, err := junoFillForm(u, form, junoFormData{ + Fill: func(name, defvalue string) (string, error) { + switch name { + case "oneTimeCode": + if defvalue != "" { + return "", fmt.Errorf("%w %q", ErrInvalidTwoFactor, defvalue) + } + if s.TOTP == nil { + return "", fmt.Errorf("%w: no totp secret provided", ErrInvalidTwoFactor) + } + return s.TOTP(time.Now()), nil + default: + return defvalue, nil + } + }, + Expect: map[string]bool{ + "oneTimeCode": true, + }, + }) + if err == nil { + if s.seenTwoFactorCode { + return nil, fmt.Errorf("already seen") + } else { + s.seenTwoFactorCode = true + } + } + return req, err +} + +func (s *junoLoginState) junoStepEnd(u *url.URL, doc *html.Node) (*http.Request, error) { + if s.seenEnd { + return nil, fmt.Errorf("already seen") + } else { + s.seenEnd = true + } + for _, n := range qsa(doc, "script") { + var d strings.Builder + htmlWalkDFS(n, func(n *html.Node, _ int) error { + if n.Type == html.CommentNode || n.Type == html.TextNode { + d.WriteString(n.Data) + } + return nil + }) + if m := regexp.MustCompile(`(?m)window.location\s*=\s*["'](https://[^"'\\]+/connect/auth[^"'\\]+)["']`).FindStringSubmatch(d.String()); m != nil { + r, err := u.Parse(string(m[1])) + if err != nil { + return nil, fmt.Errorf("resolve js redirect %q against %q: %w", string(m[1]), u.String(), err) + } + + req, err := http.NewRequest(http.MethodGet, r.String(), nil) + if err == nil { + req.Header.Set("Referrer", u.String()) + } + return req, nil + } + } + return nil, fmt.Errorf("could not find js redirect") +} + +type junoFormData struct { + Fill func(name, defvalue string) (string, error) + Expect map[string]bool +} + +// junoFillForm fills the provided HTML form, using values from data.Fill +// (returning an error if the returned value is invalid for a select/radio/etc), +// and ensuring that the fields in data.Expect are present (or not) as expected. +func junoFillForm(u *url.URL, form *html.Node, data junoFormData) (*http.Request, error) { + if form.DataAtom != atom.Form { + return nil, fmt.Errorf("element is not a form") + } + submitURL := &url.URL{ + Scheme: "https", + Host: u.Host, + Path: u.Path, + RawPath: u.RawPath, + RawQuery: u.RawQuery, + } + for _, a := range form.Attr { + if a.Namespace == "" { + switch strings.ToLower(a.Key) { + case "action": + if v, err := u.Parse(a.Val); err == nil { + submitURL = v + } else { + return nil, fmt.Errorf("resolve form submit url: %w", err) + } + case "method": + if a.Val != "" && strings.ToLower(a.Val) != "post" { + return nil, fmt.Errorf("unexpected form method %q", a.Val) + } + case "enctype": + if a.Val != "" && strings.ToLower(a.Val) != "application/x-www-form-urlencoded" { + return nil, fmt.Errorf("unexpected form method %q", a.Val) + } + } + } + } + + var ( + formData = url.Values{} + formOptions = map[string][]string{} + formCheckbox = map[string]string{} + ) + for _, n := range qsa(form, `[name]`) { + var ( + eName, _ = htmlAttr(n, "name", "") + eValue, _ = htmlAttr(n, "value", "") + eType, _ = htmlAttr(n, "type", "") + _, eChecked = htmlAttr(n, "checked", "") + ) + if eName == "" { + continue + } + switch n.DataAtom { + case atom.A: + // ignore + case atom.Input: + switch { + case asciiEqualFold(eType, "submit"), asciiEqualFold(eType, "reset"), asciiEqualFold(eType, "image"), asciiEqualFold(eType, "button"): + continue // ignore buttons + case asciiEqualFold(eType, "checkbox"): + if eValue != "" { + formCheckbox[eName] = eValue + } else { + formCheckbox[eName] = "on" + } + if eChecked { + formData[eName] = []string{formCheckbox[eName]} + } else { + formData[eName] = nil + } + case asciiEqualFold(eType, "radio"): + if eValue != "" { + formOptions[eName] = append(formOptions[eName], eValue) + } else { + formOptions[eName] = append(formOptions[eName], "on") + } + if eChecked { + if eValue != "" { + formData[eName] = []string{eValue} + } else { + formData[eName] = []string{"on"} + } + } else { + formData[eName] = nil + } + default: + formData[eName] = []string{eValue} + } + case atom.Select: + if _, x := htmlAttr(n, "multiple", ""); x { + return nil, fmt.Errorf("unhandled form element %s[multiple]", n.DataAtom) + } + for i, m := range qsa(n, `option`) { + if v, ok := htmlAttr(m, "value", ""); ok { + if _, selected := htmlAttr(m, "selected", ""); selected || i == 0 { + formData[eName] = []string{v} + } + formOptions[eName] = append(formOptions[eName], v) + } + } + default: + return nil, fmt.Errorf("unhandled form element %s[name=%s]", n.DataAtom, eName) + } + } + + for k, v := range formData { + if data.Expect != nil { + if expected, ok := data.Expect[k]; ok { + if expected { + delete(data.Expect, k) + } else { + return nil, fmt.Errorf("have unexpected field %q", k) + } + } + } + var defvalue string + if len(v) != 0 { + defvalue = v[0] + } + if value, err := data.Fill(k, defvalue); err != nil { + return nil, fmt.Errorf("fill field %q: %w", k, err) + } else if value != defvalue { + if opts, isSelect := formOptions[k]; isSelect { + if value == "" { + formData[k] = nil + } else { + var found bool + for _, opt := range opts { + if value == opt { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("fill field %q: new value %q not in options %q", k, value, opts) + } + formData[k] = []string{value} + } + } else { + formData[k] = []string{value} + } + } + } + if data.Expect != nil { + for k, expected := range data.Expect { + if expected { + return nil, fmt.Errorf("missing expected field %q", k) + } + } + } + + req, err := http.NewRequest(http.MethodPost, submitURL.String(), strings.NewReader(formData.Encode())) + if err == nil { + req.Header.Set("Referrer", u.String()) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + return req, err +} + +var ( + htmlWalkBreak = errors.New("break") //lint:ignore ST1012 special error + htmlWalkSkip = errors.New("skip") //lint:ignore ST1012 special error +) + +// htmlWalk does a depth-first walk of the provided node. +func htmlWalkDFS(n *html.Node, fn func(n *html.Node, depth int) error) error { + if n != nil { + var depth int + var stk []*html.Node + for stk = append(stk, n); len(stk) != 0; { + var cur *html.Node + if cur, stk = stk[len(stk)-1], stk[:len(stk)-1]; cur != nil { + var skip bool + if err := fn(cur, depth); err != nil { + if err == htmlWalkBreak { + return nil + } + if err != htmlWalkSkip { + return err + } + skip = true + } + if !skip && cur.LastChild != nil { + stk = append(stk, nil) + for n := cur.LastChild; n != nil; n = n.PrevSibling { + stk = append(stk, n) + } + depth++ + } + } else { + depth-- + } + } + } + return nil +} + +// htmlText gets the normalized inner text of n. +func htmlText(n *html.Node) string { + var tok []string + htmlWalkDFS(n, func(n *html.Node, _ int) error { + if n.Type == html.TextNode { + tok = append(tok, strings.Fields(n.Data)...) + } + return nil + }) + return strings.Join(tok, " ") +} + +// htmlAttr gets the value of a non-namespaced attribute. +func htmlAttr(n *html.Node, key, defvalue string) (string, bool) { + if n != nil { + for _, a := range n.Attr { + if a.Namespace == "" && asciiEqualFold(a.Key, key) { + return a.Val, true + } + } + } + return defvalue, false +} + +// qs executes a CSS selector against n, returning a single match. +func qs(n *html.Node, q string) *html.Node { + if n == nil { + return nil + } + return cascadia.Query(n, cascadia.MustCompile(q)) +} + +// qsa executes a CSS selector against n, returning all matches. +func qsa(n *html.Node, q string) []*html.Node { + if n == nil { + return nil + } + return cascadia.QueryAll(n, cascadia.MustCompile(q)) +} + +// asciiEqualFold is like strings.EqualFold, but ASCII-only. +func asciiEqualFold(s, t string) bool { + if len(s) != len(t) { + return false + } + for i := 0; i < len(s); i++ { + if asciiLower(s[i]) != asciiLower(t[i]) { + return false + } + } + return true +} + +// asciiLower gets the ASCII lowercase version of b. +func asciiLower(b byte) byte { + if 'A' <= b && b <= 'Z' { + return b + ('a' - 'A') + } + return b +} + +// totp returns the RFC6238 time-based counter for hotp. +func totp(t time.Time, s time.Duration) uint64 { + if t.IsZero() { + t = time.Now() + } + if s == 0 { + s = time.Second * 30 + } + return uint64(math.Floor(float64(t.Unix()) / s.Seconds())) +} + +// hotp computes a RFC4226 otp. +func hotp(c uint64, k []byte, n int, h func() hash.Hash) string { + if n == 0 { + n = 6 + } + if h == nil { + h = sha1.New + } + if n <= 0 || n > 8 { + panic("otp: must be 0 < n <= 8") + } + if len(k) == 0 { + panic("otp: key must not be empty") + } + hsh := hmac.New(h, k) + binary.Write(hsh, binary.BigEndian, c) + dst := hsh.Sum(nil) + off := dst[len(dst)-1] & 0xf + val := int64(((int(dst[off]))&0x7f)<<24 | + ((int(dst[off+1] & 0xff)) << 16) | + ((int(dst[off+2] & 0xff)) << 8) | + ((int(dst[off+3]) & 0xff) << 0)) + return fmt.Sprintf("%0*d", n, val%int64(math.Pow10(n))) +} 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) -} |