diff --git a/go.mod b/go.mod index 351eb02..bee369a 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,7 @@ go 1.18 require golang.org/x/mod v0.5.1 -require golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 // indirect +require ( + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 // indirect +) diff --git a/go.sum b/go.sum index 7214757..f47271c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e h1:aZzprAO9/8oim3qStq3wc1Xuxx4QmAGriC4VU4ojemQ= diff --git a/handler.go b/handler.go index b0113d5..0f0bdaa 100644 --- a/handler.go +++ b/handler.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "golang.org/x/crypto/bcrypt" "golang.org/x/mod/semver" ) @@ -35,6 +36,7 @@ type handler struct { socketPath string root string hostname string + auth map[string]string } func (h handler) run() error { @@ -335,12 +337,23 @@ func (h handler) upload(modpath, modversion string, w http.ResponseWriter, r *ht return } - _, _, ok := r.BasicAuth() + user, pass, ok := r.BasicAuth() if !ok { writeError(w, apiError(http.StatusUnauthorized)) return } + hash := h.auth[user] + if hash == "" { + writeError(w, apiError(http.StatusUnauthorized)) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)); err != nil { + writeError(w, fmt.Errorf("%v: %w", err, apiError(http.StatusUnauthorized))) + return + } + dest := h.zipPath(modpath, modversion) if _, err := os.Stat(dest); !errors.Is(err, fs.ErrNotExist) { writeError(w, apiError(http.StatusConflict)) diff --git a/hash.go b/hash.go new file mode 100644 index 0000000..a491469 --- /dev/null +++ b/hash.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// pwhashcmd is just a thing for generating bcrypt hashes for passwords. This +// is like using htpasswd from the apache-utils package but honestly adding +// that whole package to a system to compute a single bcrypt hash is ridiculous +func pwhashcmd(args []string) { + cost := bcrypt.DefaultCost + + flags := flag.NewFlagSet("pwhash", flag.ExitOnError) + flags.IntVar(&cost, "cost", cost, "bcrypt cost difficulty") + flags.Parse(args) + + for _, pw := range flags.Args() { + hash, err := bcrypt.GenerateFromPassword([]byte(pw), cost) + if err != nil { + bail(1, "hash failed: %v", err) + } + fmt.Println(string(hash)) + } +} diff --git a/main.go b/main.go index ef86036..641422f 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,8 @@ func main() { serve(root.Args()[1:]) case "zip": zipcmd(root.Args()[1:]) + case "pwhash": + pwhashcmd(root.Args()[1:]) default: bail(0, usage) } diff --git a/serve.go b/serve.go index 3c2cfdd..07dbf68 100644 --- a/serve.go +++ b/serve.go @@ -2,25 +2,30 @@ package main import ( "flag" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" ) func serve(args []string) { // listen on this unix domain socket - socketPath := "" + var socketPath string // serve modules out of this root directory rootDir := "/srv/mir" // serve module traffic on this hostname - hostname := "" - - httpAddr := "" + var hostname string + var httpAddr string + auth := make(authUsers) serveFlags := flag.NewFlagSet("serve", flag.ExitOnError) serveFlags.StringVar(&socketPath, "unix", socketPath, "path for a unix domain socket to listen on") serveFlags.StringVar(&httpAddr, "http", httpAddr, "http address to listen on") serveFlags.StringVar(&rootDir, "root", rootDir, "root directory for module storage") serveFlags.StringVar(&hostname, "hostname", hostname, "domain name on which mir serves modules") + serveFlags.Var(&auth, "auth-users", "comma-separated list of usernames and bcrypt password hashes") serveFlags.Parse(args) h := handler{ @@ -28,8 +33,48 @@ func serve(args []string) { httpAddr: httpAddr, root: rootDir, hostname: hostname, + auth: auth, } if err := h.run(); err != nil { bail(1, err.Error()) } } + +type authUsers map[string]string + +func (a authUsers) String() string { + if len(a) == 0 { + return "" + } + + var b strings.Builder + for k, v := range a { + fmt.Fprintf(&b, "%s:%s,", k, v) + } + s := b.String() + return s[:len(s)-1] +} + +func (a authUsers) Set(v string) error { + pairs := strings.Split(v, ",") + if len(pairs) == 0 { + return fmt.Errorf("auth users string cannot be empty") + } + + // Each pair is a colon-delimited username-hash pair + // username:$2a$10$8KTGhnP8Myh62wjdOqCsiO.zE.i9FQ1Y0PD9lfpvgR7GLtIbbcteG + for _, pair := range pairs { + parts := strings.Split(pair, ":") + if len(parts) != 2 { + return fmt.Errorf("invalid user/hash pair: %s", pair) + } + + // check the cost to ensure it's a valid bcrypt hash + if _, err := bcrypt.Cost([]byte(parts[1])); err != nil { + return fmt.Errorf("invalid hash %q: %v", parts[1], err) + } + a[parts[0]] = parts[1] + } + + return nil +} diff --git a/usage b/usage index ad8a82e..c406ca3 100644 --- a/usage +++ b/usage @@ -6,3 +6,4 @@ Usage: Commands: serve: live module server zip: creates module zip files + pwhash: bcrypt hash a password