package authenticator import ( "compress/gzip" "context" "crypto/rand" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "strings" "time" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // Steam base URLs. Kept as vars (not consts) so tests can override. var ( steamCommunityBase = "https://steamcommunity.com" steamWebAPIBase = "https://api.steampowered.com" ) // steamMobileUserAgent is the exact UA the original WinAuth uses, chosen // so that Steam's mobile login endpoint accepts the request as coming // from the official Android app. Changing this without testing tends to // trigger captcha or outright rejection. const steamMobileUserAgent = "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30" // steamOAuthClientID / steamOAuthScope are the constants the mobile app // sends to /mobilelogin. Do not log them — they are not secret, but // keeping the redact list short reduces accidents. const ( steamOAuthClientID = "DE45CD61" steamOAuthScope = "read_profile write_profile read_client write_client" ) // steamFormFieldsToRedact lists the form keys that must never appear in // logs. The HTTP layer prints request body at debug only after filtering // these out. var steamFormFieldsToRedact = map[string]struct{}{ "password": {}, "access_token": {}, "oauth_token": {}, "twofactorcode": {}, "emailauth": {}, "shared_secret": {}, "identity_secret": {}, "revocation_code": {}, "authenticator_code": {}, "activation_code": {}, } // newSteamCookieJar returns a cookie jar pre-loaded with the constant // cookies the original mobile login flow needs before its first request. func newSteamCookieJar() (http.CookieJar, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } u, _ := url.Parse(steamCommunityBase + "/") jar.SetCookies(u, []*http.Cookie{ {Name: "mobileClientVersion", Value: "3067969+%282.1.3%29"}, {Name: "mobileClient", Value: "android"}, {Name: "steamid", Value: ""}, {Name: "steamLogin", Value: ""}, {Name: "Steam_Language", Value: "english"}, {Name: "dob", Value: ""}, }) return jar, nil } // steamHTTPClient returns a fresh http.Client that uses the given jar // and a 30s timeout. We intentionally do NOT reuse internal/httpc here: // httpc dumps full request bodies at Trace level, which would leak the // password / OAuth token form fields used by the Steam endpoints. This // client instead logs only method / URL / status at debug level. func steamHTTPClient(jar http.CookieJar) *http.Client { return &http.Client{ Jar: jar, Timeout: 30 * time.Second, } } // steamRequest performs a Steam mobile / WebAPI request. method is GET or // POST. For GET the form values are appended to the URL; for POST they // are sent as application/x-www-form-urlencoded. // // The returned body is the full response payload as a string. headers // are added before the call. extraCookies are sent as the explicit // Cookie header on top of whatever the jar already supplies — most // callers can pass nil. // // IMPORTANT: passwords/tokens MUST be in `form` and never in the URL. // The debug log only records method/URL/status; form keys listed in // steamFormFieldsToRedact are filtered out of the (debug-level) body // dump. func steamRequest( ctx context.Context, client *http.Client, method, rawURL string, form url.Values, headers http.Header, ) (string, error) { const fn = "internal.authenticator.steamRequest" logger := global.Log.WithField("func", fn). WithField("method", method). WithField("url", rawURL) method = strings.ToUpper(method) body := "" if form != nil { body = form.Encode() } finalURL := rawURL var reqBody io.Reader if method == http.MethodGet { if body != "" { if strings.Contains(finalURL, "?") { finalURL += "&" + body } else { finalURL += "?" + body } } } else { reqBody = strings.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, finalURL, reqBody) if err != nil { return "", err } req.Header.Set("User-Agent", steamMobileUserAgent) req.Header.Set("Accept", "text/javascript, text/html, application/xml, text/xml, */*") req.Header.Set("Accept-Encoding", "gzip, deflate") req.Header.Set("Referer", steamCommunityBase) if method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") } for k, vs := range headers { for _, v := range vs { req.Header.Add(k, v) } } if global.Log != nil { logger.WithField("form", redactFormForLog(form)).Debug("steam request") } resp, err := client.Do(req) if err != nil { logger.WithError(err).Warn("steam request failed") return "", err } defer resp.Body.Close() logger = logger.WithField("status", resp.StatusCode) var reader io.Reader = resp.Body if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { gz, gerr := gzip.NewReader(resp.Body) if gerr != nil { return "", gerr } defer gz.Close() reader = gz } raw, err := io.ReadAll(reader) if err != nil { return "", err } if resp.StatusCode == http.StatusForbidden { logger.Warn("steam request unauthorised") return string(raw), errSteamUnauthorised } if resp.StatusCode != http.StatusOK { logger.Warn("steam request non-200") return string(raw), fmt.Errorf("steam: %d %s", resp.StatusCode, resp.Status) } logger.Debug("steam response ok") return string(raw), nil } // errSteamUnauthorised maps the C# UnauthorisedRequestException — a 403 // from /steamguard or related endpoints typically means Family View has // disabled community content. var errSteamUnauthorised = fmt.Errorf("steam: unauthorised (403)") // redactFormForLog produces a copy of form with sensitive values // replaced by "". Returns "" for nil/empty input. func redactFormForLog(form url.Values) string { if len(form) == 0 { return "" } parts := make([]string, 0, len(form)) for k, vs := range form { if _, sensitive := steamFormFieldsToRedact[k]; sensitive { parts = append(parts, k+"=") continue } for _, v := range vs { parts = append(parts, k+"="+v) } } return strings.Join(parts, "&") } // buildRandomDeviceID returns "android:" followed by a freshly generated // UUID v4. Matches the C# SteamAuthenticator.BuildRandomId helper. func buildRandomDeviceID() string { var u [16]byte if _, err := rand.Read(u[:]); err != nil { // crypto/rand failing is fatal; fall back to a time-derived // value so the caller does not crash, but log it loudly. now := time.Now().UnixNano() for i := 0; i < 16; i++ { u[i] = byte(now >> (i % 8 * 8)) } } u[6] = (u[6] & 0x0F) | 0x40 // version 4 u[8] = (u[8] & 0x3F) | 0x80 // variant RFC 4122 hexb := func(b byte) (byte, byte) { const hexd = "0123456789abcdef" return hexd[b>>4], hexd[b&0x0F] } out := make([]byte, 0, 8+36) out = append(out, "android:"...) for i, b := range u { if i == 4 || i == 6 || i == 8 || i == 10 { out = append(out, '-') } hi, lo := hexb(b) out = append(out, hi, lo) } return string(out) } // stripNonASCII removes any non-ASCII code points from s. Steam's // login endpoint silently drops these from username/password, so the // client side must do the same to keep RSA-encrypted bytes consistent. func stripNonASCII(s string) string { b := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { c := s[i] if c < 0x80 { b = append(b, c) } } return string(b) }