feat: Phase 1 WinAuth Go 移植完整实现
将原 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 桩实现。
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user