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:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+366
View File
@@ -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
}
}