package auth import ( "crypto/sha1" "encoding/hex" "fmt" "io" "net/http" "net/url" "os" "regexp" "runtime" "strconv" "strings" "time" ) const userAgentTemplate = "SQEXAuthor/2.0.0(Windows 6.2; ja-jp; %s)" // httpClient bounds every SE request: http.DefaultClient has no Timeout, and a // hung connection would pin doLogin's busy state forever (the Log in button is // re-enabled from doLogin's defer). 60 s covers slow links plus TLS handshakes. var httpClient = &http.Client{Timeout: 60 * time.Second} // Result carries what login.send tells us about the session. SessionId is the // OAuth token used only to register with the version server (RegisterSession); // it must NOT be passed to the game as TestSID. type Result struct { SessionId string Region int TermsAccepted bool Playable bool MaxExpansion int } var ( // Upstream C# verbatim string @"...name=""_STORED_"" value=""(?.*)"">" // decodes ("" -> ") to a single closing quote before the '>'. storedRe = regexp.MustCompile(`\t<\s*input .* name="_STORED_" value="(?P.*)">`) okRe = regexp.MustCompile(`window\.external\.user\("login=auth,ok,(?P.*)"\);`) errRe = regexp.MustCompile(`window\.external\.user\("login=auth,ng,err,(?P.*)"\);`) ) // Login performs the SE OAuth login for the given region (1=NA, 2=EU). func Login(user, pass, otp string, region int) (*Result, error) { topURL := fmt.Sprintf( "https://ffxiv-login.square-enix.com/oauth/ffxivarr/login/top?lng=en&rgn=%d&isft=0&cssmode=1&isnew=1&launchver=3", region) stored, err := getStored(topURL) if err != nil { return nil, fmt.Errorf("fetching login page: %w", err) } form := url.Values{} form.Set("_STORED_", stored) form.Set("sqexid", user) form.Set("password", pass) form.Set("otppw", otp) req, err := http.NewRequest(http.MethodPost, "https://ffxiv-login.square-enix.com/oauth/ffxivarr/login/login.send", strings.NewReader(form.Encode())) if err != nil { return nil, err } setCommonHeaders(req, topURL) // Upstream uses FormUrlEncodedContent, which adds this automatically; a raw // Go reader does not. SE's edge rejects the POST without it. req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Host", "ffxiv-login.square-enix.com") req.Header.Set("Cache-Control", "no-cache") resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("login request: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) reply := string(body) if p := os.Getenv("FFXIV_DEBUG_LOGIN"); p != "" { _ = os.WriteFile(p, body, 0600) } if m := okRe.FindStringSubmatch(reply); m != nil { params := strings.Split(m[1], ",") // The field positions are fixed by SE's response; validate the shape // before indexing so a changed or truncated reply becomes an error the // user can retry, not an out-of-range panic in a worker goroutine. if len(params) < 14 { return nil, fmt.Errorf("unrecognized login response (expected at least 14 fields, got %d)", len(params)) } res := &Result{SessionId: params[1]} res.TermsAccepted = params[3] != "0" var err error if res.Region, err = strconv.Atoi(params[5]); err != nil || (res.Region != 1 && res.Region != 2) { return nil, fmt.Errorf("unrecognized login response: bad region %q", params[5]) } res.Playable = params[9] != "0" if res.MaxExpansion, err = strconv.Atoi(params[13]); err != nil { return nil, fmt.Errorf("unrecognized login response: bad expansion id %q", params[13]) } return res, nil } if m := errRe.FindStringSubmatch(reply); m != nil { return nil, fmt.Errorf("login rejected: %s", m[1]) } return nil, fmt.Errorf("unrecognized login response") } func getStored(topURL string) (string, error) { req, err := http.NewRequest(http.MethodGet, topURL, nil) if err != nil { return "", err } setCommonHeaders(req, frontierReferer()) resp, err := httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), `window.external.user("restartup");`) { return "", fmt.Errorf("server demands a launcher restart (rare; try again)") } m := storedRe.FindSubmatch(body) if m == nil { return "", fmt.Errorf("could not find _STORED_ token on login page") } return string(m[1]), nil } func setCommonHeaders(req *http.Request, referer string) { req.Header.Set("Accept", "image/gif, image/jpeg, image/pjpeg, application/x-ms-application, application/xaml+xml, application/x-ms-xbap, */*") req.Header.Set("Referer", referer) req.Header.Set("Accept-Encoding", "gzip, deflate") req.Header.Set("Accept-Language", acceptLanguage()) req.Header.Set("User-Agent", UserAgent()) req.Header.Set("Connection", "Keep-Alive") req.Header.Set("Cookie", `_rsid=""`) } // UserAgent mimics the launcher's UA with a stable per-machine computer id. func UserAgent() string { return fmt.Sprintf(userAgentTemplate, ComputerID()) } // ComputerID mirrors the launcher: sha1(UTF-16LE(machine+user+os+cpus)), take // bytes[1..4], prepend their negated sum, hex it. func ComputerID() string { machine, _ := os.Hostname() user := os.Getenv("USER") if user == "" { user = "ffxiv" } hashString := machine + user + runtime.GOOS + strconv.Itoa(runtime.NumCPU()) digest := sha1.Sum(utf16le(hashString)) b := make([]byte, 5) copy(b[1:], digest[0:4]) sum := int(b[1]) + int(b[2]) + int(b[3]) + int(b[4]) b[0] = byte(-sum) return hex.EncodeToString(b) } func utf16le(s string) []byte { var out []byte for _, r := range s { if r > 0xFFFF { r -= 0x10000 high := uint16(0xD800 + (r>>10)&0x3FF) low := uint16(0xDC00 + r&0x3FF) out = append(out, byte(high), byte(high>>8), byte(low), byte(low>>8)) continue } v := uint16(r) out = append(out, byte(v), byte(v>>8)) } return out } func acceptLanguage() string { if v := os.Getenv("FFXIV_ACCEPT_LANGUAGE"); v != "" { return v } return "en-US,en;q=0.5" }