aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/origin-login-test/main.go12
-rw-r--r--pkg/atlas/config.go14
-rw-r--r--pkg/atlas/server.go91
-rw-r--r--pkg/juno/login.go716
-rw-r--r--pkg/origin/authmgr.go37
-rw-r--r--pkg/origin/login.go637
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=...&regionCode=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=...&regionCode=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)
-}