package authenticator import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "sync" "time" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // Steam WebAPI endpoints used by the client (login / cookie refresh / // logoff). Kept here rather than in steam_http.go because they are // SteamClient-specific. const ( steamAPIGetWGToken = "/IMobileAuthService/GetWGToken/v0001" steamAPILogon = "/ISteamWebUserPresenceOAuth/Logon/v0001" steamAPILogoff = "/ISteamWebUserPresenceOAuth/Logoff/v0001" ) // SteamClient is the Go port of the C# SteamClient inner class. It owns // a SteamSession, an attached SteamAuthenticator (for live TOTP codes), // and an HTTP client with persistent cookie jar. // // SECURITY: holds OAuth token, login cookies, and a reference to the // authenticator's shared secret via Authenticator.SteamData. Never log // the Session directly. type SteamClient struct { Authenticator *SteamAuthenticator Session *SteamSession // Login state flags inspected by the UI after Login returns. They // mirror the C# public fields exactly so the wizard logic stays // recognisable. InvalidLogin bool RequiresCaptcha bool CaptchaID string CaptchaURL string RequiresEmailAuth bool EmailDomain string Requires2FA bool Error string mu sync.Mutex jar http.CookieJar client *http.Client // confirmationsHTML / confirmationsQuery hold the most recent // GetConfirmations response so GetConfirmationDetails can wrap the // per-trade detail HTML in the same outer markup. Recomputed every // poll; never persisted. confirmationsHTML string confirmationsQuery string // Poller hooks. Set by the UI before StartConfirmationPoller. // Called from the poller goroutine — implementations must not // block the UI thread or attempt to drive the Gio frame loop // directly; marshal back via window.Invalidate or a channel. OnConfirmation ConfirmationCallback OnConfirmationError ConfirmationErrorCallback // ConfirmationPollerRetries controls how many consecutive failed // poll cycles trigger OnConfirmationError. Zero falls back to // defaultConfirmationPollerRetries (3). ConfirmationPollerRetries int // poller is the handle to the currently running background // goroutine, or nil if none is active. poller *pollerHandle } // NewSteamClient builds a client around an existing authenticator. If // sessionJSON is non-empty it is parsed via ParseSteamSession; the // resulting cookies are loaded into the internal jar so subsequent // requests carry the login state. func NewSteamClient(auth *SteamAuthenticator, sessionJSON string) (*SteamClient, error) { sess := ParseSteamSession(sessionJSON) jar, err := newSteamCookieJar() if err != nil { return nil, err } // Replay any cookies the session already had into the live jar. if len(sess.Cookies) > 0 { u, _ := url.Parse(steamCommunityBase + "/") cs := make([]*http.Cookie, 0, len(sess.Cookies)) for name, value := range sess.Cookies { cs = append(cs, &http.Cookie{Name: name, Value: value}) } jar.SetCookies(u, cs) } return &SteamClient{ Authenticator: auth, Session: sess, jar: jar, client: steamHTTPClient(jar), }, nil } // IsLoggedIn reports whether the session carries an OAuth token. Note // this does NOT round-trip to Steam — the token might have been // invalidated server-side. func (c *SteamClient) IsLoggedIn() bool { c.mu.Lock() defer c.mu.Unlock() return c.Session != nil && c.Session.OAuthToken != "" } // Clear resets the per-login flags and wipes the session. The jar is // recreated so leftover Steam cookies do not bleed into the next login // attempt. func (c *SteamClient) Clear() { c.mu.Lock() defer c.mu.Unlock() c.clearLocked() } func (c *SteamClient) clearLocked() { c.InvalidLogin = false c.RequiresCaptcha = false c.CaptchaID = "" c.CaptchaURL = "" c.RequiresEmailAuth = false c.EmailDomain = "" c.Requires2FA = false c.Error = "" if c.Session != nil { c.Session.Clear() } if jar, err := newSteamCookieJar(); err == nil { c.jar = jar c.client = steamHTTPClient(jar) } } // Login authenticates against Steam using credentials plus the live // TOTP code from the bound authenticator. Returns true on success. // On a non-fatal failure (captcha / email / 2FA needed, bad password) // it returns (false, nil) with the corresponding Requires* flag and // Error set. A non-nil error means the call could not even complete // the transport-level handshake. func (c *SteamClient) Login( ctx context.Context, username, password, captchaID, captchaText string, ) (bool, error) { const fn = "internal.authenticator.SteamClient.Login" logger := global.Log.WithField("func", fn) c.mu.Lock() defer c.mu.Unlock() c.Error = "" if c.Session.OAuthToken != "" { return true, nil } if !steamJarHasSessionID(c.jar) { headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}} if _, err := steamRequest(ctx, c.client, http.MethodGet, steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+ "&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers); err != nil { return false, fmt.Errorf("mobilelogin GET: %w", err) } } username = stripNonASCII(username) password = stripNonASCII(password) rsaResp, err := steamRequest(ctx, c.client, http.MethodPost, steamCommunityBase+"/mobilelogin/getrsakey", url.Values{"username": {username}}, nil) if err != nil { return false, fmt.Errorf("getrsakey: %w", err) } var rsaJSON rsaKeyResponse if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil { return false, fmt.Errorf("getrsakey parse: %w", err) } if !rsaJSON.Success { c.InvalidLogin = true c.Error = "Unknown username" return false, nil } encPw, err := steamRSAEncryptPassword(password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp) if err != nil { return false, fmt.Errorf("rsa encrypt: %w", err) } // Live TOTP — the key difference from Enroll, which sends "". twoFactor, err := c.Authenticator.CurrentCode() if err != nil { // No secret yet: send empty, Steam will reply requires_twofactor. twoFactor = "" } if captchaID == "" { captchaID = "-1" } if captchaText == "" { captchaText = "enter above characters" } form := url.Values{ "password": {base64.StdEncoding.EncodeToString(encPw)}, "username": {username}, "twofactorcode": {twoFactor}, "loginfriendlyname": {"#login_emailauth_friendlyname_mobile"}, "captchagid": {captchaID}, "captcha_text": {captchaText}, "rsatimestamp": {rsaJSON.Timestamp}, "remember_login": {"false"}, "oauth_client_id": {steamOAuthClientID}, "oauth_scope": {steamOAuthScope}, "donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, } // Wipe the local plaintext password copy now that it is RSA-encrypted. password = "" _ = password loginResp, err := steamRequest(ctx, c.client, http.MethodPost, steamCommunityBase+"/mobilelogin/dologin/", form, nil) if err != nil { return false, fmt.Errorf("dologin: %w", err) } var login map[string]any if err := json.Unmarshal([]byte(loginResp), &login); err != nil { return false, fmt.Errorf("dologin parse: %w", err) } if v, ok := login["emailsteamid"].(string); ok { c.Session.SteamId = v } c.InvalidLogin = false c.RequiresCaptcha = false c.CaptchaID = "" c.CaptchaURL = "" c.RequiresEmailAuth = false c.EmailDomain = "" c.Requires2FA = false complete, _ := login["login_complete"].(bool) oauthStr, _ := login["oauth"].(string) if !complete || oauthStr == "" { c.InvalidLogin = true if b, _ := login["captcha_needed"].(bool); b { c.RequiresCaptcha = true if id, ok := login["captcha_gid"].(string); ok { c.CaptchaID = id c.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id } } if b, _ := login["emailauth_needed"].(bool); b { if d, ok := login["emaildomain"].(string); ok && d != "" { c.EmailDomain = d } c.RequiresEmailAuth = true } if b, _ := login["requires_twofactor"].(bool); b { c.Requires2FA = true } if msg, ok := login["message"].(string); ok && msg != "" { c.Error = msg } return false, nil } var oauth struct { OAuthToken string `json:"oauth_token"` SteamID string `json:"steamid"` } if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil { return false, fmt.Errorf("oauth parse: %w", err) } c.Session.OAuthToken = oauth.OAuthToken if oauth.SteamID != "" { c.Session.SteamId = oauth.SteamID } c.syncCookiesFromJarLocked() logger.WithField("steamid", c.Session.SteamId).Info("steam login ok") return true, nil } // Refresh exchanges the stored OAuth token for fresh steamLogin / // steamLoginSecure cookies via IMobileAuthService/GetWGToken. Returns // true on success; false (with no error) if the response is missing // expected fields, mirroring the C# best-effort behaviour. func (c *SteamClient) Refresh(ctx context.Context) (bool, error) { const fn = "internal.authenticator.SteamClient.Refresh" logger := global.Log.WithField("func", fn) c.mu.Lock() defer c.mu.Unlock() if c.Session == nil || c.Session.OAuthToken == "" { return false, errors.New("steam: refresh without OAuth token") } resp, err := steamRequest(ctx, c.client, http.MethodPost, steamWebAPIBase+steamAPIGetWGToken, url.Values{"access_token": {c.Session.OAuthToken}}, nil) if err != nil { return false, fmt.Errorf("GetWGToken: %w", err) } var parsed struct { Response struct { Token string `json:"token"` TokenSecure string `json:"token_secure"` } `json:"response"` } if err := json.Unmarshal([]byte(resp), &parsed); err != nil { logger.WithError(err).Warn("GetWGToken parse failed") return false, nil } if parsed.Response.Token == "" || parsed.Response.TokenSecure == "" { return false, nil } u, _ := url.Parse(steamCommunityBase + "/") c.jar.SetCookies(u, []*http.Cookie{ {Name: "steamLogin", Value: c.Session.SteamId + "||" + parsed.Response.Token}, {Name: "steamLoginSecure", Value: c.Session.SteamId + "||" + parsed.Response.TokenSecure}, }) c.syncCookiesFromJarLocked() logger.Debug("session cookies refreshed") return true, nil } // Logout best-effort revokes the UMQ session (if one was opened) and // then clears the local state. Network errors are swallowed — the // local state must still end up cleared regardless of server reply. func (c *SteamClient) Logout(ctx context.Context) { c.mu.Lock() defer c.mu.Unlock() if c.Session != nil && c.Session.OAuthToken != "" && c.Session.UmqId != "" { _, _ = steamRequest(ctx, c.client, http.MethodPost, steamWebAPIBase+steamAPILogoff, url.Values{ "access_token": {c.Session.OAuthToken}, "umqid": {c.Session.UmqId}, }, nil) } c.clearLocked() } // syncCookiesFromJarLocked copies the community-domain cookies out of // the live jar into Session.Cookies. Must be called with c.mu held. func (c *SteamClient) syncCookiesFromJarLocked() { if c.Session == nil || c.jar == nil { return } u, _ := url.Parse(steamCommunityBase + "/") c.Session.Cookies = map[string]string{} for _, ck := range c.jar.Cookies(u) { c.Session.Cookies[ck.Name] = ck.Value } }