Files
ward/htpasswd.go
2026-03-02 15:19:32 +01:00

112 lines
2.8 KiB
Go

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)")
}
}