package main import ( "bufio" "crypto/sha1" "crypto/subtle" "encoding/base64" "fmt" "os" "strings" "sync" "golang.org/x/crypto/bcrypt" ) // HtpasswdAuth authenticates users against an Apache-compatible htpasswd file. // // Supported hash formats: // - bcrypt ($2y$, $2a$, $2b$) — recommended; generate with: htpasswd -B -c file user // - SHA-1 ({SHA}...) — legacy only; use bcrypt for new entries // // The file is parsed at startup and can be reloaded at runtime by calling Reload() // (wired to SIGHUP in main). Lines beginning with '#' and blank lines are ignored. type HtpasswdAuth struct { path string mu sync.RWMutex entries map[string]string // username → hash } // NewHtpasswdAuth reads path and returns a ready HtpasswdAuth. func NewHtpasswdAuth(path string) (*HtpasswdAuth, error) { h := &HtpasswdAuth{path: path} if err := h.Reload(); err != nil { return nil, err } return h, nil } // Reload re-reads the htpasswd file from disk, atomically swapping the // in-memory table. Safe to call from a signal handler goroutine. func (h *HtpasswdAuth) Reload() error { f, err := os.Open(h.path) if err != nil { return fmt.Errorf("opening htpasswd %s: %w", h.path, err) } defer f.Close() entries := 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 { continue } entries[user] = hash } if err := scanner.Err(); err != nil { return fmt.Errorf("reading htpasswd: %w", err) } h.mu.Lock() h.entries = entries h.mu.Unlock() return nil } // Len returns the number of users currently loaded. func (h *HtpasswdAuth) Len() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.entries) } // Authenticate returns nil if username/password match an entry in the file. func (h *HtpasswdAuth) Authenticate(username, password string) error { h.mu.RLock() hash, ok := h.entries[username] h.mu.RUnlock() if !ok { return fmt.Errorf("invalid credentials") } return verifyHTPasswd(hash, password) } func verifyHTPasswd(hash, password string) error { switch { case strings.HasPrefix(hash, "$2y$"), strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"): if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { return fmt.Errorf("invalid credentials") } return nil case strings.HasPrefix(hash, "{SHA}"): sum := sha1.Sum([]byte(password)) expected := "{SHA}" + base64.StdEncoding.EncodeToString(sum[:]) if subtle.ConstantTimeCompare([]byte(hash), []byte(expected)) == 1 { return nil } return fmt.Errorf("invalid credentials") default: return fmt.Errorf("unsupported htpasswd hash format (use bcrypt: htpasswd -B file user)") } }