From cb916d7750711b65e4b2a3987d02cc8760a550a1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 21 Oct 2026 19:59:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(dcauth):=20=E6=96=B0=E5=A2=9E=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E6=89=98=E7=9B=98=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8E?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 改为单实例托盘常驻,协议唤起只弹一个确认窗口;补充 macOS 后台守护进程 与客户端版本显示。 --- apps/dcauth/README.md | 79 ++- apps/dcauth/hardware_windows.go | 12 +- apps/dcauth/main.go | 42 +- apps/dcauth/process_windows.go | 17 + apps/dcauth/registry_windows.go | 26 +- apps/dcauth/ui_darwin.go | 330 +++++++++++ apps/dcauth/ui_darwin_test.go | 13 + apps/dcauth/ui_other.go | 40 +- apps/dcauth/ui_windows.go | 561 +++++++++++++++++- apps/dcauth/versioninfo.rc | 28 + apps/dcauth/versioninfo_windows_amd64.syso | Bin 0 -> 924 bytes .../src/pages/admin/AdminLoginPage.tsx | 12 +- 12 files changed, 1087 insertions(+), 73 deletions(-) create mode 100644 apps/dcauth/process_windows.go create mode 100644 apps/dcauth/ui_darwin.go create mode 100644 apps/dcauth/ui_darwin_test.go create mode 100644 apps/dcauth/versioninfo.rc create mode 100644 apps/dcauth/versioninfo_windows_amd64.syso diff --git a/apps/dcauth/README.md b/apps/dcauth/README.md index f6fe7765..46d2ded8 100644 --- a/apps/dcauth/README.md +++ b/apps/dcauth/README.md @@ -1,38 +1,84 @@ -# DCAuth Windows 客户端 +# DCAuth 客户端 三个系统共用的设备认证客户端,通过 `dcauth://` 协议被浏览器唤起。 +## 运行方式 + +- **Windows**:GUI 常驻程序。启动后进入系统托盘,通过协议唤起时只出现一个登录确认窗口;成功后在托盘气泡中提示,不再连续弹多个消息框。 +- **macOS**:后台常驻进程,使用系统图形对话框确认登录。重复执行命令会转发给已有进程,不会叠加实例。 + ## 构建 +Windows x64: + ```bash cd apps/dcauth -GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe . +x86_64-w64-mingw32-windres --codepage=65001 versioninfo.rc \ + -O coff -o versioninfo_windows_amd64.syso +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -trimpath -ldflags "-H windowsgui -s -w" \ + -o dist/DCAuth.exe . ``` -ARM64: +版本号统一在 `main.go` 的 `clientVersion` 和 `versioninfo.rc` 中维护。`DCAuth.exe` 右键“属性 → 详细信息”会显示文件版本和产品版本,托盘菜单、设备信息及确认窗口也会显示当前版本。 + +Windows ARM64: ```bash -GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth-arm64.exe . +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 \ + go build -trimpath -ldflags "-H windowsgui -s -w" \ + -o dist/DCAuth-arm64.exe . ``` -## 首次运行 +macOS 通用二进制: -直接双击 exe 会: +```bash +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-arm64 . + +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-amd64 . + +lipo -create -output dist/DCAuth-macos \ + /tmp/DCAuth-macos-arm64 /tmp/DCAuth-macos-amd64 + +chmod +x dist/DCAuth-macos +codesign --force --sign - dist/DCAuth-macos +``` + +## Windows 首次运行 + +直接运行一次 `DCAuth.exe`: - 注册 `dcauth://` 协议到当前 Windows 用户。 - 生成 Ed25519 设备密钥,使用 Windows DPAPI 加密保存在 `%LOCALAPPDATA%\DCAuth\device.key`。 -- 显示设备码和机器码,并复制到剪贴板。 +- 进入系统托盘后台运行,不弹设备信息窗口。 + +托盘操作: + +- 双击图标:查看设备码和机器码,并复制到剪贴板。 +- 右键菜单:查看设备码、退出 DCAuth。 +- 浏览器登录:只弹出一个确认窗口;点击“确定”后显示成功气泡,点击“取消”则拒绝。 +- 再次唤起协议:第二个进程会把请求转交给常驻进程,然后立即退出。 ## macOS 开发调试 -Mac 上不需要安装 Windows exe,也不需要注册自定义协议。登录页在开发模式会显示一条 `dcauth://...` 链接,复制后执行: +登录页在开发模式会显示一条 `dcauth://...` 链接。复制后在终端执行: ```bash cd apps/dcauth -go run . "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost%3A5173" +./dist/DCAuth-macos "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost" ``` -客户端会提示确认,然后直接调用本地 Vite 代理的 `/api/dcauth/authorize`,完成本机签名。 +首次执行会启动后台进程并显示图形确认框;命令本身会立即返回。后续协议请求会转发给这个后台进程。 + +停止后台进程: + +```bash +./dist/DCAuth-macos --exit +``` + +macOS 当前仍是开发版:私钥明文保存在用户配置目录的 `DCAuth/device.key`,且浏览器不会自动拉起程序,需要从登录页复制命令执行。 如果只是测后台业务,不想每次都走短信和设备验证,可以在“后台用户”里把测试账号设为 **技术测试**。该账号只使用账号密码登录;正式账号仍必须走短信 + DCAuth。 @@ -44,7 +90,7 @@ go run . "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=h dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=https%3A%2F%2Forder.khhao.com ``` -客户端校验 app 和 api 白名单后,弹出确认框,签名并提交到: +客户端校验 app 和 api 白名单后,弹出一次确认框,签名并提交到: ```text POST /api/dcauth/authorize @@ -60,11 +106,18 @@ POST /api/dcauth/authorize | `live_hub` | `https://live.khhao.com` | | `affiliate_dash` | `https://skin.khhao.com` | -本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。 +本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://localhost/`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。 + +## 验证 + +```bash +go test ./... +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe . +``` ## 安全说明 -- 私钥不离开本机,使用 DPAPI 保护。 +- 私钥不离开本机,Windows 使用 DPAPI 保护。 - 服务端只保存公钥和机器码哈希/脱敏值。 - 机器码采集主板 UUID、MachineGuid、主板/BIOS/系统盘序列号、CPU ID,不上传原始序列号。 - 正式分发前建议使用代码签名证书,避免 SmartScreen 和杀毒软件误报。 diff --git a/apps/dcauth/hardware_windows.go b/apps/dcauth/hardware_windows.go index d9698f34..9b05d5d4 100644 --- a/apps/dcauth/hardware_windows.go +++ b/apps/dcauth/hardware_windows.go @@ -26,13 +26,15 @@ $disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Sele systemDiskSerial = [string]$disk.SerialNumber } | ConvertTo-Json -Compress ` - output, err := exec.Command( + command := exec.Command( "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, - ).Output() + ) + hideCommandWindow(command) + output, err := command.Output() if err == nil { var payload map[string]string if json.Unmarshal([]byte(strings.TrimSpace(string(output))), &payload) == nil { @@ -54,9 +56,11 @@ $disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Sele } func readMachineGUID() string { - output, err := exec.Command( + command := exec.Command( "reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid", - ).Output() + ) + hideCommandWindow(command) + output, err := command.Output() if err != nil { return "" } diff --git a/apps/dcauth/main.go b/apps/dcauth/main.go index e982f478..6d0677c4 100644 --- a/apps/dcauth/main.go +++ b/apps/dcauth/main.go @@ -15,7 +15,7 @@ import ( "time" ) -const clientVersion = "1.0.0" +const clientVersion = "1.1.0" type appConfig struct { DisplayName string @@ -77,45 +77,7 @@ type authorizeRequest struct { } func main() { - registerProtocol() - - identity, err := loadOrCreateIdentity() - if err != nil { - showError("DCAuth 初始化失败", err.Error()) - return - } - - protocolArg := findProtocolArg(os.Args[1:]) - if protocolArg == "" { - text := fmt.Sprintf( - "设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。", - identity.deviceID, - identity.machineCode, - ) - copyToClipboard(identity.deviceID + "\n" + identity.machineCode) - showInfo("DCAuth 设备信息", text+"\n\n设备码和机器码已复制到剪贴板。") - return - } - - target, err := parseAuthorizeURL(protocolArg) - if err != nil { - showError("DCAuth 协议无效", err.Error()) - return - } - - app := appRegistry[target.App] - if !confirm( - app.DisplayName+" · 本机登录确认", - fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode), - ) { - return - } - - if err := submitAuthorization(identity, target); err != nil { - showError("本机验证失败", err.Error()) - return - } - showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。") + runClient(findProtocolArg(os.Args[1:])) } type authorizeTarget struct { diff --git a/apps/dcauth/process_windows.go b/apps/dcauth/process_windows.go new file mode 100644 index 00000000..7352d180 --- /dev/null +++ b/apps/dcauth/process_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +const createNoWindow = 0x08000000 + +func hideCommandWindow(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNoWindow, + } +} diff --git a/apps/dcauth/registry_windows.go b/apps/dcauth/registry_windows.go index e2361921..2421c7eb 100644 --- a/apps/dcauth/registry_windows.go +++ b/apps/dcauth/registry_windows.go @@ -19,8 +19,26 @@ func registerProtocol() { } command := `"` + executable + `" "%1"` - _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/ve", "/d", "URL:DCAuth Protocol", "/f").Run() - _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/v", "URL Protocol", "/d", "", "/f").Run() - _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`, "/ve", "/d", executable+",0", "/f").Run() - _ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`, "/ve", "/d", command, "/f").Run() + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth`, + "/ve", "/d", "URL:DCAuth Protocol", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth`, + "/v", "URL Protocol", "/d", "", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`, + "/ve", "/d", executable+",0", "/f", + ) + runHiddenCommand( + "reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`, + "/ve", "/d", command, "/f", + ) +} + +func runHiddenCommand(name string, args ...string) { + command := exec.Command(name, args...) + hideCommandWindow(command) + _ = command.Run() } diff --git a/apps/dcauth/ui_darwin.go b/apps/dcauth/ui_darwin.go new file mode 100644 index 00000000..a970e4eb --- /dev/null +++ b/apps/dcauth/ui_darwin.go @@ -0,0 +1,330 @@ +//go:build darwin + +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + daemonShow = "__show__" + daemonExit = "__exit__" +) + +func runClient(protocolArg string) { + socketPath, err := darwinSocketPath() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + + if hasCommandLineArg("--exit") { + if forwardToDarwinDaemon(socketPath, daemonExit) { + return + } + showError("DCAuth 未运行", "当前没有正在运行的 DCAuth 后台进程。") + return + } + + message := protocolArg + if message == "" { + message = daemonShow + } + if forwardToDarwinDaemon(socketPath, message) { + return + } + if os.Getenv("DCAUTH_BACKGROUND") == "1" { + runDarwinDaemon(protocolArg) + return + } + if err := startDarwinDaemon(protocolArg); err != nil { + showError("DCAuth 启动失败", err.Error()) + } +} + +func runDarwinDaemon(protocolArg string) { + lockPath, socketPath, err := darwinDaemonPaths() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + lockFile, acquired, err := acquireDaemonLock(lockPath) + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if !acquired { + message := protocolArg + if message == "" { + message = daemonShow + } + if !waitForDarwinDaemon(socketPath, message, 10*time.Second) { + showError("DCAuth 启动失败", "已有后台进程正在启动,但暂时无法连接。") + } + return + } + defer lockFile.Close() + + identity, err := loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + _ = os.Remove(socketPath) + listener, err := net.Listen("unix", socketPath) + if err != nil { + showError("DCAuth 启动失败", fmt.Sprintf("无法创建后台通信通道:%v", err)) + return + } + defer listener.Close() + defer os.Remove(socketPath) + _ = os.Chmod(socketPath, 0o600) + + requests := make(chan string, 16) + go acceptDarwinRequests(listener, requests) + + if protocolArg != "" { + handleDarwinAuthorization(identity, protocolArg) + } else { + notifyDarwin("DCAuth v"+clientVersion, "DCAuth 已在后台运行。首次打开会更新协议绑定。") + } + + for request := range requests { + switch request { + case daemonExit: + return + case daemonShow: + showDarwinDeviceInfo(identity) + default: + handleDarwinAuthorization(identity, request) + } + } +} + +func acceptDarwinRequests(listener net.Listener, requests chan<- string) { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 1024), 1024*1024) + for scanner.Scan() { + request := strings.TrimSpace(scanner.Text()) + if request != "" { + requests <- request + } + } + }(connection) + } +} + +func handleDarwinAuthorization(identity identity, raw string) { + target, err := parseAuthorizeURL(raw) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirmDarwin( + "DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认", + fmt.Sprintf( + "是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“允许登录”完成验证,点击“取消”拒绝。", + app.DisplayName, + clientVersion, + identity.deviceID, + identity.machineCode, + ), + ) { + return + } + if err := submitAuthorization(identity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + notifyDarwin("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。") +} + +func showDarwinDeviceInfo(identity identity) { + copyToClipboard(identity.deviceID + "\n" + identity.machineCode) + showInfo( + "DCAuth v"+clientVersion+" 设备信息", + fmt.Sprintf( + "版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。", + clientVersion, + identity.deviceID, + identity.machineCode, + ), + ) +} + +func startDarwinDaemon(protocolArg string) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("无法定位 DCAuth 程序:%w", err) + } + args := make([]string, 0, 1) + if protocolArg != "" { + args = append(args, protocolArg) + } + command := exec.Command(executable, args...) + command.Env = append(os.Environ(), "DCAUTH_BACKGROUND=1") + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + logPath, err := darwinLogPath() + if err == nil { + if logFile, openErr := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); openErr == nil { + defer logFile.Close() + command.Stdout = logFile + command.Stderr = logFile + } + } + if err := command.Start(); err != nil { + return fmt.Errorf("无法启动后台进程:%w", err) + } + return command.Process.Release() +} + +func acquireDaemonLock(path string) (*os.File, bool, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, false, fmt.Errorf("无法创建后台锁文件:%w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, false, nil + } + return nil, false, fmt.Errorf("无法锁定后台进程:%w", err) + } + return file, true, nil +} + +func forwardToDarwinDaemon(socketPath, message string) bool { + connection, err := net.DialTimeout("unix", socketPath, 250*time.Millisecond) + if err != nil { + return false + } + defer connection.Close() + _ = connection.SetWriteDeadline(time.Now().Add(time.Second)) + _, err = io.WriteString(connection, message+"\n") + return err == nil +} + +func waitForDarwinDaemon(socketPath, message string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if forwardToDarwinDaemon(socketPath, message) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +func darwinDaemonPaths() (string, string, error) { + directory, err := darwinDataDir() + if err != nil { + return "", "", err + } + return filepath.Join(directory, "daemon.lock"), filepath.Join(directory, "dcauth.sock"), nil +} + +func darwinSocketPath() (string, error) { + _, socketPath, err := darwinDaemonPaths() + return socketPath, err +} + +func darwinLogPath() (string, error) { + directory, err := darwinDataDir() + if err != nil { + return "", err + } + return filepath.Join(directory, "dcauth.log"), nil +} + +func darwinDataDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("无法定位用户配置目录:%w", err) + } + directory := filepath.Join(base, "DCAuth") + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", fmt.Errorf("无法创建 DCAuth 配置目录:%w", err) + } + return directory, nil +} + +func hasCommandLineArg(target string) bool { + for _, arg := range os.Args[1:] { + if strings.EqualFold(strings.TrimSpace(arg), target) { + return true + } + } + return false +} + +func showInfo(title, text string) { + _, _ = runAppleScript( + "display dialog " + appleScriptString(text) + + " with title " + appleScriptString(title) + + " buttons {\"好\"} default button \"好\" with icon note", + ) +} + +func showError(title, text string) { + _, _ = runAppleScript( + "display alert " + appleScriptString(title) + + " message " + appleScriptString(text) + + " as critical buttons {\"好\"} default button \"好\"", + ) +} + +func confirmDarwin(title, text string) bool { + output, err := runAppleScript( + "display dialog " + appleScriptString(text) + + " with title " + appleScriptString(title) + + " buttons {\"取消\", \"允许登录\"} default button \"允许登录\" cancel button \"取消\" with icon caution", + ) + return err == nil && strings.Contains(output, "允许登录") +} + +func notifyDarwin(title, text string) { + _, _ = runAppleScript( + "display notification " + appleScriptString(text) + + " with title " + appleScriptString(title), + ) +} + +func runAppleScript(statement string) (string, error) { + output, err := exec.Command("osascript", "-e", statement).CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func appleScriptString(text string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "\"", "\\\"", + "\r", "", + "\n", "\\n", + ) + return `"` + replacer.Replace(text) + `"` +} + +func copyToClipboard(text string) { + command := exec.Command("pbcopy") + command.Stdin = strings.NewReader(text) + _ = command.Run() +} diff --git a/apps/dcauth/ui_darwin_test.go b/apps/dcauth/ui_darwin_test.go new file mode 100644 index 00000000..d93436bd --- /dev/null +++ b/apps/dcauth/ui_darwin_test.go @@ -0,0 +1,13 @@ +//go:build darwin + +package main + +import "testing" + +func TestAppleScriptStringEscapesDialogText(t *testing.T) { + got := appleScriptString("第一行\n第二行 \"确认\" \\ DCAuth") + want := `"第一行\n第二行 \"确认\" \\ DCAuth"` + if got != want { + t.Fatalf("appleScriptString() = %q, want %q", got, want) + } +} diff --git a/apps/dcauth/ui_other.go b/apps/dcauth/ui_other.go index af193d24..39b3882d 100644 --- a/apps/dcauth/ui_other.go +++ b/apps/dcauth/ui_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !darwin package main @@ -7,6 +7,44 @@ import ( "os" ) +func runClient(protocolArg string) { + identity, err := loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + + if protocolArg == "" { + text := fmt.Sprintf( + "DCAuth v%s\n设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。", + clientVersion, + identity.deviceID, + identity.machineCode, + ) + copyToClipboard(identity.deviceID + "\n" + identity.machineCode) + showInfo("DCAuth v"+clientVersion+" 设备信息", text+"\n\n设备码和机器码已复制到剪贴板。") + return + } + + target, err := parseAuthorizeURL(protocolArg) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirm( + app.DisplayName+" · 本机登录确认", + fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode), + ) { + return + } + if err := submitAuthorization(identity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。") +} + func showInfo(title, text string) { fmt.Printf("[%s]\n%s\n", title, text) } diff --git a/apps/dcauth/ui_windows.go b/apps/dcauth/ui_windows.go index bfae5d01..9d80dfff 100644 --- a/apps/dcauth/ui_windows.go +++ b/apps/dcauth/ui_windows.go @@ -3,6 +3,8 @@ package main import ( + "fmt" + "runtime" "syscall" "unicode/utf16" "unsafe" @@ -14,6 +16,8 @@ const ( mbIconError = 0x00000010 mbIconQuestion = 0x00000020 mbIconInformation = 0x00000040 + mbSetForeground = 0x00010000 + mbTopMost = 0x00040000 idOK = 1 ) @@ -22,18 +26,539 @@ const ( gmemMoveable = 0x0002 ) +const ( + wmNull = 0x0000 + wmDestroy = 0x0002 + wmClose = 0x0010 + wmCopyData = 0x004A + wmCommand = 0x0111 + wmLButtonUp = 0x0202 + wmLButtonDblClk = 0x0203 + wmRButtonUp = 0x0205 + wmContextMenu = 0x007B + wmApp = 0x8000 +) + +const ( + trayCallbackMessage = wmApp + 1 + showWindowMessage = wmApp + 2 + copyDataMagic = 0x44434131 +) + +const ( + trayMenuDeviceInfo = 1001 + trayMenuExit = 1002 +) + +const ( + nimAdd = 0x00000000 + nimModify = 0x00000001 + nimDelete = 0x00000002 +) + +const ( + nifMessage = 0x00000001 + nifIcon = 0x00000002 + nifTip = 0x00000004 + nifInfo = 0x00000010 +) + +const ( + niifInfo = 0x00000001 +) + +const ( + mfString = 0x00000000 + mfGrayed = 0x00000001 + mfSeparator = 0x00000800 + tpmRight = 0x00000002 + tpmReturn = 0x00000100 +) + +const ( + errorAlreadyExists = syscall.Errno(183) + errorClassAlreadyReg = syscall.Errno(1410) +) + +const ( + windowClassName = "DCAuthHiddenWindow" + windowTitle = "DCAuth" + mutexName = `Local\DCAuth.OrderSite` + authorizationMutexName = `Local\DCAuth.OrderSite.Authorization` +) + +type point struct { + X int32 + Y int32 +} + +type message struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point + Private uint32 +} + +type wndClassEx struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance uintptr + HIcon uintptr + HCursor uintptr + HbrBackground uintptr + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm uintptr +} + +type copyDataStruct struct { + DwData uintptr + CbData uint32 + LpData uintptr +} + +type guid struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +type notifyIconData struct { + CbSize uint32 + HWnd uintptr + UID uint32 + UFlags uint32 + UCallbackMessage uint32 + HIcon uintptr + SzTip [128]uint16 + DwState uint32 + DwStateMask uint32 + SzInfo [256]uint16 + UVersion uint32 + SzInfoTitle [64]uint16 + DwInfoFlags uint32 + GuidItem guid + HBalloonIcon uintptr +} + var ( - user32DLL = syscall.NewLazyDLL("user32.dll") - messageBoxW = user32DLL.NewProc("MessageBoxW") - openClip = user32DLL.NewProc("OpenClipboard") - closeClip = user32DLL.NewProc("CloseClipboard") - emptyClip = user32DLL.NewProc("EmptyClipboard") - setClipData = user32DLL.NewProc("SetClipboardData") + user32DLL = syscall.NewLazyDLL("user32.dll") + shell32DLL = syscall.NewLazyDLL("shell32.dll") + + messageBoxW = user32DLL.NewProc("MessageBoxW") + openClip = user32DLL.NewProc("OpenClipboard") + closeClip = user32DLL.NewProc("CloseClipboard") + emptyClip = user32DLL.NewProc("EmptyClipboard") + setClipData = user32DLL.NewProc("SetClipboardData") + registerClassExW = user32DLL.NewProc("RegisterClassExW") + createWindowExW = user32DLL.NewProc("CreateWindowExW") + defWindowProcW = user32DLL.NewProc("DefWindowProcW") + getMessageW = user32DLL.NewProc("GetMessageW") + translateMessage = user32DLL.NewProc("TranslateMessage") + dispatchMessageW = user32DLL.NewProc("DispatchMessageW") + postQuitMessage = user32DLL.NewProc("PostQuitMessage") + destroyWindow = user32DLL.NewProc("DestroyWindow") + findWindowW = user32DLL.NewProc("FindWindowW") + sendMessageW = user32DLL.NewProc("SendMessageW") + postMessageW = user32DLL.NewProc("PostMessageW") + createPopupMenu = user32DLL.NewProc("CreatePopupMenu") + appendMenuW = user32DLL.NewProc("AppendMenuW") + trackPopupMenu = user32DLL.NewProc("TrackPopupMenu") + destroyMenu = user32DLL.NewProc("DestroyMenu") + setForegroundWindow = user32DLL.NewProc("SetForegroundWindow") + getCursorPos = user32DLL.NewProc("GetCursorPos") + loadIconW = user32DLL.NewProc("LoadIconW") + shellNotifyIconW = shell32DLL.NewProc("Shell_NotifyIconW") + globalAlloc = kernel32DLL.NewProc("GlobalAlloc") globalLock = kernel32DLL.NewProc("GlobalLock") globalUnlock = kernel32DLL.NewProc("GlobalUnlock") + createMutexW = kernel32DLL.NewProc("CreateMutexW") + getModuleW = kernel32DLL.NewProc("GetModuleHandleW") + sleep = kernel32DLL.NewProc("Sleep") ) +var ( + mainWindow uintptr + instanceMutex uintptr + trayData notifyIconData + trayAdded bool + runtimeIdentity identity + authorizationActive bool + windowProcedure = syscall.NewCallback(dcauthWindowProc) +) + +func runClient(protocolArg string) { + runtime.LockOSThread() + firstInstance, err := acquireSingleInstance() + if err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if !firstInstance { + if authorizationInProgress() { + return + } + if forwardToRunningInstance(protocolArg) { + return + } + showError("DCAuth 启动失败", "已有 DCAuth 实例正在启动,但暂时无法连接,请稍后重试。") + return + } + + registerProtocol() + runtimeIdentity, err = loadOrCreateIdentity() + if err != nil { + showError("DCAuth 初始化失败", err.Error()) + return + } + if err := createHiddenWindow(); err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + if err := addTrayIcon(); err != nil { + showError("DCAuth 启动失败", err.Error()) + return + } + + if protocolArg != "" { + handleAuthorization(protocolArg) + } else { + showTrayBalloon("DCAuth v"+clientVersion+" 已在后台运行", "双击托盘图标可查看版本、设备码和机器码。") + } + + runMessageLoop() + removeTrayIcon() +} + +func acquireSingleInstance() (bool, error) { + namePtr, _ := syscall.UTF16PtrFromString(mutexName) + handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + runtime.KeepAlive(namePtr) + if handle == 0 { + return false, fmt.Errorf("无法创建单实例锁:%v", callErr) + } + if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists { + _ = syscall.CloseHandle(syscall.Handle(handle)) + return false, nil + } + instanceMutex = handle + return true, nil +} + +func authorizationInProgress() bool { + handle, acquired, err := acquireNamedMutex(authorizationMutexName) + if err != nil || !acquired { + if handle != 0 { + _ = syscall.CloseHandle(syscall.Handle(handle)) + } + return err == nil && !acquired + } + _ = syscall.CloseHandle(syscall.Handle(handle)) + return false +} + +func acquireNamedMutex(name string) (uintptr, bool, error) { + namePtr, _ := syscall.UTF16PtrFromString(name) + handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + runtime.KeepAlive(namePtr) + if handle == 0 { + return 0, false, fmt.Errorf("无法创建互斥锁:%v", callErr) + } + if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists { + return handle, false, nil + } + return handle, true, nil +} + +func forwardToRunningInstance(protocolArg string) bool { + for attempt := 0; attempt < 100; attempt++ { + if postToRunningInstance(protocolArg) { + return true + } + sleep.Call(100) + } + return false +} + +func postToRunningInstance(protocolArg string) bool { + classPtr, _ := syscall.UTF16PtrFromString(windowClassName) + titlePtr, _ := syscall.UTF16PtrFromString(windowTitle) + hwnd, _, _ := findWindowW.Call( + uintptr(unsafe.Pointer(classPtr)), + uintptr(unsafe.Pointer(titlePtr)), + ) + runtime.KeepAlive(classPtr) + runtime.KeepAlive(titlePtr) + if hwnd == 0 { + return false + } + if protocolArg == "" { + postMessageW.Call(hwnd, showWindowMessage, 0, 0) + return true + } + + encoded, _ := syscall.UTF16FromString(protocolArg) + data := copyDataStruct{ + DwData: copyDataMagic, + CbData: uint32(len(encoded) * 2), + LpData: uintptr(unsafe.Pointer(&encoded[0])), + } + result, _, _ := sendMessageW.Call( + hwnd, + wmCopyData, + 0, + uintptr(unsafe.Pointer(&data)), + ) + runtime.KeepAlive(encoded) + return result != 0 +} + +func createHiddenWindow() error { + instance, _, _ := getModuleW.Call(0) + if instance == 0 { + return fmt.Errorf("无法读取程序实例句柄") + } + icon, _, _ := loadIconW.Call(0, 32512) + + classNamePtr, _ := syscall.UTF16PtrFromString(windowClassName) + class := wndClassEx{ + CbSize: uint32(unsafe.Sizeof(wndClassEx{})), + LpfnWndProc: windowProcedure, + HInstance: instance, + HIcon: icon, + LpszClassName: classNamePtr, + HIconSm: icon, + } + result, _, callErr := registerClassExW.Call(uintptr(unsafe.Pointer(&class))) + runtime.KeepAlive(classNamePtr) + if result == 0 { + if errno, ok := callErr.(syscall.Errno); !ok || errno != errorClassAlreadyReg { + return fmt.Errorf("无法注册窗口类:%v", callErr) + } + } + + titlePtr, _ := syscall.UTF16PtrFromString(windowTitle) + const wsExToolWindow = 0x00000080 + hwnd, _, callErr := createWindowExW.Call( + wsExToolWindow, + uintptr(unsafe.Pointer(classNamePtr)), + uintptr(unsafe.Pointer(titlePtr)), + 0, + 0, 0, 0, 0, + 0, + 0, + instance, + 0, + ) + if hwnd == 0 { + return fmt.Errorf("无法创建 DCAuth 后台窗口:%v", callErr) + } + runtime.KeepAlive(classNamePtr) + runtime.KeepAlive(titlePtr) + mainWindow = hwnd + return nil +} + +func runMessageLoop() { + var msg message + for { + result, _, _ := getMessageW.Call( + uintptr(unsafe.Pointer(&msg)), + 0, + 0, + 0, + ) + if int32(result) <= 0 { + return + } + translateMessage.Call(uintptr(unsafe.Pointer(&msg))) + dispatchMessageW.Call(uintptr(unsafe.Pointer(&msg))) + } +} + +func dcauthWindowProc(hwnd, msg, wParam, lParam uintptr) uintptr { + switch uint32(msg) { + case wmCopyData: + data := (*copyDataStruct)(unsafe.Pointer(lParam)) + if data != nil && data.DwData == copyDataMagic && data.CbData > 0 && data.LpData != 0 { + count := int(data.CbData / 2) + raw := syscall.UTF16ToString(unsafe.Slice((*uint16)(unsafe.Pointer(data.LpData)), count)) + handleAuthorization(raw) + return 1 + } + return 0 + case showWindowMessage: + showDeviceInfo() + return 0 + case trayCallbackMessage: + switch uint32(lParam) { + case wmLButtonDblClk: + showDeviceInfo() + case wmRButtonUp, wmContextMenu: + showTrayMenu(hwnd) + } + return 0 + case wmClose: + destroyWindow.Call(hwnd) + return 0 + case wmDestroy: + removeTrayIcon() + postQuitMessage.Call(0) + return 0 + } + result, _, _ := defWindowProcW.Call(hwnd, msg, wParam, lParam) + return result +} + +func addTrayIcon() error { + icon, _, _ := loadIconW.Call(0, 32512) + trayData = notifyIconData{ + HWnd: mainWindow, + UID: 1, + UFlags: nifMessage | nifIcon | nifTip, + UCallbackMessage: trayCallbackMessage, + HIcon: icon, + } + trayData.CbSize = uint32(unsafe.Sizeof(trayData)) + copyUTF16(trayData.SzTip[:], "DCAuth v"+clientVersion+" - 本机设备认证(后台运行)") + result, _, _ := shellNotifyIconW.Call( + nimAdd, + uintptr(unsafe.Pointer(&trayData)), + ) + if result == 0 { + return fmt.Errorf("无法创建系统托盘图标") + } + trayAdded = true + return nil +} + +func removeTrayIcon() { + if !trayAdded { + return + } + shellNotifyIconW.Call(nimDelete, uintptr(unsafe.Pointer(&trayData))) + trayAdded = false +} + +func showTrayBalloon(title, text string) { + if !trayAdded { + return + } + info := trayData + info.UFlags |= nifInfo + info.UVersion = 10000 + info.DwInfoFlags = niifInfo + copyUTF16(info.SzInfoTitle[:], title) + copyUTF16(info.SzInfo[:], text) + shellNotifyIconW.Call(nimModify, uintptr(unsafe.Pointer(&info))) +} + +func showTrayMenu(hwnd uintptr) { + menu, _, _ := createPopupMenu.Call() + if menu == 0 { + return + } + defer destroyMenu.Call(menu) + + statusText := mustUTF16("DCAuth v" + clientVersion + " · 后台运行中") + deviceText := mustUTF16("查看设备码") + exitText := mustUTF16("退出 DCAuth") + appendMenuW.Call(menu, mfString|mfGrayed, 0, uintptr(unsafe.Pointer(statusText))) + appendMenuW.Call(menu, mfString, trayMenuDeviceInfo, uintptr(unsafe.Pointer(deviceText))) + appendMenuW.Call(menu, mfSeparator, 0, 0) + appendMenuW.Call(menu, mfString, trayMenuExit, uintptr(unsafe.Pointer(exitText))) + runtime.KeepAlive(statusText) + runtime.KeepAlive(deviceText) + runtime.KeepAlive(exitText) + + var cursor point + if ok, _, _ := getCursorPos.Call(uintptr(unsafe.Pointer(&cursor))); ok == 0 { + return + } + setForegroundWindow.Call(hwnd) + command, _, _ := trackPopupMenu.Call( + menu, + tpmRight|tpmReturn, + uintptr(cursor.X), + uintptr(cursor.Y), + 0, + hwnd, + 0, + ) + postMessageW.Call(hwnd, wmNull, 0, 0) + + switch command { + case trayMenuDeviceInfo: + showDeviceInfo() + case trayMenuExit: + destroyWindow.Call(hwnd) + } +} + +func showDeviceInfo() { + text := fmt.Sprintf( + "版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。", + clientVersion, + runtimeIdentity.deviceID, + runtimeIdentity.machineCode, + ) + copyToClipboard(runtimeIdentity.deviceID + "\n" + runtimeIdentity.machineCode) + showInfo("DCAuth v"+clientVersion+" 设备信息", text) +} + +func handleAuthorization(raw string) { + if authorizationActive { + showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。") + return + } + authorizationActive = true + defer func() { authorizationActive = false }() + + authHandle, acquired, err := acquireNamedMutex(authorizationMutexName) + if err != nil { + showError("DCAuth 验证失败", err.Error()) + return + } + if !acquired { + _ = syscall.CloseHandle(syscall.Handle(authHandle)) + showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。") + return + } + defer syscall.CloseHandle(syscall.Handle(authHandle)) + + target, err := parseAuthorizeURL(raw) + if err != nil { + showError("DCAuth 协议无效", err.Error()) + return + } + app := appRegistry[target.App] + if !confirm( + "DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认", + fmt.Sprintf( + "是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“确定”允许本次登录,点击“取消”拒绝。", + app.DisplayName, + clientVersion, + runtimeIdentity.deviceID, + runtimeIdentity.machineCode, + ), + ) { + return + } + if err := submitAuthorization(runtimeIdentity, target); err != nil { + showError("本机验证失败", err.Error()) + return + } + showTrayBalloon("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。") +} + func showInfo(title, text string) { showMessage(title, text, mbOK|mbIconInformation) } @@ -50,21 +575,37 @@ func showMessage(title, text string, flags uint) int { titlePtr, _ := syscall.UTF16PtrFromString(title) textPtr, _ := syscall.UTF16PtrFromString(text) result, _, _ := messageBoxW.Call( - 0, + mainWindow, uintptr(unsafe.Pointer(textPtr)), uintptr(unsafe.Pointer(titlePtr)), - uintptr(flags), + uintptr(flags|mbSetForeground|mbTopMost), ) + runtime.KeepAlive(titlePtr) + runtime.KeepAlive(textPtr) return int(result) } +func copyUTF16(destination []uint16, text string) { + encoded := utf16.Encode([]rune(text)) + if len(encoded) >= len(destination) { + encoded = encoded[:len(destination)-1] + } + copy(destination, encoded) + destination[len(encoded)] = 0 +} + +func mustUTF16(text string) *uint16 { + value, _ := syscall.UTF16PtrFromString(text) + return value +} + func copyToClipboard(text string) { - opened, _, _ := openClip.Call(0) + opened, _, _ := openClip.Call(mainWindow) if opened == 0 { return } defer closeClip.Call() - emptyClip.Call() + emptyClip.Call(mainWindow) encoded := utf16.Encode([]rune(text)) encoded = append(encoded, 0) diff --git a/apps/dcauth/versioninfo.rc b/apps/dcauth/versioninfo.rc new file mode 100644 index 00000000..74755237 --- /dev/null +++ b/apps/dcauth/versioninfo.rc @@ -0,0 +1,28 @@ +1 VERSIONINFO +FILEVERSION 1,1,0,0 +PRODUCTVERSION 1,1,0,0 +FILEFLAGSMASK 0x3fL +FILEFLAGS 0x0L +FILEOS 0x40004L +FILETYPE 0x1L +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Order Site" + VALUE "FileDescription", "DCAuth device authentication client" + VALUE "FileVersion", "1.1.0.0" + VALUE "InternalName", "DCAuth" + VALUE "LegalCopyright", "Copyright (C) 2026 Order Site" + VALUE "OriginalFilename", "DCAuth.exe" + VALUE "ProductName", "DCAuth" + VALUE "ProductVersion", "1.1.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END diff --git a/apps/dcauth/versioninfo_windows_amd64.syso b/apps/dcauth/versioninfo_windows_amd64.syso new file mode 100644 index 0000000000000000000000000000000000000000..2278b81d3575413a0e1ee5d6cbfd7fd5f682531f GIT binary patch literal 924 zcmZvbPcH*O7{;G%H)vcOon#LVT$&|~L?Tp_Xd(#GxKXRB32n12A#rf>d3*uirgz^! z{N5S0>(4YZ^Um`=@1J>gdifH_#!FH1R;Y41NE3(prHXG2^NHPt@v4feW@jVN42N0i zIm65f96$aCFNM-Bj8o>QU|c^&g$hPP$2w!())7`+2ioUcXWhZJ@fN;+`24jPUJYTDvGtrtmuMOTG2GITX1gGR}=mt z-84D1T!8~}USg+;sL-K?$NQQmZd|7ZI#A;3_u+KN*LCqVa&PJm?3xUg=^k&w9#O^P zEw~nwfQ(}!PB&oQCwiUA)oXyKRLtrusVtkZn_``WZ}+#3Q}!nLvYX}mvPRD}2Y(N4 z+c8a%K3bTfZF(QV`HMwb0A1uR%T!)Q&4};#pJMAyXY*M;`%2c&SU)>?#q)iY>E?lH z!{6tS_#P_Wq0TUO8{R-pXXD43;66Y3zg5dLvv_+$4f;sGyUJdV_}V;do#3@MGNAYD WTUotQ_-X|A_H8fz_{RkfF~TQ=+H~3g literal 0 HcmV?d00001 diff --git a/apps/frontend/src/pages/admin/AdminLoginPage.tsx b/apps/frontend/src/pages/admin/AdminLoginPage.tsx index c94349d7..967923e3 100644 --- a/apps/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/apps/frontend/src/pages/admin/AdminLoginPage.tsx @@ -25,6 +25,9 @@ type LoginForm = { smsCode: string } +const isMacPlatform = + typeof navigator !== 'undefined' && /Macintosh|Mac OS X|iPhone|iPad/i.test(navigator.userAgent) + const brandFeatures = [ { icon: , @@ -54,6 +57,7 @@ export default function AdminLoginPage() { const [waiting, setWaiting] = useState(false) const [devLaunchUrl, setDevLaunchUrl] = useState('') const timerRef = useRef(null) + const submittingRef = useRef(false) useEffect(() => { return () => { @@ -94,6 +98,8 @@ export default function AdminLoginPage() { } async function submitLogin(values: LoginForm) { + if (submittingRef.current) return + submittingRef.current = true setLoading(true) setWaiting(false) try { @@ -105,7 +111,7 @@ export default function AdminLoginPage() { }) setPhoneMasked(prepared.data.phoneMasked) setWaiting(true) - setDevLaunchUrl(import.meta.env.DEV ? prepared.data.launchUrl : '') + setDevLaunchUrl(import.meta.env.DEV && isMacPlatform ? prepared.data.launchUrl : '') message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认') launchProtocol(prepared.data.launchUrl) @@ -121,6 +127,7 @@ export default function AdminLoginPage() { message.error(error instanceof Error ? error.message : '登录失败') } } finally { + submittingRef.current = false setLoading(false) setWaiting(false) setDevLaunchUrl('') @@ -128,12 +135,14 @@ export default function AdminLoginPage() { } async function submitLegacyLogin() { + if (submittingRef.current) return let values: Pick try { values = await form.validateFields(['username', 'password']) } catch { return } + submittingRef.current = true setLoading(true) try { const response = await loginAdmin({ @@ -146,6 +155,7 @@ export default function AdminLoginPage() { } catch (error) { message.error(error instanceof Error ? error.message : '账号密码登录失败') } finally { + submittingRef.current = false setLoading(false) } }