112 lines
2.8 KiB
Go
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)")
|
|
}
|
|
}
|