Import
This commit is contained in:
111
htpasswd.go
Normal file
111
htpasswd.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user