Add htpasswd-style static file auth module

Can be used via `-auth.url=file://`. Only supports bcrypt password
hashes ($2y). Use e.g. `htpasswd -c -BC 14 <filename> <user>` to create
a file. Documentation forthcoming.
This commit is contained in:
Conrad Hoffmann 2024-02-05 17:23:11 +01:00
parent 536f83fa61
commit a87520cb0f
4 changed files with 101 additions and 1 deletions

91
auth/htpasswd.go Normal file
View file

@ -0,0 +1,91 @@
package auth
import (
"bufio"
"fmt"
"net/http"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/rs/zerolog/log"
)
// This provider provides htpasswd style authentication, but _only_ if the
// bcrypt algorithm is used (hash must start with $2y). Use e.g.
// `htpasswd -c -BC 17 <filename> <user>`
type htpasswdProvider struct {
users map[string]string
}
func NewHtpasswd(location string) (AuthProvider, error) {
file, err := os.Open(location)
if err != nil {
return nil, fmt.Errorf("failed to open %s: %s", location, err.Error())
}
defer file.Close()
var result htpasswdProvider
result.users = make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), ":")
if len(fields) != 2 {
return nil, fmt.Errorf("failed to parse %s: %s: expected 2 fields, found %d", location, scanner.Text(), len(fields))
}
if !strings.HasPrefix(fields[1], "$2y$") {
return nil, fmt.Errorf("failed to parse %s: %s is not a bcrypt hash ($2y)", location, scanner.Text())
}
result.users[fields[0]] = fields[1]
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to parse %s: %s", location, err.Error())
}
return &result, nil
}
func (prov *htpasswdProvider) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prov.htpasswdAuth(next, w, r)
})
}
}
func (prov *htpasswdProvider) htpasswdAuth(next http.Handler, w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Add("WWW-Authenticate", `Basic realm="Please provide your system credentials", charset="UTF-8"`)
http.Error(w, "HTTP Basic auth is required", http.StatusUnauthorized)
return
}
hash, ok := prov.users[user]
if !ok {
log.Debug().Str("user", user).Msg("auth error")
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)); err != nil {
if err != bcrypt.ErrMismatchedHashAndPassword {
log.Warn().Err(err).Str("user", user).Msg("password check failed")
} else {
log.Debug().Str("user", user).Msg("auth error")
}
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
authCtx := AuthContext{
AuthMethod: "htpasswd",
UserName: user,
}
ctx := NewContext(r.Context(), &authCtx)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}

View file

@ -18,6 +18,12 @@ func NewFromURL(authURL string) (AuthProvider, error) {
return NewIMAP(u.Host, true), nil
case "pam":
return NewPAM()
case "file":
path := u.Path
if u.Host != "" {
path = u.Host + path
}
return NewHtpasswd(path)
case "null":
return NewNull()
default: