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 桩实现。
441 lines
12 KiB
Go
441 lines
12 KiB
Go
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
|
)
|
|
|
|
// steamWizardStep enumerates the wizard pages. The state machine is
|
|
// driven by the EnrollState.Requires* flags returned by each Enroll
|
|
// call; we just map them onto a UI step.
|
|
type steamWizardStep int
|
|
|
|
const (
|
|
steamStepCredentials steamWizardStep = iota
|
|
steamStepCaptcha
|
|
steamStepEmail
|
|
steamStepActivation
|
|
steamStepDone
|
|
)
|
|
|
|
// addSteamDialog is the Steam mobile authenticator enrollment wizard.
|
|
// It owns a single long-lived EnrollState plus a SteamAuthenticator
|
|
// instance; each "OK"-style button kicks off a goroutine that runs one
|
|
// Enroll round and updates the UI when it finishes.
|
|
//
|
|
// SECURITY: state.Password is wiped by Enroll itself once the credentials
|
|
// have been RSA-encrypted and sent. Captcha / email / SMS codes are not
|
|
// secrets per se but we still avoid logging them anywhere.
|
|
type addSteamDialog struct {
|
|
nameEd widget.Editor
|
|
usernameEd widget.Editor
|
|
passwordEd widget.Editor
|
|
captchaEd widget.Editor
|
|
emailEd widget.Editor
|
|
activateEd widget.Editor
|
|
|
|
okBtn widget.Clickable
|
|
cancelBtn widget.Clickable
|
|
|
|
step steamWizardStep
|
|
errorMsg string
|
|
|
|
mu sync.Mutex
|
|
pending bool
|
|
authImpl *authenticator.SteamAuthenticator
|
|
state *authenticator.EnrollState
|
|
lastDone bool
|
|
lastErr error
|
|
finalName string
|
|
|
|
// Cached decoded captcha image, plus a marker so we re-fetch only
|
|
// when the URL actually changes between Enroll rounds.
|
|
captchaURLLoaded string
|
|
captchaImg image.Image
|
|
captchaImgErr error
|
|
captchaLoading bool
|
|
|
|
invalidate func()
|
|
}
|
|
|
|
func newAddSteamDialog(invalidate func()) *addSteamDialog {
|
|
d := &addSteamDialog{
|
|
step: steamStepCredentials,
|
|
authImpl: authenticator.NewSteamAuthenticator(),
|
|
state: &authenticator.EnrollState{},
|
|
invalidate: invalidate,
|
|
}
|
|
d.nameEd.SingleLine = true
|
|
d.usernameEd.SingleLine = true
|
|
d.passwordEd.SingleLine = true
|
|
d.passwordEd.Mask = '*'
|
|
d.captchaEd.SingleLine = true
|
|
d.emailEd.SingleLine = true
|
|
d.activateEd.SingleLine = true
|
|
return d
|
|
}
|
|
|
|
func (d *addSteamDialog) Layout(
|
|
gtx layout.Context, th *material.Theme,
|
|
onDone func(authenticator.Authenticator, string),
|
|
) layout.Dimensions {
|
|
const fn = "internal.ui.addSteamDialog.Layout"
|
|
|
|
d.mu.Lock()
|
|
pending := d.pending
|
|
finished := !pending && d.lastDone
|
|
resErr := d.lastErr
|
|
stateErr := ""
|
|
if d.state != nil {
|
|
stateErr = d.state.Error
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
// Network-error path (e.g. transport, parse): show and let user retry.
|
|
if finished && resErr != nil {
|
|
d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error())
|
|
d.mu.Lock()
|
|
d.lastDone = false
|
|
d.lastErr = nil
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// Successful Enroll round: advance the wizard based on which flags
|
|
// the state machine just set, or surface state.Error for soft fails.
|
|
if finished && resErr == nil {
|
|
d.mu.Lock()
|
|
d.lastDone = false
|
|
st := d.state
|
|
d.mu.Unlock()
|
|
if st.Success {
|
|
d.step = steamStepDone
|
|
d.errorMsg = ""
|
|
} else if stateErr != "" {
|
|
d.errorMsg = stateErr
|
|
// Stay on current step so the user can retry the same input.
|
|
} else {
|
|
d.errorMsg = ""
|
|
switch {
|
|
case st.RequiresCaptcha:
|
|
d.step = steamStepCaptcha
|
|
case st.RequiresEmailAuth:
|
|
d.step = steamStepEmail
|
|
case st.RequiresActivation:
|
|
d.step = steamStepActivation
|
|
}
|
|
}
|
|
}
|
|
|
|
// Captcha image fetch when we land on the captcha step with a new URL.
|
|
if d.step == steamStepCaptcha && d.state != nil && d.state.CaptchaURL != "" &&
|
|
d.state.CaptchaURL != d.captchaURLLoaded && !d.captchaLoading {
|
|
d.captchaLoading = true
|
|
d.captchaURLLoaded = d.state.CaptchaURL
|
|
urlCopy := d.state.CaptchaURL
|
|
go func(u string) {
|
|
img, err := fetchCaptchaImage(u)
|
|
d.mu.Lock()
|
|
d.captchaImg = img
|
|
d.captchaImgErr = err
|
|
d.captchaLoading = false
|
|
d.mu.Unlock()
|
|
if d.invalidate != nil {
|
|
d.invalidate()
|
|
}
|
|
}(urlCopy)
|
|
}
|
|
|
|
// Cancel: always allowed; abort whatever step we're on.
|
|
if d.cancelBtn.Clicked(gtx) {
|
|
// Scrub the password just in case the user cancels before Enroll
|
|
// had a chance to wipe it.
|
|
d.mu.Lock()
|
|
if d.state != nil {
|
|
d.state.Password = ""
|
|
}
|
|
d.mu.Unlock()
|
|
onDone(nil, "")
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
}
|
|
|
|
// OK button: action depends on current step.
|
|
if d.okBtn.Clicked(gtx) && !pending {
|
|
switch d.step {
|
|
case steamStepDone:
|
|
// Final hand-off: build SessionData JSON now (we couldn't
|
|
// earlier because we kept the jar live until success).
|
|
d.mu.Lock()
|
|
sess := authenticator.SessionFromEnrollState(d.state)
|
|
d.authImpl.SessionData = sess.ToJSON()
|
|
d.mu.Unlock()
|
|
name := d.finalName
|
|
if name == "" {
|
|
name = i18n.T("vendor_steam")
|
|
}
|
|
onDone(d.authImpl, name)
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
|
|
case steamStepCredentials:
|
|
username := strings.TrimSpace(d.usernameEd.Text())
|
|
password := d.passwordEd.Text()
|
|
if username == "" {
|
|
d.errorMsg = i18n.T("msg_empty_username")
|
|
break
|
|
}
|
|
if password == "" {
|
|
d.errorMsg = i18n.T("msg_empty_password")
|
|
break
|
|
}
|
|
name := strings.TrimSpace(d.nameEd.Text())
|
|
if name == "" {
|
|
name = i18n.T("vendor_steam")
|
|
}
|
|
d.finalName = name
|
|
d.errorMsg = ""
|
|
d.mu.Lock()
|
|
d.state.Username = username
|
|
d.state.Password = password
|
|
// Drop the editor's plaintext copy as soon as we've handed
|
|
// it to the state machine.
|
|
d.passwordEd.SetText("")
|
|
d.mu.Unlock()
|
|
d.runEnroll(fn)
|
|
|
|
case steamStepCaptcha:
|
|
txt := strings.TrimSpace(d.captchaEd.Text())
|
|
if txt == "" {
|
|
d.errorMsg = i18n.T("msg_empty_captcha")
|
|
break
|
|
}
|
|
d.errorMsg = ""
|
|
d.mu.Lock()
|
|
d.state.CaptchaText = txt
|
|
d.mu.Unlock()
|
|
d.captchaEd.SetText("")
|
|
d.runEnroll(fn)
|
|
|
|
case steamStepEmail:
|
|
txt := strings.TrimSpace(d.emailEd.Text())
|
|
if txt == "" {
|
|
d.errorMsg = i18n.T("msg_empty_email_code")
|
|
break
|
|
}
|
|
d.errorMsg = ""
|
|
d.mu.Lock()
|
|
d.state.EmailAuthText = txt
|
|
d.mu.Unlock()
|
|
d.emailEd.SetText("")
|
|
d.runEnroll(fn)
|
|
|
|
case steamStepActivation:
|
|
txt := strings.TrimSpace(d.activateEd.Text())
|
|
if txt == "" {
|
|
d.errorMsg = i18n.T("msg_empty_activation_code")
|
|
break
|
|
}
|
|
d.errorMsg = ""
|
|
d.mu.Lock()
|
|
d.state.ActivationCode = txt
|
|
d.mu.Unlock()
|
|
d.activateEd.SetText("")
|
|
d.runEnroll(fn)
|
|
}
|
|
}
|
|
|
|
body := d.layoutBody(th)
|
|
return modalCard(gtx, th, i18n.T("dialog_add_steam_title"),
|
|
d.okLabel(), i18n.T("btn_cancel"),
|
|
&d.okBtn, &d.cancelBtn, body)
|
|
}
|
|
|
|
// runEnroll fires off a background goroutine that performs exactly one
|
|
// Enroll round. The Layout function picks up the result on the next frame.
|
|
func (d *addSteamDialog) runEnroll(fn string) {
|
|
d.mu.Lock()
|
|
if d.pending {
|
|
d.mu.Unlock()
|
|
return
|
|
}
|
|
d.pending = true
|
|
d.errorMsg = i18n.T("msg_busy")
|
|
d.mu.Unlock()
|
|
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
_, err := d.authImpl.Enroll(ctx, d.state)
|
|
d.mu.Lock()
|
|
d.pending = false
|
|
d.lastDone = true
|
|
d.lastErr = err
|
|
d.mu.Unlock()
|
|
if err != nil {
|
|
global.Log.WithField("func", fn).WithError(err).Warn("steam enroll round failed")
|
|
}
|
|
if d.invalidate != nil {
|
|
d.invalidate()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// okLabel chooses the primary-button text for the current step.
|
|
func (d *addSteamDialog) okLabel() string {
|
|
switch d.step {
|
|
case steamStepCredentials:
|
|
return i18n.T("btn_login")
|
|
case steamStepActivation:
|
|
return i18n.T("btn_activate")
|
|
case steamStepDone:
|
|
return i18n.T("btn_finish")
|
|
default:
|
|
return i18n.T("btn_continue")
|
|
}
|
|
}
|
|
|
|
// layoutBody renders the inputs specific to the current wizard step.
|
|
func (d *addSteamDialog) layoutBody(th *material.Theme) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
switch d.step {
|
|
case steamStepCredentials:
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("steam_step_credentials")).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_steam"))),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_username"), &d.usernameEd, "")),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.passwordEd, "")),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
|
|
case steamStepCaptcha:
|
|
d.mu.Lock()
|
|
img := d.captchaImg
|
|
loading := d.captchaLoading
|
|
imgErr := d.captchaImgErr
|
|
d.mu.Unlock()
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("steam_step_captcha")).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(captchaImageWidget(th, img, loading, imgErr)),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_captcha_text"), &d.captchaEd, "")),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
|
|
case steamStepEmail:
|
|
domain := ""
|
|
d.mu.Lock()
|
|
if d.state != nil {
|
|
domain = d.state.EmailDomain
|
|
}
|
|
d.mu.Unlock()
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, fmt.Sprintf(i18n.T("steam_step_email"), domain)).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_email_code"), &d.emailEd, "")),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
|
|
case steamStepActivation:
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("steam_step_activation")).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_activation_code"), &d.activateEd, "")),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
|
|
case steamStepDone:
|
|
revocation := ""
|
|
d.mu.Lock()
|
|
if d.state != nil {
|
|
revocation = d.state.RevocationCode
|
|
}
|
|
d.mu.Unlock()
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("steam_step_done")).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("label_revocation_code")).Layout),
|
|
layout.Rigid(material.H6(th, revocation).Layout),
|
|
)
|
|
}),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
}
|
|
return layout.Dimensions{}
|
|
}
|
|
}
|
|
|
|
// captchaImageWidget paints the captcha PNG/JPEG, or a placeholder line
|
|
// while it loads / on failure. We never log the captcha URL because it
|
|
// contains the captcha gid which is a Steam session token.
|
|
func captchaImageWidget(th *material.Theme, img image.Image, loading bool, imgErr error) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
if loading {
|
|
return material.Body2(th, i18n.T("hint_captcha_loading")).Layout(gtx)
|
|
}
|
|
if imgErr != nil {
|
|
return material.Body2(th, fmt.Sprintf(i18n.T("hint_captcha_failed"), imgErr.Error())).Layout(gtx)
|
|
}
|
|
if img == nil {
|
|
return layout.Dimensions{}
|
|
}
|
|
return widget.Image{
|
|
Src: paint.NewImageOp(img),
|
|
Fit: widget.Unscaled,
|
|
}.Layout(gtx)
|
|
}
|
|
}
|
|
|
|
// fetchCaptchaImage GETs the captcha URL and decodes it as PNG/JPEG/GIF.
|
|
// 10s timeout — Steam's captchas are tiny so anything longer is a hang.
|
|
func fetchCaptchaImage(rawURL string) (image.Image, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Steam's captcha endpoint accepts any UA but the rest of the flow
|
|
// uses the mobile UA, so be consistent.
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 4.1.1) Mobile")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
// Cap at 256KB — captchas are tiny; anything bigger is suspicious.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return img, nil
|
|
}
|