summaryrefslogtreecommitdiffstats
path: root/internal/auth.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/auth.go')
-rw-r--r--internal/auth.go198
1 files changed, 198 insertions, 0 deletions
diff --git a/internal/auth.go b/internal/auth.go
new file mode 100644
index 0000000..5c5991e
--- /dev/null
+++ b/internal/auth.go
@@ -0,0 +1,198 @@
+// 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 NewAuth(path string) *Auth { return &Auth{path: path} }
+
+// IsConfigured reports whether the password file exists and contains at least one user.
+// It does not create the file as a side effect.
+func (a *Auth) IsConfigured() bool {
+ info, err := os.Stat(a.path)
+ return err == nil && info.Size() > 0
+}
+
+// AddUserWithPassword adds a user with an already-known password (for HTTP handlers).
+func (a *Auth) AddUserWithPassword(username, password string) error {
+ users, err := a.read()
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ if users == nil {
+ users = make(map[string]string)
+ }
+ if _, exists := users[username]; exists {
+ return fmt.Errorf("user %q already exists", username)
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ users[username] = string(hash)
+ return a.write(users)
+}
+
+func (a *Auth) AddUser(username string) error {
+ users, err := a.read()
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ if users == nil {
+ users = make(map[string]string)
+ }
+ 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) {
+ var _, staterr = os.Stat(a.path)
+ if os.IsNotExist(staterr) {
+ var cf, cferr = os.Create(a.path)
+ if cferr != nil {
+ return nil, cferr
+ }
+ defer cf.Close()
+ }
+
+ 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
+}