diff --git a/README.md b/README.md index 95edffb..c275f78 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ go build -o tracker . ``` Non-interactive variant: `TRACKER_PASSWORD=... ./tracker -adduser kyle`. +Change an existing user's passphrase the same way: +`./tracker -changepw kyle` (or `TRACKER_PASSWORD=... ./tracker -changepw kyle`). Defaults: listens on `:8420` (all interfaces, so it's reachable over Tailscale), database at `~/.local/share/tracker/tracker.db`. diff --git a/internal/store/auth.go b/internal/store/auth.go index 6187356..50f1e5b 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -46,6 +46,14 @@ func (s *Store) UserByName(username string) (User, error) { return u, err } +func (s *Store) SetPassword(username, passwordHash string) error { + res, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE username = ?`, passwordHash, username) + if err != nil { + return err + } + return checkAffected(res) +} + // --- api keys --- func (s *Store) CreateAPIKey(name, keyHash string) (int64, error) { diff --git a/main.go b/main.go index 32210b4..249ab10 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ func main() { dbPath := flag.String("db", defaultDB, "sqlite database path") addr := flag.String("addr", ":8420", "listen address") addUser := flag.String("adduser", "", "create a login user, then exit") + changepw := flag.String("changepw", "", "change a login user's passphrase, then exit") flag.Parse() if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { @@ -35,6 +36,13 @@ func main() { return } + if *changepw != "" { + if err := runChangePassword(*dbPath, *changepw); err != nil { + log.Fatal(err) + } + return + } + st, err := store.Open(*dbPath) if err != nil { log.Fatal(err) @@ -106,3 +114,61 @@ func runAddUser(dbPath, username string) error { fmt.Printf("user %q created. log in at /login\n", username) return nil } + +func runChangePassword(dbPath, username string) error { + username = strings.TrimSpace(username) + if username == "" { + return errors.New("username cannot be empty") + } + if strings.ContainsAny(username, " \t") { + return errors.New("username cannot contain whitespace") + } + + st, err := store.Open(dbPath) + if err != nil { + return err + } + defer st.Close() + + if _, err := st.UserByName(username); err != nil { + if errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("user %q not found", username) + } + return err + } + + password := os.Getenv("TRACKER_PASSWORD") + if password == "" { + fmt.Print("new passphrase: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return err + } + fmt.Print("confirm: ") + confirmPw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return err + } + password = string(pw) + if string(confirmPw) != password { + return errors.New("passphrases do not match") + } + } + + if len(password) < 8 { + return errors.New("passphrase must be at least 8 characters") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + if err := st.SetPassword(username, string(hash)); err != nil { + return err + } + fmt.Printf("passphrase for %q updated\n", username) + return nil +}