// Package auth manages a htpasswd-compatible password file (bcrypt entries). // The file format is one "username:$2a$..." entry per line. // nginx auth_basic accepts this file directly via auth_basic_user_file. package auth import ( "bufio" "fmt" "os" "strings" "syscall" "golang.org/x/crypto/bcrypt" "golang.org/x/term" ) type Auth struct { path string } func New(path string) *Auth { return &Auth{path: path} } func (a *Auth) AddUser(username string) error { users, err := a.read() if err != nil && !os.IsNotExist(err) { return err } if _, exists := users[username]; exists { return fmt.Errorf("user %q already exists", username) } pw, err := readPassword("Password: ") if err != nil { return err } confirm, err := readPassword("Confirm: ") if err != nil { return err } if pw != confirm { return fmt.Errorf("passwords do not match") } hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { return err } users[username] = string(hash) return a.write(users) } func (a *Auth) ChangePassword(username string) error { users, err := a.read() if err != nil { return err } if _, exists := users[username]; !exists { return fmt.Errorf("user %q not found", username) } pw, err := readPassword("New password: ") if err != nil { return err } confirm, err := readPassword("Confirm: ") if err != nil { return err } if pw != confirm { return fmt.Errorf("passwords do not match") } hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { return err } users[username] = string(hash) return a.write(users) } func (a *Auth) DeleteUser(username string) error { users, err := a.read() if err != nil { return err } if _, exists := users[username]; !exists { return fmt.Errorf("user %q not found", username) } delete(users, username) return a.write(users) } func (a *Auth) ListUsers() ([]string, error) { users, err := a.read() if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } names := make([]string, 0, len(users)) for k := range users { names = append(names, k) } return names, nil } func (a *Auth) Verify(username, password string) (bool, error) { users, err := a.read() if err != nil { return false, err } hash, ok := users[username] if !ok { return false, nil } err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) if err == bcrypt.ErrMismatchedHashAndPassword { return false, nil } return err == nil, err } func (a *Auth) read() (map[string]string, error) { f, err := os.Open(a.path) if err != nil { return nil, err } defer f.Close() users := make(map[string]string) scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } user, hash, ok := strings.Cut(line, ":") if ok { users[user] = hash } } return users, scanner.Err() } func (a *Auth) write(users map[string]string) error { f, err := os.OpenFile(a.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer f.Close() w := bufio.NewWriter(f) for user, hash := range users { fmt.Fprintf(w, "%s:%s\n", user, hash) } return w.Flush() } func readPassword(prompt string) (string, error) { fmt.Print(prompt) b, err := term.ReadPassword(int(syscall.Stdin)) fmt.Println() return string(b), err }