~sircmpwn/tokidoki

a87520cb0f4556c073a2b02ce7d76f5090f22c55 — Conrad Hoffmann 4 months ago 536f83f
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.
4 files changed, 101 insertions(+), 1 deletions(-)

A auth/htpasswd.go
M auth/url.go
M go.mod
M go.sum
A auth/htpasswd.go => auth/htpasswd.go +91 -0
@@ 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)
}

M auth/url.go => auth/url.go +6 -0
@@ 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:

M go.mod => go.mod +1 -0
@@ 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 (

M go.sum => go.sum +3 -1
@@ 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=