diff options
-rw-r--r-- | docs/PRODUCTION.md | 13 | ||||
-rw-r--r-- | pkg/api/api0/api.go | 5 | ||||
-rw-r--r-- | pkg/api/api0/client.go | 121 | ||||
-rw-r--r-- | pkg/api/api0/metrics.go | 16 | ||||
-rw-r--r-- | pkg/atlas/config.go | 35 | ||||
-rw-r--r-- | pkg/atlas/server.go | 138 | ||||
-rw-r--r-- | pkg/juno/login.go | 716 | ||||
-rw-r--r-- | pkg/origin/authmgr.go | 193 | ||||
-rw-r--r-- | pkg/origin/login.go | 90 | ||||
-rw-r--r-- | pkg/origin/origin.go | 131 | ||||
-rw-r--r-- | pkg/origin/origin_test.go | 99 |
11 files changed, 7 insertions, 1550 deletions
diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 81e1b06..f10e6ac 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -161,16 +161,7 @@ This document describes the recommended setup for an non-containerized Atlas ser ATLAS_API0_MAINMENUPROMOS_UPDATENEEDED=file:/etc/atlas/mainmenupromos_updateneeded.json # username lookup - ATLAS_USERNAMESOURCE=eax-origin - - # origin (the account MUST have app-based two-factor authentication set up) - ATLAS_ORIGIN_EMAIL=email@example.com - ATLAS_ORIGIN_PASSWORD=@origin_password - ATLAS_ORIGIN_TOTP=@origin_totp - ATLAS_ORIGIN_HAR_GZIP=true - ATLAS_ORIGIN_HAR_SUCCESS=/var/log/atlas/har - ATLAS_ORIGIN_HAR_ERROR=/var/log/atlas/har - ATLAS_ORIGIN_PERSIST=/var/lib/atlas/origin.json + ATLAS_USERNAMESOURCE=stryder # eax EAX_UPDATE_INTERVAL=24h @@ -291,7 +282,7 @@ This document describes the recommended setup for an non-containerized Atlas ser echo "[Service]" | tee /etc/systemd/system/atlas.service.d/credentials.conf ``` - For each credential (in the example config: `northstartf.key`, `northstartf.crt`, `api0_server_id_secret`, `metrics_secret`, `origin_password`, `origin_totp`), + For each credential (in the example config: `northstartf.key`, `northstartf.crt`, `api0_server_id_secret`, `metrics_secret`), ```bash sudo systemd-creds --pretty encrypt --name credential_name - - | tee -a /etc/systemd/system/atlas.service.d/credentials.conf diff --git a/pkg/api/api0/api.go b/pkg/api/api0/api.go index 2e3593c..355deef 100644 --- a/pkg/api/api0/api.go +++ b/pkg/api/api0/api.go @@ -30,7 +30,6 @@ import ( "github.com/r2northstar/atlas/pkg/eax" "github.com/r2northstar/atlas/pkg/metricsx" "github.com/r2northstar/atlas/pkg/nspkt" - "github.com/r2northstar/atlas/pkg/origin" "github.com/rs/zerolog/hlog" "golang.org/x/mod/semver" ) @@ -52,10 +51,6 @@ type Handler struct { // UsernameSource configures the source to use for usernames. UsernameSource UsernameSource - // OriginAuthMgr, if provided, manages Origin nucleus tokens for checking - // usernames. - OriginAuthMgr *origin.AuthMgr - // EAXClient makes requests to the EAX API. EAXClient *eax.Client diff --git a/pkg/api/api0/client.go b/pkg/api/api0/client.go index b3fd686..b718e17 100644 --- a/pkg/api/api0/client.go +++ b/pkg/api/api0/client.go @@ -13,7 +13,6 @@ import ( "github.com/r2northstar/atlas/pkg/api/api0/api0gameserver" "github.com/r2northstar/atlas/pkg/eax" - "github.com/r2northstar/atlas/pkg/origin" "github.com/r2northstar/atlas/pkg/pdata" "github.com/r2northstar/atlas/pkg/stryder" "github.com/rs/zerolog/hlog" @@ -26,22 +25,9 @@ const ( // Don't get usernames. UsernameSourceNone UsernameSource = "" - // Get the username from the Origin API. - UsernameSourceOrigin UsernameSource = "origin" - - // Get the username from the Origin API, but fall back to EAX on failure. - UsernameSourceOriginEAX UsernameSource = "origin-eax" - // Get the username from EAX. UsernameSourceEAX UsernameSource = "eax" - // Get the username from the Origin API, but also check EAX and warn if it's - // different. - UsernameSourceOriginEAXDebug UsernameSource = "origin-eax-debug" - - // Get the username from EAX, but fall back to the Origin API on failure. - UsernameSourceEAXOrigin UsernameSource = "eax-origin" - // Get the username from Stryder (available since October 2, 2023). Note // that this source only returns usernames for valid tokens. UsernameSourceStryder UsernameSource = "stryder" @@ -318,48 +304,8 @@ func (h *Handler) lookupUsername(r *http.Request, uid uint64, stryderRes []byte) switch h.UsernameSource { case UsernameSourceNone: break - case UsernameSourceOrigin: - username, _ = h.lookupUsernameOrigin(r, uid) - case UsernameSourceOriginEAX: - username, _ = h.lookupUsernameOrigin(r, uid) - if username == "" { - if eaxUsername, ok := h.lookupUsernameEAX(r, uid); ok { - username = eaxUsername - hlog.FromRequest(r).Warn(). - Uint64("uid", uid). - Str("origin_username", eaxUsername). - Msgf("failed to get username from origin, but got it from eax") - } - } - case UsernameSourceOriginEAXDebug: - username, _ = h.lookupUsernameOrigin(r, uid) - if eaxUsername, ok := h.lookupUsernameEAX(r, uid); ok { - if eaxUsername != username { - hlog.FromRequest(r).Warn(). - Uint64("uid", uid). - Str("origin_username", username). - Str("eax_username", eaxUsername). - Msgf("got username from origin and eax, but they don't match; using the origin one") - } - } else { - hlog.FromRequest(r).Warn(). - Uint64("uid", uid). - Str("origin_username", username). - Msgf("got username from origin, but failed to get username from eax") - } case UsernameSourceEAX: username, _ = h.lookupUsernameEAX(r, uid) - case UsernameSourceEAXOrigin: - username, _ = h.lookupUsernameEAX(r, uid) - if username == "" { - if originUsername, ok := h.lookupUsernameOrigin(r, uid); ok { - username = originUsername - hlog.FromRequest(r).Warn(). - Uint64("uid", uid). - Str("origin_username", originUsername). - Msgf("failed to get username from eax, but got it from origin") - } - } case UsernameSourceStryder: username, _ = h.lookupUsernameStryder(r, uid, stryderRes) case UsernameSourceStryderEAX: @@ -396,73 +342,6 @@ func (h *Handler) lookupUsername(r *http.Request, uid uint64, stryderRes []byte) return } -// lookupUsernameOrigin gets the username for uid from the Origin API, returning -// an empty string if a username does not exist for the uid, and false on error. -func (h *Handler) lookupUsernameOrigin(r *http.Request, uid uint64) (username string, ok bool) { - select { - case <-r.Context().Done(): // check if the request was canceled to avoid polluting the metrics - return - default: - } - if h.OriginAuthMgr == nil { - hlog.FromRequest(r).Error(). - Str("username_source", "origin"). - Msgf("no origin auth available for username lookup") - return - } - originStart := time.Now() - if tok, ours, err := h.OriginAuthMgr.OriginAuth(false); err == nil { - if ui, err := origin.GetUserInfo(r.Context(), tok, uid); err == nil { - if len(ui) == 1 { - username = ui[0].EAID - h.m().client_originauth_origin_username_lookup_calls_total.success.Inc() - } else { - h.m().client_originauth_origin_username_lookup_calls_total.notfound.Inc() - } - ok = true - } else if errors.Is(err, origin.ErrAuthRequired) { - if tok, ours, err := h.OriginAuthMgr.OriginAuth(true); err == nil { - if ui, err := origin.GetUserInfo(r.Context(), tok, uid); err == nil { - if len(ui) == 1 { - username = ui[0].EAID - h.m().client_originauth_origin_username_lookup_calls_total.success.Inc() - } else { - h.m().client_originauth_origin_username_lookup_calls_total.notfound.Inc() - } - ok = true - } - } else if ours { - hlog.FromRequest(r).Error(). - Err(err). - Str("username_source", "origin"). - Msgf("origin auth token refresh failure") - h.m().client_originauth_origin_username_lookup_calls_total.fail_authtok_refresh.Inc() - } - } else if !errors.Is(err, context.Canceled) { - hlog.FromRequest(r).Error(). - Err(err). - Str("username_source", "origin"). - Msgf("failed to get origin user info") - h.m().client_originauth_origin_username_lookup_calls_total.fail_other_error.Inc() - } - if username == "" && ok { - hlog.FromRequest(r).Warn(). - Err(err). - Uint64("uid", uid). - Str("username_source", "origin"). - Msgf("no origin username found for uid") - } - } else if ours { - hlog.FromRequest(r).Error(). - Err(err). - Str("username_source", "origin"). - Msgf("origin auth token refresh failure") - h.m().client_originauth_origin_username_lookup_calls_total.fail_authtok_refresh.Inc() - } - h.m().client_originauth_origin_username_lookup_duration_seconds.UpdateDuration(originStart) - return -} - // lookupUsernameEAX gets the username for uid from the EAX API, returning an // empty string if a username does not exist for the uid, and false on error. func (h *Handler) lookupUsernameEAX(r *http.Request, uid uint64) (username string, ok bool) { diff --git a/pkg/api/api0/metrics.go b/pkg/api/api0/metrics.go index 0c18d0a..5c8c462 100644 --- a/pkg/api/api0/metrics.go +++ b/pkg/api/api0/metrics.go @@ -70,15 +70,8 @@ type apiMetrics struct { fail_other_error *metrics.Counter http_method_not_allowed *metrics.Counter } - client_originauth_requests_map *metricsx.GeoCounter2 - client_originauth_stryder_auth_duration_seconds *metrics.Histogram - client_originauth_origin_username_lookup_duration_seconds *metrics.Histogram - client_originauth_origin_username_lookup_calls_total struct { - success *metrics.Counter - notfound *metrics.Counter - fail_authtok_refresh *metrics.Counter - fail_other_error *metrics.Counter - } + client_originauth_requests_map *metricsx.GeoCounter2 + client_originauth_stryder_auth_duration_seconds *metrics.Histogram client_originauth_eax_username_lookup_duration_seconds *metrics.Histogram client_originauth_eax_username_lookup_calls_total struct { success *metrics.Counter @@ -270,11 +263,6 @@ func (h *Handler) m() *apiMetrics { mo.client_originauth_requests_total.http_method_not_allowed = mo.set.NewCounter(`atlas_api0_client_originauth_requests_total{result="http_method_not_allowed"}`) mo.client_originauth_requests_map = metricsx.NewGeoCounter2(`atlas_api0_client_originauth_requests_map`) mo.client_originauth_stryder_auth_duration_seconds = mo.set.NewHistogram(`atlas_api0_client_originauth_stryder_auth_duration_seconds`) - mo.client_originauth_origin_username_lookup_duration_seconds = mo.set.NewHistogram(`atlas_api0_client_originauth_origin_username_lookup_duration_seconds`) - mo.client_originauth_origin_username_lookup_calls_total.success = mo.set.NewCounter(`atlas_api0_client_originauth_origin_username_lookup_calls_total{result="success"}`) - mo.client_originauth_origin_username_lookup_calls_total.notfound = mo.set.NewCounter(`atlas_api0_client_originauth_origin_username_lookup_calls_total{result="notfound"}`) - mo.client_originauth_origin_username_lookup_calls_total.fail_authtok_refresh = mo.set.NewCounter(`atlas_api0_client_originauth_origin_username_lookup_calls_total{result="fail_authtok_refresh"}`) - mo.client_originauth_origin_username_lookup_calls_total.fail_other_error = mo.set.NewCounter(`atlas_api0_client_originauth_origin_username_lookup_calls_total{result="fail_other_error"}`) mo.client_originauth_eax_username_lookup_duration_seconds = mo.set.NewHistogram(`atlas_api0_client_originauth_eax_username_lookup_duration_seconds`) mo.client_originauth_eax_username_lookup_calls_total.success = mo.set.NewCounter(`atlas_api0_client_originauth_eax_username_lookup_calls_total{result="success"}`) mo.client_originauth_eax_username_lookup_calls_total.notfound = mo.set.NewCounter(`atlas_api0_client_originauth_eax_username_lookup_calls_total{result="notfound"}`) diff --git a/pkg/atlas/config.go b/pkg/atlas/config.go index ed108c1..07c88ac 100644 --- a/pkg/atlas/config.go +++ b/pkg/atlas/config.go @@ -168,41 +168,12 @@ type Config struct { // Sets the source used for resolving usernames. If not specified, "origin" // is used if OriginEmail is provided, otherwise, "none" is used. // - none (don't get usernames) - // - origin (get the username from the Origin API) - // - origin-eax (get the username from the Origin API, but fall back to EAX on failure) - // - origin-eax-debug (get the username from the Origin API, but also check EAX and warn if it's different) // - eax (get the username from EAX) - // - eax-origin (get the username from EAX, but fall back to the Origin API on failure) + // - stryder (get the username from Stryder) + // - stryder-eax (get the username from Stryder, but fall back to EAX on failure) + // - stryder-eax-debug (get the username from Stryder, but also check EAX and warn if it's different) UsernameSource string `env:"ATLAS_USERNAMESOURCE"` - // The email address to use for Origin login. If not provided, the Origin - // API will not be used. If it begins with @, it is treated as the name of a - // systemd credential to load. - OriginEmail string `env:"ATLAS_ORIGIN_EMAIL" sdcreds:"load,trimspace"` - - // The password for Origin login. If it begins with @, it is treated as the - // name of a systemd credential to load. - OriginPassword string `env:"ATLAS_ORIGIN_PASSWORD" sdcreds:"load,trimspace"` - - // The base32 TOTP secret for Origin login. If it begins with @, it is - // treated as the name of a systemd credential to load. - OriginTOTP string `env:"ATLAS_ORIGIN_TOTP" sdcreds:"load,trimspace"` - - // 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"` - // Override the EAX EA App version. If specified, updates will not be // checked automatically. EAXUpdateVersion string `env:"EAX_UPDATE_VERSION"` diff --git a/pkg/atlas/server.go b/pkg/atlas/server.go index 4b450c2..7ad8fce 100644 --- a/pkg/atlas/server.go +++ b/pkg/atlas/server.go @@ -20,7 +20,6 @@ import ( "time" "github.com/VictoriaMetrics/metrics" - "github.com/klauspost/compress/gzip" "github.com/pg9182/ip2x" "github.com/r2northstar/atlas/db/atlasdb" "github.com/r2northstar/atlas/db/pdatadb" @@ -29,7 +28,6 @@ import ( "github.com/r2northstar/atlas/pkg/eax" "github.com/r2northstar/atlas/pkg/memstore" "github.com/r2northstar/atlas/pkg/nspkt" - "github.com/r2northstar/atlas/pkg/origin" "github.com/r2northstar/atlas/pkg/regionmap" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" @@ -300,11 +298,6 @@ 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 exc, err := configureEAX(c, s.Logger.With().Str("component", "eax").Logger()); err == nil { s.API0.EAXClient = exc } else { @@ -498,125 +491,6 @@ func configureLogging(c *Config) (l zerolog.Logger, reopen func(), err error) { return } -func configureOrigin(c *Config, l zerolog.Logger) (*origin.AuthMgr, error) { - if c.OriginEmail == "" { - return nil, nil - } - var mu sync.Mutex - mgr := &origin.AuthMgr{ - Credentials: func() (email, password, otpsecret string, err error) { - return c.OriginEmail, c.OriginPassword, c.OriginTOTP, nil - }, - Backoff: expbackoff, - Updated: func(as origin.AuthState, err error) { - mu.Lock() - defer mu.Unlock() - - if fn := c.OriginPersist; fn != "" { - if buf, err := json.Marshal(as); err != nil { - l.Err(err).Msg("failed to save origin auth json") - return - } else if err = os.WriteFile(fn, buf, 0666); err != nil { - l.Err(err).Msg("failed to save origin auth json") - return - } - } - if err != nil { - l.Err(err).Msg("origin auth error; using old token") - } else { - l.Info().Msg("got new origin token") - } - }, - } - if fn := c.OriginPersist; fn != "" { - var as origin.AuthState - if buf, err := os.ReadFile(fn); err != nil { - if !os.IsNotExist(err) { - l.Err(err).Msg("failed to load origin auth json") - } - } else if err := json.Unmarshal(buf, &as); err != nil { - l.Err(err).Msg("failed to load origin auth json") - } else { - mgr.SetAuth(as) - } - } - 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 configureEAX(c *Config, l zerolog.Logger) (*eax.Client, error) { mgr := &eax.UpdateMgr{ AutoUpdateBackoff: expbackoff, @@ -656,16 +530,8 @@ func configureUsernameSource(c *Config) (api0.UsernameSource, error) { switch typ := c.UsernameSource; typ { case "none": return api0.UsernameSourceNone, nil - case "origin": - return api0.UsernameSourceOrigin, nil - case "origin-eax": - return api0.UsernameSourceOriginEAX, nil - case "origin-eax-debug": - return api0.UsernameSourceOriginEAXDebug, nil case "eax": return api0.UsernameSourceEAX, nil - case "eax-origin": - return api0.UsernameSourceEAXOrigin, nil case "stryder": return api0.UsernameSourceStryder, nil case "stryder-eax": @@ -673,10 +539,6 @@ func configureUsernameSource(c *Config) (api0.UsernameSource, error) { case "stryder-eax-debug": return api0.UsernameSourceStryderEAXDebug, nil case "": - // backwards compat - if c.OriginEmail != "" { - return api0.UsernameSourceOrigin, nil - } return api0.UsernameSourceNone, nil default: return "", fmt.Errorf("unknown source %q", typ) diff --git a/pkg/juno/login.go b/pkg/juno/login.go deleted file mode 100644 index 63784d3..0000000 --- a/pkg/juno/login.go +++ /dev/null @@ -1,716 +0,0 @@ -// 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 a SID using the provided credentials. -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 deleted file mode 100644 index 2fb1d55..0000000 --- a/pkg/origin/authmgr.go +++ /dev/null @@ -1,193 +0,0 @@ -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") - -// AuthMgr manages Origin NucleusTokens. It is efficient and safe for concurrent -// use. -// -// For persistence, load the credentials on startup using SetAuth, and store the -// credentials using Updated. -type AuthMgr struct { - // Timeout is the timeout for refreshing tokens. If zero, a reasonable - // default is used. If negative, there is no timeout. - Timeout time.Duration - - // Updated, if provided, is called in a new goroutine when tokens have - // changed. AuthState is always set and should be saved, even if an error - // occured. - Updated func(AuthState, error) - - // Credentials, if provided, is called to get credentials when updating the - // SID. - 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 - authErr error // last auth error - authErrTime time.Time // last auth error time - authErrCount int // consecutive auth errors - auth AuthState // current auth tokens -} - -// AuthState contains the current authentication tokens. -type AuthState struct { - SID juno.SID `json:"sid,omitempty"` - NucleusToken NucleusToken `json:"nucleus_token,omitempty"` - NucleusTokenExpiry time.Time `json:"nucleus_token_expiry,omitempty"` -} - -func (a *AuthMgr) init() { - a.authInit.Do(func() { - a.authCv = sync.NewCond(new(sync.Mutex)) - }) -} - -// SetAuth sets the current Origin credentials. If authentication is in -// progress, it will block. -func (a *AuthMgr) SetAuth(auth AuthState) { - a.init() - a.authCv.L.Lock() - for a.authPf { - a.authCv.Wait() - } - a.auth = auth - a.authErr = nil - a.authCv.L.Unlock() -} - -// OriginAuth gets the current NucleusToken. If refresh is true or the nucleus -// token is missing/expired, it generates a new NucleusToken, getting a new SID -// if required. If another refresh is in progress, it waits for the result of -// it. True is returned (on success or failure) if this call performed a -// refresh. This function may block for up to Timeout. -// -// In general, OriginAuth(false) should be used first, then if an API call error -// is ErrAuthRequired, try it again with the token from OriginAuth(true). -func (a *AuthMgr) OriginAuth(refresh bool) (NucleusToken, bool, error) { - a.init() - if a.authCv.L.Lock(); a.authPf { - for a.authPf { - a.authCv.Wait() - } - defer a.authCv.L.Unlock() - return a.auth.NucleusToken, false, a.authErr - } else { - if refresh || a.auth.NucleusToken == "" || !time.Now().Before(a.auth.NucleusTokenExpiry) { - a.authPf = true - a.authCv.L.Unlock() - defer func() { - a.authCv.L.Lock() - a.authCv.Broadcast() - a.authPf = false - a.authCv.L.Unlock() - }() - } else { - defer a.authCv.L.Unlock() - return a.auth.NucleusToken, false, a.authErr - } - } - if a.authErr != nil && a.Backoff != nil { - if !a.Backoff(a.authErr, a.authErrTime, a.authErrCount) { - return a.auth.NucleusToken, true, fmt.Errorf("%w (%d attempts, last error: %v)", ErrAuthMgrBackoff, a.authErrCount, a.authErrCount) - } - } - 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) - } - }() - - var ctx context.Context - var cancel context.CancelFunc - if a.Timeout > 0 { - ctx, cancel = context.WithTimeout(context.Background(), a.Timeout) - } else if a.Timeout == 0 { - ctx, cancel = context.WithTimeout(context.Background(), time.Second*15) - } else { - ctx, cancel = context.WithCancel(context.Background()) - } - defer cancel() - - if a.auth.SID != "" { - if tok, exp, aerr := GetNucleusToken(ctx, t, a.auth.SID); aerr == nil { - a.auth.NucleusToken = tok - a.auth.NucleusTokenExpiry = exp - return - } else if !errors.Is(aerr, ErrAuthRequired) { - err = fmt.Errorf("refresh nucleus token: %w", aerr) - return - } - } - if a.Credentials == nil { - err = fmt.Errorf("no origin credentials to refresh sid with") - return - } else if email, password, otpsecret, aerr := a.Credentials(); aerr != nil { - err = fmt.Errorf("get origin credentials: %w", aerr) - return - } 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 = res.SID - } - 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 - a.auth.NucleusTokenExpiry = exp - } - return - }() - if a.authErrCount != 0 { - a.authErr = fmt.Errorf("%w (attempt %d)", a.authErr, a.authErrCount) - } - if a.authErr != nil { - a.authErrCount++ - a.authErrTime = time.Now() - } else { - a.authErrCount = 0 - a.authErrTime = time.Time{} - } - if a.Updated != nil { - go a.Updated(a.auth, a.authErr) - } - return a.auth.NucleusToken, true, a.authErr -} diff --git a/pkg/origin/login.go b/pkg/origin/login.go deleted file mode 100644 index f8bb255..0000000 --- a/pkg/origin/login.go +++ /dev/null @@ -1,90 +0,0 @@ -package origin - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "time" - - "github.com/r2northstar/atlas/pkg/juno" -) - -type NucleusToken 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, 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: t, - Jar: jar, - } - 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 { - return "", 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) - } - defer resp.Body.Close() - - buf, err := io.ReadAll(resp.Body) - if err != nil { - return "", time.Time{}, fmt.Errorf("get nucleus token: %w", err) - } - - var eobj struct { - ErrorCode string `json:"error_code"` - Error string `json:"error"` - ErrorNumber json.Number `json:"error_number"` - } - if err := json.Unmarshal(buf, &eobj); err == nil && eobj.Error != "" { - if eobj.ErrorCode == "login_required" { - return "", time.Time{}, fmt.Errorf("get nucleus token: %w: login required", ErrAuthRequired) - } - return "", time.Time{}, fmt.Errorf("get nucleus token: %w: error %s: %s (%q)", ErrOrigin, eobj.ErrorNumber, eobj.ErrorCode, eobj.Error) - } - - if resp.StatusCode != http.StatusOK { - return "", time.Time{}, fmt.Errorf("get nucleus token: request %q: response status %d (%s)", resp.Request.URL.String(), resp.StatusCode, resp.Status) - } - - var obj struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn json.Number `json:"expires_in"` - } - if err := json.Unmarshal(buf, &obj); err != nil { - return "", time.Time{}, fmt.Errorf("get nucleus token: %w", err) - } - if obj.AccessToken == "" || obj.ExpiresIn == "" { - return "", time.Time{}, fmt.Errorf("get nucleus token: invalid response %q", string(buf)) - } - - var expiry time.Time - if v, err := obj.ExpiresIn.Int64(); err == nil { - expiry = time.Now().Add(time.Duration(v) * time.Second) - } else { - return "", time.Time{}, fmt.Errorf("get nucleus token: invalid response %q: invalid expiry: %w", string(buf), err) - } - return NucleusToken(obj.AccessToken), expiry, nil -} diff --git a/pkg/origin/origin.go b/pkg/origin/origin.go deleted file mode 100644 index d7f4426..0000000 --- a/pkg/origin/origin.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package origin is a client for parts of the Origin API used by Northstar for -// authentication. -package origin - -import ( - "context" - "encoding/xml" - "errors" - "fmt" - "io" - "mime" - "net/http" - "strconv" - "strings" -) - -var ( - ErrInvalidResponse = errors.New("invalid origin api response") - ErrOrigin = errors.New("origin api error") - ErrAuthRequired = errors.New("origin authentication required") -) - -// Base is the base path for the Origin API. -var Base = "https://api1.origin.com" - -// UserInfo contains information about an Origin account. -type UserInfo struct { - UserID uint64 - PersonaID string - EAID string -} - -// GetUserInfo gets information about Origin accounts by their Origin UserID. -// -// If errors.Is(err, ErrAuthRequired), you need a new NucleusToken. -func GetUserInfo(ctx context.Context, token NucleusToken, uid ...uint64) ([]UserInfo, error) { - uids := make([]string, len(uid)) - for _, x := range uid { - uids = append(uids, strconv.FormatUint(x, 10)) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, Base+"/atom/users?userIds="+strings.Join(uids, ","), nil) - if err != nil { - return nil, err - } - - req.Header.Set("AuthToken", string(token)) - req.Header.Set("X-Origin-Platform", "UnknownOS") - req.Header.Set("Referrer", "https://www.origin.com/") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - buf, root, err := checkResponseXML(resp) - if err != nil { - return nil, err - } - return parseUserInfo(buf, root) -} - -func checkResponseXML(resp *http.Response) ([]byte, xml.Name, error) { - var root xml.Name - buf, err := io.ReadAll(resp.Body) - if err != nil { - return buf, root, err - } - if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "application/xml" && mt != "text/xml" { - if resp.StatusCode != http.StatusOK { - return buf, root, fmt.Errorf("%w: response status %d (%s)", ErrOrigin, resp.StatusCode, resp.Status) - } - return buf, root, fmt.Errorf("%w: expected xml, got %q", ErrOrigin, mt) - } - if err := xml.Unmarshal(buf, &root); err != nil { - return buf, root, fmt.Errorf("%w: invalid xml: %v", ErrInvalidResponse, err) - } - if root.Local == "error" { - var obj struct { - Code int `xml:"code,attr"` - Failure []struct { - Field string `xml:"field,attr"` - Cause string `xml:"cause,attr"` - Value string `xml:"value,attr"` - } `xml:"failure"` - } - if err := xml.Unmarshal(buf, &obj); err != nil { - return buf, root, fmt.Errorf("%w: response %#q (unmarshal: %v)", ErrOrigin, string(buf), err) - } - for _, f := range obj.Failure { - if f.Cause == "invalid_token" { - return buf, root, fmt.Errorf("%w: invalid token", ErrAuthRequired) - } - } - if len(obj.Failure) == 1 { - return buf, root, fmt.Errorf("%w: error %d: %s (%s) %q", ErrOrigin, obj.Code, obj.Failure[0].Cause, obj.Failure[0].Field, obj.Failure[0].Value) - } - return buf, root, fmt.Errorf("%w: error %d: response %#q", ErrOrigin, obj.Code, string(buf)) - } - return buf, root, nil -} - -func parseUserInfo(buf []byte, root xml.Name) ([]UserInfo, error) { - var obj struct { - User []struct { - UserID string `xml:"userId"` - PersonaID string `xml:"personaId"` - EAID string `xml:"EAID"` - } `xml:"user"` - } - if root.Local != "users" { - return nil, fmt.Errorf("%w: unexpected %s response", ErrInvalidResponse, root.Local) - } - if err := xml.Unmarshal(buf, &obj); err != nil { - return nil, fmt.Errorf("%w: invalid xml: %v", ErrInvalidResponse, err) - } - res := make([]UserInfo, len(obj.User)) - for i, x := range obj.User { - var v UserInfo - if uid, err := strconv.ParseUint(x.UserID, 10, 64); err == nil { - v.UserID = uid - } else { - return nil, fmt.Errorf("parse userId %q: %w", x.UserID, err) - } - v.PersonaID = x.PersonaID - v.EAID = x.EAID - res[i] = v - } - return res, nil -} diff --git a/pkg/origin/origin_test.go b/pkg/origin/origin_test.go deleted file mode 100644 index 7735355..0000000 --- a/pkg/origin/origin_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package origin - -import ( - "errors" - "io" - "net/http" - "reflect" - "strconv" - "strings" - "testing" -) - -func TestUserInfoResponse(t *testing.T) { - testUserInfoResponse(t, - "SuccessNew", - 200, "text/xml", `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><users><user><userId>1001111111111</userId><personaId>1001111111111</personaId><EAID>test</EAID></user><user><userId>1001111111112</userId><personaId>1001111111112</personaId><EAID>test1</EAID></user></users>`, - []UserInfo{ - {UserID: 1001111111111, PersonaID: "1001111111111", EAID: "test"}, - {UserID: 1001111111112, PersonaID: "1001111111112", EAID: "test1"}, - }, nil, - ) - testUserInfoResponse(t, - "SuccessOld", - 200, "text/xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><users><user><userId>2291234567</userId><personaId>328123456</personaId><EAID>blahblah</EAID></user></users>`, - []UserInfo{ - {UserID: 2291234567, PersonaID: "328123456", EAID: "blahblah"}, - }, nil, - ) - testUserInfoResponse(t, - "EmptyToken", - 200, "text/xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><error code="10044"><failure value="" field="authToken" cause="MISSING_AUTHTOKEN"/></error>`, - nil, ErrOrigin, - ) - testUserInfoResponse(t, - "InvalidExpiredToken", - 200, "text/xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><error code="10044"><failure value="" field="authToken" cause="invalid_token"/></error>`, - nil, ErrAuthRequired, - ) - testUserInfoResponse(t, - "FakeWrongRootElement", - 200, "text/xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><fake/></error>`, - nil, ErrInvalidResponse, - ) - testUserInfoResponse(t, - "FakeError", - 200, "text/xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><error code="12345"><failure value="" field="dummy" cause="fake"/></error>`, - nil, ErrOrigin, - ) - testUserInfoResponse(t, - "FakeBadResponse", - 500, "text/plain", `Fake Internal Server Error`, - nil, ErrOrigin, - ) - testUserInfoResponse(t, - "FakeInvalidXML", - 200, "text/xml", `fake`, - nil, ErrInvalidResponse, - ) -} - -func testUserInfoResponse(t *testing.T, name string, status int, mime, xml string, v []UserInfo, err error) { - t.Run(name, func(t *testing.T) { - buf, root, err1 := checkResponseXML(&http.Response{ - Status: strconv.Itoa(status) + " " + http.StatusText(status), - StatusCode: status, - Body: io.NopCloser(strings.NewReader(xml)), - Header: http.Header{ - "Content-Type": {mime}, - }, - }) - if err1 != nil { - if err == nil { - t.Fatalf("expected no error, got %q", err1) - } - if !errors.Is(err1, err) { - t.Fatalf("expected error %q, got %q", err, err1) - } - return - } - - ui, err1 := parseUserInfo(buf, root) - if err1 != nil { - if err == nil { - t.Fatalf("expected no error, got %q", err1) - } - if !errors.Is(err1, err) { - t.Fatalf("expected error %q, got %q", err, err1) - } - return - } - if err != nil { - t.Fatalf("expected error %q, got nothing", err) - } - - if !reflect.DeepEqual(ui, v) { - t.Errorf("unexpected result %#v", ui) - } - }) -} |