From a87520cb0f4556c073a2b02ce7d76f5090f22c55 Mon Sep 17 00:00:00 2001 From: Conrad Hoffmann Date: Mon, 5 Feb 2024 17:23:11 +0100 Subject: [PATCH] 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 ` to create a file. Documentation forthcoming. --- auth/htpasswd.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ auth/url.go | 6 ++++ go.mod | 1 + go.sum | 4 ++- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 auth/htpasswd.go diff --git a/auth/htpasswd.go b/auth/htpasswd.go new file mode 100644 index 0000000..98d6526 --- /dev/null +++ b/auth/htpasswd.go @@ -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 ` + +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) +} diff --git a/auth/url.go b/auth/url.go index 4c2ce3a..e5b2f28 100644 --- a/auth/url.go +++ b/auth/url.go @@ -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: diff --git a/go.mod b/go.mod index 4fc335f..33042be 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-chi/chi/v5 v5.0.10 github.com/msteinert/pam v1.2.0 github.com/rs/zerolog v1.31.0 + golang.org/x/crypto v0.18.0 ) require ( diff --git a/go.sum b/go.sum index eca6eec..c585855 100644 --- a/go.sum +++ b/go.sum @@ -30,12 +30,14 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=