From 392f1d70eb29ebfc2d8b155a3707c81f2fcee529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Gul=C3=A1csi?= Date: Sun, 30 Aug 2015 20:07:43 +0200 Subject: [PATCH] Add htpasswd support for basic auth If the password arg starts with htpasswd=, then the rest is treated as the file name of the htpasswd file, and used for md5 and sha1 hashes. --- config/setup/basicauth.go | 20 ++++++- config/setup/basicauth_test.go | 64 +++++++++++++++----- middleware/basicauth/basicauth.go | 81 +++++++++++++++++++++++++- middleware/basicauth/basicauth_test.go | 36 +++++++++++- 4 files changed, 178 insertions(+), 23 deletions(-) diff --git a/config/setup/basicauth.go b/config/setup/basicauth.go index 6d1ece108..a59e13497 100644 --- a/config/setup/basicauth.go +++ b/config/setup/basicauth.go @@ -1,6 +1,8 @@ package setup import ( + "strings" + "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/basicauth" ) @@ -23,6 +25,7 @@ func BasicAuth(c *Controller) (middleware.Middleware, error) { func basicAuthParse(c *Controller) ([]basicauth.Rule, error) { var rules []basicauth.Rule + var err error for c.Next() { var rule basicauth.Rule @@ -31,7 +34,10 @@ func basicAuthParse(c *Controller) ([]basicauth.Rule, error) { switch len(args) { case 2: rule.Username = args[0] - rule.Password = args[1] + if rule.Password, err = passwordMatcher(rule.Username, args[1]); err != nil { + return rules, c.Errf("Get password matcher from %s: %v", c.Val(), err) + } + for c.NextBlock() { rule.Resources = append(rule.Resources, c.Val()) if c.NextArg() { @@ -41,7 +47,9 @@ func basicAuthParse(c *Controller) ([]basicauth.Rule, error) { case 3: rule.Resources = append(rule.Resources, args[0]) rule.Username = args[1] - rule.Password = args[2] + if rule.Password, err = passwordMatcher(rule.Username, args[2]); err != nil { + return rules, c.Errf("Get password matcher from %s: %v", c.Val(), err) + } default: return rules, c.ArgErr() } @@ -51,3 +59,11 @@ func basicAuthParse(c *Controller) ([]basicauth.Rule, error) { return rules, nil } + +func passwordMatcher(username, passw string) (basicauth.PasswordMatcher, error) { + if !strings.HasPrefix(passw, "htpasswd=") { + return basicauth.PlainMatcher(passw), nil + } + + return basicauth.GetHtpasswdMatcher(passw[9:], username) +} diff --git a/config/setup/basicauth_test.go b/config/setup/basicauth_test.go index f5cb80672..7b0ba3d96 100644 --- a/config/setup/basicauth_test.go +++ b/config/setup/basicauth_test.go @@ -2,6 +2,9 @@ package setup import ( "fmt" + "io/ioutil" + "os" + "strings" "testing" "github.com/mholt/caddy/middleware/basicauth" @@ -30,35 +33,57 @@ func TestBasicAuth(t *testing.T) { } func TestBasicAuthParse(t *testing.T) { + htpasswdPasswd := "IedFOuGmTpT8" + htpasswdFile := `sha1:{SHA}dcAUljwz99qFjYR0YLTXx0RqLww= +md5:$apr1$l42y8rex$pOA2VJ0x/0TwaFeAF9nX61` + + var skipHtpassword bool + htfh, err := ioutil.TempFile("", "basicauth-") + if err != nil { + t.Logf("Error creating temp file (%v), will skip htpassword test") + skipHtpassword = true + } else { + if _, err = htfh.Write([]byte(htpasswdFile)); err != nil { + t.Fatalf("write htpasswd file %q: %v", htfh.Name(), err) + } + htfh.Close() + defer os.Remove(htfh.Name()) + } + tests := []struct { input string shouldErr bool + password string expected []basicauth.Rule }{ - {`basicauth user pwd`, false, []basicauth.Rule{ - {Username: "user", Password: "pwd"}, + {`basicauth user pwd`, false, "pwd", []basicauth.Rule{ + {Username: "user"}, }}, {`basicauth user pwd { - }`, false, []basicauth.Rule{ - {Username: "user", Password: "pwd"}, + }`, false, "pwd", []basicauth.Rule{ + {Username: "user"}, }}, {`basicauth user pwd { /resource1 /resource2 - }`, false, []basicauth.Rule{ - {Username: "user", Password: "pwd", Resources: []string{"/resource1", "/resource2"}}, + }`, false, "pwd", []basicauth.Rule{ + {Username: "user", Resources: []string{"/resource1", "/resource2"}}, }}, - {`basicauth /resource user pwd`, false, []basicauth.Rule{ - {Username: "user", Password: "pwd", Resources: []string{"/resource"}}, + {`basicauth /resource user pwd`, false, "pwd", []basicauth.Rule{ + {Username: "user", Resources: []string{"/resource"}}, }}, {`basicauth /res1 user1 pwd1 - basicauth /res2 user2 pwd2`, false, []basicauth.Rule{ - {Username: "user1", Password: "pwd1", Resources: []string{"/res1"}}, - {Username: "user2", Password: "pwd2", Resources: []string{"/res2"}}, + basicauth /res2 user2 pwd2`, false, "pwd", []basicauth.Rule{ + {Username: "user1", Resources: []string{"/res1"}}, + {Username: "user2", Resources: []string{"/res2"}}, + }}, + {`basicauth user`, true, "", []basicauth.Rule{}}, + {`basicauth`, true, "", []basicauth.Rule{}}, + {`basicauth /resource user pwd asdf`, true, "", []basicauth.Rule{}}, + + {`basicauth sha1 htpasswd=` + htfh.Name(), false, htpasswdPasswd, []basicauth.Rule{ + {Username: "sha1"}, }}, - {`basicauth user`, true, []basicauth.Rule{}}, - {`basicauth`, true, []basicauth.Rule{}}, - {`basicauth /resource user pwd asdf`, true, []basicauth.Rule{}}, } for i, test := range tests { @@ -84,9 +109,16 @@ func TestBasicAuthParse(t *testing.T) { i, j, expectedRule.Username, actualRule.Username) } - if actualRule.Password != expectedRule.Password { + if strings.Contains(test.input, "htpasswd=") && skipHtpassword { + continue + } + pwd := test.password + if len(actual) > 1 { + pwd = fmt.Sprintf("%s%d", pwd, j+1) + } + if !actualRule.Password(pwd) || actualRule.Password(test.password+"!") { t.Errorf("Test %d, rule %d: Expected password '%s', got '%s'", - i, j, expectedRule.Password, actualRule.Password) + i, j, test.password, actualRule.Password) } expectedRes := fmt.Sprintf("%v", expectedRule.Resources) diff --git a/middleware/basicauth/basicauth.go b/middleware/basicauth/basicauth.go index 221446a2f..02ad36c55 100644 --- a/middleware/basicauth/basicauth.go +++ b/middleware/basicauth/basicauth.go @@ -2,9 +2,17 @@ package basicauth import ( + "bufio" "crypto/subtle" + "fmt" + "io" "net/http" + "os" + "path/filepath" + "strings" + "sync" + "github.com/jimstudt/http-authentication/basic" "github.com/mholt/caddy/middleware" ) @@ -37,7 +45,8 @@ func (a BasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error // Check credentials if !ok || username != rule.Username || - subtle.ConstantTimeCompare([]byte(password), []byte(rule.Password)) != 1 { + !rule.Password(password) { + //subtle.ConstantTimeCompare([]byte(password), []byte(rule.Password)) != 1 { continue } @@ -64,6 +73,74 @@ func (a BasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error // file or directory paths. type Rule struct { Username string - Password string + Password func(string) bool Resources []string } + +type PasswordMatcher func(pw string) bool + +var ( + htpasswords map[string]map[string]PasswordMatcher + htpasswordsMu sync.Mutex +) + +func GetHtpasswdMatcher(filename, username string) (PasswordMatcher, error) { + filename, err := filepath.Abs(filename) + if err != nil { + return nil, err + } + htpasswordsMu.Lock() + if htpasswords == nil { + htpasswords = make(map[string]map[string]PasswordMatcher) + } + pm := htpasswords[filename] + if pm == nil { + fh, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("open %q: %v", filename, err) + } + defer fh.Close() + pm = make(map[string]PasswordMatcher) + if err = parseHtpasswd(pm, fh); err != nil { + return nil, fmt.Errorf("parsing htpasswd %q: %v", fh.Name(), err) + } + htpasswords[filename] = pm + } + htpasswordsMu.Unlock() + if pm[username] == nil { + return nil, fmt.Errorf("username %q not found in %q", username, filename) + } + return pm[username], nil +} + +func parseHtpasswd(pm map[string]PasswordMatcher, r io.Reader) error { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.IndexByte(line, '#') == 0 { + continue + } + i := strings.IndexByte(line, ':') + if i <= 0 { + return fmt.Errorf("malformed line, no color: %q", line) + } + user, encoded := line[:i], line[i+1:] + for _, p := range basic.DefaultSystems { + matcher, err := p(encoded) + if err != nil { + return err + } + if matcher != nil { + pm[user] = matcher.MatchesPassword + break + } + } + } + return scanner.Err() +} + +func PlainMatcher(passw string) PasswordMatcher { + return func(pw string) bool { + return subtle.ConstantTimeCompare([]byte(pw), []byte(passw)) == 1 + } +} diff --git a/middleware/basicauth/basicauth_test.go b/middleware/basicauth/basicauth_test.go index 51f944bd6..393f2e4ed 100644 --- a/middleware/basicauth/basicauth_test.go +++ b/middleware/basicauth/basicauth_test.go @@ -3,8 +3,10 @@ package basicauth import ( "encoding/base64" "fmt" + "io/ioutil" "net/http" "net/http/httptest" + "os" "testing" "github.com/mholt/caddy/middleware" @@ -15,7 +17,7 @@ func TestBasicAuth(t *testing.T) { rw := BasicAuth{ Next: middleware.HandlerFunc(contentHandler), Rules: []Rule{ - {Username: "test", Password: "ttest", Resources: []string{"/testing"}}, + {Username: "test", Password: PlainMatcher("ttest"), Resources: []string{"/testing"}}, }, } @@ -66,8 +68,8 @@ func TestMultipleOverlappingRules(t *testing.T) { rw := BasicAuth{ Next: middleware.HandlerFunc(contentHandler), Rules: []Rule{ - {Username: "t", Password: "p1", Resources: []string{"/t"}}, - {Username: "t1", Password: "p2", Resources: []string{"/t/t"}}, + {Username: "t", Password: PlainMatcher("p1"), Resources: []string{"/t"}}, + {Username: "t1", Password: PlainMatcher("p2"), Resources: []string{"/t/t"}}, }, } @@ -111,3 +113,31 @@ func contentHandler(w http.ResponseWriter, r *http.Request) (int, error) { fmt.Fprintf(w, r.URL.String()) return http.StatusOK, nil } + +func TestHtpasswd(t *testing.T) { + htpasswdPasswd := "IedFOuGmTpT8" + htpasswdFile := `sha1:{SHA}dcAUljwz99qFjYR0YLTXx0RqLww= +md5:$apr1$l42y8rex$pOA2VJ0x/0TwaFeAF9nX61` + + htfh, err := ioutil.TempFile("", "basicauth-") + if err != nil { + t.Skipf("Error creating temp file (%v), will skip htpassword test") + return + } + if _, err = htfh.Write([]byte(htpasswdFile)); err != nil { + t.Fatalf("write htpasswd file %q: %v", htfh.Name(), err) + } + htfh.Close() + defer os.Remove(htfh.Name()) + + for i, username := range []string{"sha1", "md5"} { + rule := Rule{Username: username, Resources: []string{"/testing"}} + if rule.Password, err = GetHtpasswdMatcher(htfh.Name(), rule.Username); err != nil { + t.Fatalf("GetHtpasswdMatcher(%q, %q): %v", htfh.Name(), rule.Username, err) + } + t.Logf("%d. username=%q password=%v", i, rule.Username, rule.Password) + if !rule.Password(htpasswdPasswd) || rule.Password(htpasswdPasswd+"!") { + t.Errorf("%d (%s) password does not match.", i, rule.Username) + } + } +}