git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #687 from oauth2-proxy/htpasswd-validator

Refactor HTPasswd Validator

commite02f99eb581a77bd4b674ebdcb07c8264904ae24 authorJoel Speed <Joel.speed@hotmail.co.uk> dateSat 18 Jul 2020, 17:29 (6 years ago) parent895403ce73db7d
12 files changed+261−130
CHANGELOG.md+1 htpasswd.go−72 htpasswd_test.go−38 main.go−9 oauthproxy.go+21−11 pkg/authentication/basic/basic_suite_test.go+16 pkg/authentication/basic/htpasswd.go+96 pkg/authentication/basic/htpasswd_test.go+96 pkg/authentication/basic/test/htpasswd-bcrypt.txt+8 pkg/authentication/basic/test/htpasswd-mixed.txt+8 pkg/authentication/basic/test/htpasswd-sha1.txt+8 pkg/authentication/basic/validator.go+7
CHANGELOG.md+1
@@ -11,6 +11,7 @@
1111
1212## Changes since v6.0.0
1313
14+- [#687](https://github.com/oauth2-proxy/oauth2-proxy/pull/687) Refactor HTPasswd Validator (@JoelSpeed)
1415- [#624](https://github.com/oauth2-proxy/oauth2-proxy/pull/624) Allow stripping authentication headers from whitelisted requests with `--skip-auth-strip-headers` (@NickMeves)
1516- [#673](https://github.com/oauth2-proxy/oauth2-proxy/pull/673) Add --session-cookie-minimal option to create session cookies with no tokens (@NickMeves)
1617- [#632](https://github.com/oauth2-proxy/oauth2-proxy/pull/632) Reduce session size by encoding with MessagePack and using LZ4 compression (@NickMeves)
htpasswd.godeleted−72
@@ -1,72 +0,0 @@
1−package main
2−
3−import (
4− "crypto/sha1"
5− "encoding/base64"
6− "encoding/csv"
7− "io"
8− "os"
9−
10− "github.com/oauth2-proxy/oauth2-proxy/pkg/logger"
11− "golang.org/x/crypto/bcrypt"
12−)
13−
14−// Lookup passwords in a htpasswd file
15−// Passwords must be generated with -B for bcrypt or -s for SHA1.
16−
17−// HtpasswdFile represents the structure of an htpasswd file
18−type HtpasswdFile struct {
19− Users map[string]string
20−}
21−
22−// NewHtpasswdFromFile constructs an HtpasswdFile from the file at the path given
23−func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) {
24− r, err := os.Open(path)
25− if err != nil {
26− return nil, err
27− }
28− defer r.Close()
29− return NewHtpasswd(r)
30−}
31−
32−// NewHtpasswd consctructs an HtpasswdFile from an io.Reader (opened file)
33−func NewHtpasswd(file io.Reader) (*HtpasswdFile, error) {
34− csvReader := csv.NewReader(file)
35− csvReader.Comma = ':'
36− csvReader.Comment = '#'
37− csvReader.TrimLeadingSpace = true
38−
39− records, err := csvReader.ReadAll()
40− if err != nil {
41− return nil, err
42− }
43− h := &HtpasswdFile{Users: make(map[string]string)}
44− for _, record := range records {
45− h.Users[record[0]] = record[1]
46− }
47− return h, nil
48−}
49−
50−// Validate checks a users password against the HtpasswdFile entries
51−func (h *HtpasswdFile) Validate(user string, password string) bool {
52− realPassword, exists := h.Users[user]
53− if !exists {
54− return false
55− }
56−
57− shaPrefix := realPassword[:5]
58− if shaPrefix == "{SHA}" {
59− shaValue := realPassword[5:]
60− d := sha1.New()
61− d.Write([]byte(password))
62− return shaValue == base64.StdEncoding.EncodeToString(d.Sum(nil))
63− }
64−
65− bcryptPrefix := realPassword[:4]
66− if bcryptPrefix == "$2a$" || bcryptPrefix == "$2b$" || bcryptPrefix == "$2x$" || bcryptPrefix == "$2y$" {
67− return bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password)) == nil
68− }
69−
70− logger.Printf("Invalid htpasswd entry for %s. Must be a SHA or bcrypt entry.", user)
71− return false
72−}
htpasswd_test.godeleted−38
@@ -1,38 +0,0 @@
1−package main
2−
3−import (
4− "bytes"
5− "fmt"
6− "testing"
7−
8− "github.com/stretchr/testify/assert"
9− "golang.org/x/crypto/bcrypt"
10−)
11−
12−func TestSHA(t *testing.T) {
13− file := bytes.NewBuffer([]byte("testuser:{SHA}PaVBVZkYqAjCQCu6UBL2xgsnZhw=\n"))
14− h, err := NewHtpasswd(file)
15− assert.Equal(t, err, nil)
16−
17− valid := h.Validate("testuser", "asdf")
18− assert.Equal(t, valid, true)
19−}
20−
21−func TestBcrypt(t *testing.T) {
22− hash1, err := bcrypt.GenerateFromPassword([]byte("password"), 1)
23− assert.Equal(t, err, nil)
24− hash2, err := bcrypt.GenerateFromPassword([]byte("top-secret"), 2)
25− assert.Equal(t, err, nil)
26−
27− contents := fmt.Sprintf("testuser1:%s\ntestuser2:%s\n", hash1, hash2)
28− file := bytes.NewBuffer([]byte(contents))
29−
30− h, err := NewHtpasswd(file)
31− assert.Equal(t, err, nil)
32−
33− valid := h.Validate("testuser1", "password")
34− assert.Equal(t, valid, true)
35−
36− valid = h.Validate("testuser2", "top-secret")
37− assert.Equal(t, valid, true)
38−}
main.go−9
@@ -66,15 +66,6 @@ func main() {
6666 }
6767 }
6868
69− if opts.HtpasswdFile != "" {
70− logger.Printf("using htpasswd file %s", opts.HtpasswdFile)
71− oauthproxy.HtpasswdFile, err = NewHtpasswdFromFile(opts.HtpasswdFile)
72− oauthproxy.DisplayHtpasswdForm = opts.DisplayHtpasswdForm
73− if err != nil {
74− logger.Fatalf("FATAL: unable to open %s %s", opts.HtpasswdFile, err)
75− }
76− }
77−
7869 rand.Seed(time.Now().UnixNano())
7970
8071 chain := alice.New()
oauthproxy.go+21−11
@@ -22,6 +22,7 @@ import (
2222 ipapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/ip"
2323 "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options"
2424 sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
25+ "github.com/oauth2-proxy/oauth2-proxy/pkg/authentication/basic"
2526 "github.com/oauth2-proxy/oauth2-proxy/pkg/cookies"
2627 "github.com/oauth2-proxy/oauth2-proxy/pkg/encryption"
2728 "github.com/oauth2-proxy/oauth2-proxy/pkg/ip"
@@ -96,8 +97,8 @@ type OAuthProxy struct {
9697 sessionStore sessionsapi.SessionStore
9798 ProxyPrefix string
9899 SignInMessage string
99− HtpasswdFile *HtpasswdFile
100− DisplayHtpasswdForm bool
100+ basicAuthValidator basic.Validator
101+ displayHtpasswdForm bool
101102 serveMux http.Handler
102103 SetXAuthRequest bool
103104 PassBasicAuth bool
@@ -314,6 +315,16 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
314315 }
315316 }
316317
318+ var basicAuthValidator basic.Validator
319+ if opts.HtpasswdFile != "" {
320+ logger.Printf("using htpasswd file: %s", opts.HtpasswdFile)
321+ var err error
322+ basicAuthValidator, err = basic.NewHTPasswdValidator(opts.HtpasswdFile)
323+ if err != nil {
324+ return nil, fmt.Errorf("could not load htpasswdfile: %v", err)
325+ }
326+ }
327+
317328 return &OAuthProxy{
318329 CookieName: opts.Cookie.Name,
319330 CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
@@ -364,6 +375,9 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
364375 trustedIPs: trustedIPs,
365376 Banner: opts.Banner,
366377 Footer: opts.Footer,
378+
379+ basicAuthValidator: basicAuthValidator,
380+ displayHtpasswdForm: basicAuthValidator != nil,
367381 }, nil
368382}
369383
@@ -386,10 +400,6 @@ func (p *OAuthProxy) GetRedirectURI(host string) string {
386400 return u.String()
387401}
388402
389−func (p *OAuthProxy) displayCustomLoginForm() bool {
390− return p.HtpasswdFile != nil && p.DisplayHtpasswdForm
391−}
392−
393403func (p *OAuthProxy) redeemCode(ctx context.Context, host, code string) (s *sessionsapi.SessionState, err error) {
394404 if code == "" {
395405 return nil, errors.New("missing code")
@@ -526,7 +536,7 @@ func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code
526536 }{
527537 ProviderName: p.provider.Data().ProviderName,
528538 SignInMessage: template.HTML(p.SignInMessage),
529− CustomLogin: p.displayCustomLoginForm(),
539+ CustomLogin: p.displayHtpasswdForm,
530540 Redirect: redirectURL,
531541 Version: VERSION,
532542 ProxyPrefix: p.ProxyPrefix,
@@ -540,7 +550,7 @@ func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code
540550
541551// ManualSignIn handles basic auth logins to the proxy
542552func (p *OAuthProxy) ManualSignIn(rw http.ResponseWriter, req *http.Request) (string, bool) {
543− if req.Method != "POST" || p.HtpasswdFile == nil {
553+ if req.Method != "POST" || p.basicAuthValidator == nil {
544554 return "", false
545555 }
546556 user := req.FormValue("username")
@@ -549,7 +559,7 @@ func (p *OAuthProxy) ManualSignIn(rw http.ResponseWriter, req *http.Request) (st
549559 return "", false
550560 }
551561 // check auth
552− if p.HtpasswdFile.Validate(user, passwd) {
562+ if p.basicAuthValidator.Validate(user, passwd) {
553563 logger.PrintAuthf(user, req, logger.AuthSuccess, "Authenticated via HtpasswdFile")
554564 return user, true
555565 }
@@ -1159,7 +1169,7 @@ func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
11591169// CheckBasicAuth checks the requests Authorization header for basic auth
11601170// credentials and authenticates these against the proxies HtpasswdFile
11611171func (p *OAuthProxy) CheckBasicAuth(req *http.Request) (*sessionsapi.SessionState, error) {
1162− if p.HtpasswdFile == nil {
1172+ if p.basicAuthValidator == nil {
11631173 return nil, nil
11641174 }
11651175 auth := req.Header.Get("Authorization")
@@ -1178,7 +1188,7 @@ func (p *OAuthProxy) CheckBasicAuth(req *http.Request) (*sessionsapi.SessionStat
11781188 if len(pair) != 2 {
11791189 return nil, fmt.Errorf("invalid format %s", b)
11801190 }
1181− if p.HtpasswdFile.Validate(pair[0], pair[1]) {
1191+ if p.basicAuthValidator.Validate(pair[0], pair[1]) {
11821192 logger.PrintAuthf(pair[0], req, logger.AuthSuccess, "Authenticated via basic auth and HTpasswd File")
11831193 return &sessionsapi.SessionState{User: pair[0]}, nil
11841194 }
pkg/authentication/basic/basic_suite_test.gonew file+16
@@ -0,0 +1,16 @@
1+package basic
2+
3+import (
4+ "testing"
5+
6+ "github.com/oauth2-proxy/oauth2-proxy/pkg/logger"
7+ . "github.com/onsi/ginkgo"
8+ . "github.com/onsi/gomega"
9+)
10+
11+func TestBasicSuite(t *testing.T) {
12+ logger.SetOutput(GinkgoWriter)
13+
14+ RegisterFailHandler(Fail)
15+ RunSpecs(t, "Basic")
16+}
pkg/authentication/basic/htpasswd.gonew file+96
@@ -0,0 +1,96 @@
1+package basic
2+
3+import (
4+ "crypto/sha1"
5+ "encoding/base64"
6+ "encoding/csv"
7+ "fmt"
8+ "io"
9+ "os"
10+
11+ "github.com/oauth2-proxy/oauth2-proxy/pkg/logger"
12+ "golang.org/x/crypto/bcrypt"
13+)
14+
15+// htpasswdMap represents the structure of an htpasswd file.
16+// Passwords must be generated with -B for bcrypt or -s for SHA1.
17+type htpasswdMap struct {
18+ users map[string]interface{}
19+}
20+
21+// bcryptPass is used to identify bcrypt passwords in the
22+// htpasswdMap users.
23+type bcryptPass string
24+
25+// sha1Pass os used to identify sha1 passwords in the
26+// htpasswdMap users.
27+type sha1Pass string
28+
29+// NewHTPasswdValidator constructs an httpasswd based validator from the file
30+// at the path given.
31+func NewHTPasswdValidator(path string) (Validator, error) {
32+ r, err := os.Open(path)
33+ if err != nil {
34+ return nil, fmt.Errorf("could not open htpasswd file: %v", err)
35+ }
36+ defer r.Close()
37+ return newHtpasswd(r)
38+}
39+
40+// newHtpasswd consctructs an htpasswd from an io.Reader (an opened file).
41+func newHtpasswd(file io.Reader) (*htpasswdMap, error) {
42+ csvReader := csv.NewReader(file)
43+ csvReader.Comma = ':'
44+ csvReader.Comment = '#'
45+ csvReader.TrimLeadingSpace = true
46+
47+ records, err := csvReader.ReadAll()
48+ if err != nil {
49+ return nil, fmt.Errorf("could not read htpasswd file: %v", err)
50+ }
51+
52+ return createHtpasswdMap(records)
53+}
54+
55+// createHtasswdMap constructs an htpasswdMap from the given records
56+func createHtpasswdMap(records [][]string) (*htpasswdMap, error) {
57+ h := &htpasswdMap{users: make(map[string]interface{})}
58+ for _, record := range records {
59+ user, realPassword := record[0], record[1]
60+ shaPrefix := realPassword[:5]
61+ if shaPrefix == "{SHA}" {
62+ h.users[user] = sha1Pass(realPassword[5:])
63+ continue
64+ }
65+
66+ bcryptPrefix := realPassword[:4]
67+ if bcryptPrefix == "$2a$" || bcryptPrefix == "$2b$" || bcryptPrefix == "$2x$" || bcryptPrefix == "$2y$" {
68+ h.users[user] = bcryptPass(realPassword)
69+ continue
70+ }
71+
72+ // Password is neither sha1 or bcrypt
73+ // TODO(JoelSpeed): In the next breaking release, make this return an error.
74+ logger.Printf("Invalid htpasswd entry for %s. Must be a SHA or bcrypt entry.", user)
75+ }
76+ return h, nil
77+}
78+
79+// Validate checks a users password against the htpasswd entries
80+func (h *htpasswdMap) Validate(user string, password string) bool {
81+ realPassword, exists := h.users[user]
82+ if !exists {
83+ return false
84+ }
85+
86+ switch real := realPassword.(type) {
87+ case sha1Pass:
88+ d := sha1.New()
89+ d.Write([]byte(password))
90+ return string(real) == base64.StdEncoding.EncodeToString(d.Sum(nil))
91+ case bcryptPass:
92+ return bcrypt.CompareHashAndPassword([]byte(real), []byte(password)) == nil
93+ default:
94+ return false
95+ }
96+}
pkg/authentication/basic/htpasswd_test.gonew file+96
@@ -0,0 +1,96 @@
1+package basic
2+
3+import (
4+ . "github.com/onsi/ginkgo"
5+ . "github.com/onsi/gomega"
6+)
7+
8+const (
9+ adminUser = "admin"
10+ adminPassword = "Adm1n1str$t0r"
11+ user1 = "user1"
12+ user1Password = "UsErOn3P455"
13+ user2 = "user2"
14+ user2Password = "us3r2P455W0Rd!"
15+)
16+
17+var _ = Describe("HTPasswd Suite", func() {
18+ Context("with an HTPassword Validator", func() {
19+ assertHtpasswdMapFromFile := func(filePath string) {
20+ var htpasswd *htpasswdMap
21+ var err error
22+
23+ BeforeEach(func() {
24+ var validator Validator
25+ validator, err = NewHTPasswdValidator(filePath)
26+
27+ var ok bool
28+ htpasswd, ok = validator.(*htpasswdMap)
29+ Expect(ok).To(BeTrue())
30+ })
31+
32+ It("does not return an error", func() {
33+ Expect(err).ToNot(HaveOccurred())
34+ })
35+
36+ It("has the correct number of users", func() {
37+ Expect(htpasswd.users).To(HaveLen(3))
38+ })
39+
40+ It("accepts the correct passwords", func() {
41+ Expect(htpasswd.Validate(adminUser, adminPassword)).To(BeTrue())
42+ Expect(htpasswd.Validate(user1, user1Password)).To(BeTrue())
43+ Expect(htpasswd.Validate(user2, user2Password)).To(BeTrue())
44+ })
45+
46+ It("rejects incorrect passwords", func() {
47+ Expect(htpasswd.Validate(adminUser, "asvdfda")).To(BeFalse())
48+ Expect(htpasswd.Validate(user1, "BHEdgbtr")).To(BeFalse())
49+ Expect(htpasswd.Validate(user2, "12345")).To(BeFalse())
50+ })
51+
52+ It("rejects a non existent user", func() {
53+ // Users are case sensitive
54+ Expect(htpasswd.Validate("ADMIN", adminPassword)).To(BeFalse())
55+ })
56+ }
57+
58+ Context("load from file", func() {
59+ Context("with sha1 entries", func() {
60+ const filePath = "./test/htpasswd-sha1.txt"
61+
62+ assertHtpasswdMapFromFile(filePath)
63+ })
64+
65+ Context("with bcrypt entries", func() {
66+ const filePath = "./test/htpasswd-bcrypt.txt"
67+
68+ assertHtpasswdMapFromFile(filePath)
69+ })
70+
71+ Context("with mixed entries", func() {
72+ const filePath = "./test/htpasswd-mixed.txt"
73+
74+ assertHtpasswdMapFromFile(filePath)
75+ })
76+
77+ Context("with a non existent file", func() {
78+ const filePath = "./test/htpasswd-doesnt-exist.txt"
79+ var validator Validator
80+ var err error
81+
82+ BeforeEach(func() {
83+ validator, err = NewHTPasswdValidator(filePath)
84+ })
85+
86+ It("returns an error", func() {
87+ Expect(err).To(MatchError("could not open htpasswd file: open ./test/htpasswd-doesnt-exist.txt: no such file or directory"))
88+ })
89+
90+ It("returns a nil validator", func() {
91+ Expect(validator).To(BeNil())
92+ })
93+ })
94+ })
95+ })
96+})
pkg/authentication/basic/test/htpasswd-bcrypt.txtnew file+8
@@ -0,0 +1,8 @@
1+# admin:Adm1n1str$t0r
2+admin:$2y$05$SXWrNM7ldtbRzBvUC3VXyOvUeiUcP45XPwM93P5eeGOEPIiAZmJjC
3+
4+# user1:UsErOn3P455
5+user1:$2y$05$/sZYJOk8.3Etg4V6fV7puuXfCJLmV5Q7u3xvKpjBSJUka.t2YtmmG
6+
7+# user2: us3r2P455W0Rd!
8+user2:$2y$05$l22MubgKTZFTjTs8TNg5k.YKvcnM2.bA/.iwl0idef5CbekdvBxva
pkg/authentication/basic/test/htpasswd-mixed.txtnew file+8
@@ -0,0 +1,8 @@
1+# admin:Adm1n1str$t0r
2+admin:$2y$05$SXWrNM7ldtbRzBvUC3VXyOvUeiUcP45XPwM93P5eeGOEPIiAZmJjC
3+
4+# user1:UsErOn3P455
5+user1:{SHA}Dvs/L78raajL4jEAHPkwflQXJzI=
6+
7+# user2: us3r2P455W0Rd!
8+user2:{SHA}MoN9/JCJEcYUb6GCQ+2buDvn9pI=
pkg/authentication/basic/test/htpasswd-sha1.txtnew file+8
@@ -0,0 +1,8 @@
1+# admin:Adm1n1str$t0r
2+admin:{SHA}gXQeRH0bcaCfhAk2gOLm1uaePMA=
3+
4+# user1:UsErOn3P455
5+user1:{SHA}Dvs/L78raajL4jEAHPkwflQXJzI=
6+
7+# user2: us3r2P455W0Rd!
8+user2:{SHA}MoN9/JCJEcYUb6GCQ+2buDvn9pI=
pkg/authentication/basic/validator.gonew file+7
@@ -0,0 +1,7 @@
1+package basic
2+
3+// Validator is a minimal interface for something that can validate a
4+// username and password combination.
5+type Validator interface {
6+ Validate(user, password string) bool
7+}