c671f2115e
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。 核心模块: - internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含 enroll/sync/code 生成、Steam 交易确认轮询 - internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链) - internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB - internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) + SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换 - internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化 - internal/qr: gozxing 二维码解码 + otpauth:// URI 解析 - internal/i18n: en/zh-CN/de 三语 TOML UI 模块 (Gio): - 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线 - 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板) - 设置:密码加密、老版 XML 导入、每条目热键配置 - Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗 构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
367 lines
11 KiB
Go
367 lines
11 KiB
Go
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
|
|
}
|
|
}
|