feat(cli): list/get 子命令 — 脚本集成 TOTP 获取
- winauth list: tabwriter 列出所有条目(名称/vendor/icon/hotkey) - winauth get NAME: 输出当前 OTP,-n 不换行,--advance 允许 HOTP - 加密配置自动 tty 隐藏密码提示(golang.org/x/term) - --config 改为 PersistentFlag,子命令可继承 - HOTP get 默认拒绝,需 --advance 显式确认并保存配置 - CLI 不运行 GUI、不持有单实例锁
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||
)
|
||||
|
||||
func newGetCmd() *cobra.Command {
|
||||
var noNewline, advance bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "get NAME",
|
||||
Short: "Print the current OTP for an entry",
|
||||
Long: `Print the current one-time password for the entry named NAME.
|
||||
The name must match exactly (case-sensitive).
|
||||
|
||||
For HOTP (counter-based) entries, this command refuses by default
|
||||
because generating a code advances the counter. Use --advance to
|
||||
confirm that you want to consume a counter value; the config file
|
||||
will be updated automatically.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runGet(cmd, args[0], noNewline, advance)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&noNewline, "no-newline", "n", false, "Do not append a newline after the code")
|
||||
cmd.Flags().BoolVar(&advance, "advance", false, "Advance HOTP counter (required for counter-based entries)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runGet(cmd *cobra.Command, name string, noNewline, advance bool) error {
|
||||
path := resolveConfigPath(cmd)
|
||||
|
||||
lang := preferredLanguage(path)
|
||||
_ = i18n.Init(lang)
|
||||
|
||||
cfg, passphrase, err := cliLoadConfig(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrPasswordWrong) {
|
||||
return fmt.Errorf("wrong password")
|
||||
}
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// Find entry by exact name.
|
||||
var target *config.Entry
|
||||
var matches []string
|
||||
for i := range cfg.Entries {
|
||||
if cfg.Entries[i].Name == name {
|
||||
target = &cfg.Entries[i]
|
||||
break
|
||||
}
|
||||
matches = append(matches, cfg.Entries[i].Name)
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("entry %q not found", name)
|
||||
}
|
||||
|
||||
a, err := buildAuth(*target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build authenticator: %w", err)
|
||||
}
|
||||
|
||||
// HOTP safety gate.
|
||||
if a.Name() == "hotp" && !advance {
|
||||
return fmt.Errorf("entry %q is counter-based (HOTP); re-run with --advance to consume a counter value", name)
|
||||
}
|
||||
|
||||
code, err := a.CurrentCode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute OTP: %w", err)
|
||||
}
|
||||
|
||||
if noNewline {
|
||||
fmt.Print(code)
|
||||
} else {
|
||||
fmt.Println(code)
|
||||
}
|
||||
|
||||
// Persist the updated counter for HOTP entries.
|
||||
if a.Name() == "hotp" {
|
||||
target.SecretRaw = a.SecretData()
|
||||
if err := config.SaveYAML(cfg, path, passphrase); err != nil {
|
||||
return fmt.Errorf("save config after HOTP advance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuth mirrors the switch in internal/ui/convert.go. Duplicated here
|
||||
// so the CLI binary does not depend on the UI package.
|
||||
func buildAuth(e config.Entry) (authenticator.Authenticator, error) {
|
||||
var a authenticator.Authenticator
|
||||
switch e.Vendor {
|
||||
case "google", "":
|
||||
a = authenticator.NewGoogleAuthenticator()
|
||||
case "microsoft":
|
||||
a = authenticator.NewMicrosoftAuthenticator()
|
||||
case "okta":
|
||||
a = authenticator.NewOktaVerifyAuthenticator()
|
||||
case "hotp":
|
||||
a = authenticator.NewHOTPAuthenticator()
|
||||
case "battlenet":
|
||||
a = authenticator.NewBattleNetAuthenticator()
|
||||
case "steam":
|
||||
a = authenticator.NewSteamAuthenticator()
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown vendor %q", e.Vendor)
|
||||
}
|
||||
if err := a.SetSecretData(e.SecretRaw); err != nil {
|
||||
return nil, fmt.Errorf("decode entry %q: %w", e.Name, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||
)
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all authenticator entries",
|
||||
Long: "Prints a table of all entries in the config file. For encrypted configs, a password prompt will appear.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runList,
|
||||
}
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, args []string) error {
|
||||
const fn = "cmd.winauth.runList"
|
||||
path := resolveConfigPath(cmd)
|
||||
|
||||
lang := preferredLanguage(path)
|
||||
_ = i18n.Init(lang) // best-effort; list output is English anyway
|
||||
|
||||
cfg, _, err := cliLoadConfig(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrPasswordWrong) {
|
||||
return fmt.Errorf("wrong password")
|
||||
}
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
if len(cfg.Entries) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No entries found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tVENDOR\tICON\tHOTKEY")
|
||||
for _, e := range cfg.Entries {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, e.Vendor, e.IconName, e.Hotkey)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||
)
|
||||
|
||||
// resolveConfigPath returns the config file path from the --config
|
||||
// flag or the default location.
|
||||
func resolveConfigPath(cmd *cobra.Command) string {
|
||||
if p, _ := cmd.Root().PersistentFlags().GetString("config"); p != "" {
|
||||
return p
|
||||
}
|
||||
return config.DefaultPath()
|
||||
}
|
||||
|
||||
// cliLoadConfig loads the config at path, prompting for a password if
|
||||
// the file is encrypted. Returns the config and the passphrase (empty
|
||||
// for plaintext configs). Up to 3 password attempts are allowed.
|
||||
func cliLoadConfig(path string) (*config.Config, []byte, error) {
|
||||
cfg, err := config.LoadYAML(path, nil)
|
||||
if err == nil {
|
||||
return cfg, nil, nil
|
||||
}
|
||||
if !errors.Is(err, config.ErrPasswordRequired) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
pw, err := readPassword("Password: ")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
cfg, err = config.LoadYAML(path, pw)
|
||||
if err == nil {
|
||||
return cfg, pw, nil
|
||||
}
|
||||
if errors.Is(err, config.ErrPasswordWrong) {
|
||||
fmt.Fprintln(os.Stderr, "Wrong password.")
|
||||
continue
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, nil, fmt.Errorf("too many failed password attempts")
|
||||
}
|
||||
+4
-1
@@ -91,9 +91,12 @@ func main() {
|
||||
"Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)")
|
||||
root.Flags().StringVar(&logFile, "log-file", "",
|
||||
"If set, also write logs to this file in addition to the console")
|
||||
root.Flags().StringVar(&configPath, "config", "",
|
||||
root.PersistentFlags().StringVar(&configPath, "config", "",
|
||||
"Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)")
|
||||
|
||||
root.AddCommand(newListCmd())
|
||||
root.AddCommand(newGetCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// readPassword prompts the user for a password on the given file
|
||||
// descriptor. If the fd is a terminal the input is hidden; otherwise
|
||||
// (piped stdin) it falls back to a plain read.
|
||||
func readPassword(prompt string) ([]byte, error) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
pw, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return pw, err
|
||||
}
|
||||
// Non-tty (piped): read a line without echo hiding.
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("no input on stdin")
|
||||
}
|
||||
return []byte(scanner.Text()), nil
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
module git.wxccs.org/iceking2nd/winauth-go
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
gioui.org v0.7.1
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/makiuchi-d/gozxing v0.1.1
|
||||
github.com/nicksnyder/go-i18n/v2 v2.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.19.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -18,11 +22,8 @@ require (
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
github.com/go-text/typesetting v0.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/makiuchi-d/gozxing v0.1.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
)
|
||||
|
||||
@@ -44,8 +44,10 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
|
||||
Reference in New Issue
Block a user