aboutsummaryrefslogtreecommitdiff
path: root/pkg/juno/login.go
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/juno/login.go')
-rw-r--r--pkg/juno/login.go716
1 files changed, 716 insertions, 0 deletions
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)))
+}