diff --git a/pkg/auth/authenticator/password/htpasswd/htpasswd.go b/pkg/auth/authenticator/password/htpasswd/htpasswd.go new file mode 100644 index 000000000000..4f269551399c --- /dev/null +++ b/pkg/auth/authenticator/password/htpasswd/htpasswd.go @@ -0,0 +1,197 @@ +package htpasswd + +import ( + "bufio" + "crypto/sha1" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" + "github.com/golang/glog" + authapi "github.com/openshift/origin/pkg/auth/api" + "github.com/openshift/origin/pkg/auth/authenticator" +) + +// Authenticator watches a file generated by htpasswd to validate usernames and passwords +type Authenticator struct { + file string + fileInfo os.FileInfo + mapper authapi.UserIdentityMapper + usernames map[string]string +} + +// New returns an authenticator which will validate usernames and passwords against the given htpasswd file +func New(file string, mapper authapi.UserIdentityMapper) (authenticator.Password, error) { + auth := &Authenticator{ + file: file, + mapper: mapper, + } + if err := auth.loadIfNeeded(); err != nil { + return nil, err + } + return auth, nil +} + +func (a *Authenticator) AuthenticatePassword(username, password string) (user.Info, bool, error) { + a.loadIfNeeded() + + if len(username) > 255 { + username = username[:255] + } + if strings.Contains(username, ":") { + return nil, false, errors.New("Usernames may not contain : characters") + } + hash, ok := a.usernames[username] + if !ok { + return nil, false, nil + } + if ok, err := testPassword(password, hash); !ok || err != nil { + return nil, false, err + } + + identity := &authapi.DefaultUserIdentityInfo{ + UserName: username, + } + user, err := a.mapper.UserFor(identity) + glog.V(4).Infof("Got userIdentityMapping: %#v", user) + if err != nil { + return nil, false, fmt.Errorf("Error creating or updating mapping for: %#v due to %v", identity, err) + } + + return user, true, nil + +} + +func (a *Authenticator) load() error { + file, err := os.Open(a.file) + if err != nil { + return err + } + defer file.Close() + + newusernames := map[string]string{} + warnedusernames := map[string]bool{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + if len(line) == 0 { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + glog.Warningf("Ignoring malformed htpasswd line: %s", line) + continue + } + + username := parts[0] + password := parts[1] + if _, duplicate := newusernames[username]; duplicate { + if _, warned := warnedusernames[username]; !warned { + warnedusernames[username] = true + glog.Warningf("%s contains multiple passwords for user '%s'. The last one specified will be used.", a.file, username) + } + } + newusernames[username] = password + } + + a.usernames = newusernames + + return nil +} + +func (a *Authenticator) loadIfNeeded() error { + info, err := os.Stat(a.file) + if err != nil { + return err + } + if a.fileInfo == nil || a.fileInfo.ModTime() != info.ModTime() { + glog.V(4).Infof("Loading htpasswd file %s...", a.file) + a.fileInfo = info + return a.load() + } + return nil +} + +func testPassword(password, hash string) (bool, error) { + switch { + case strings.HasPrefix(hash, "$apr1$"): + // MD5, default + return testMD5Password(password, hash) + case strings.HasPrefix(hash, "$2y$") || strings.HasPrefix(hash, "$2a$"): + // Bcrypt, secure + return testBCryptPassword(password, hash) + case strings.HasPrefix(hash, "{SHA}"): + // SHA-1, insecure + return testSHAPassword(password, hash[5:]) + case len(hash) == 13: + // looks like crypt + return testCryptPassword(password, hash) + default: + return false, errors.New("Unrecognized hash type") + } +} + +func testSHAPassword(password, hash string) (bool, error) { + if len(hash) == 0 { + return false, errors.New("Invalid SHA hash") + } + // Compute hash of password + shasum := sha1.Sum([]byte(password)) + // Base-64 encode + base64shasum := base64.StdEncoding.EncodeToString(shasum[:]) + // Compare + match := hash == base64shasum + return match, nil +} + +func testBCryptPassword(password, hash string) (bool, error) { + // TODO: import bcrypt + // err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + // if err == bcrypt.ErrMismatchedHashAndPassword { + // return false, nil + // } + // if err != nil { + // return false, err + // } + // return true, nil + return false, errors.New("bcrypt password hashes are not supported") +} + +func testMD5Password(password, hash string) (bool, error) { + parts := strings.Split(hash, "$") + if len(parts) != 4 { + return false, errors.New("Malformed MD5 hash") + } + + salt := parts[2] + if len(salt) == 0 { + return false, errors.New("Malformed MD5 hash: missing salt") + } + if len(salt) > 8 { + salt = salt[:8] + } + + md5hash := parts[3] + if len(md5hash) == 0 { + return false, errors.New("Malformed MD5 hash: missing hash") + } + + testhash := string(apr_md5([]byte(password), []byte(salt))) + match := testhash == hash + + return match, nil +} + +func testCryptPassword(password, hash string) (bool, error) { + // if len(password) > 8 { + // password = password[:8] + // } + // salt := hash[0:2] + // hash = hash[2:] + return false, errors.New("crypt password hashes are not supported") +} diff --git a/pkg/auth/authenticator/password/htpasswd/htpasswd_test.go b/pkg/auth/authenticator/password/htpasswd/htpasswd_test.go new file mode 100644 index 000000000000..abdb7519c198 --- /dev/null +++ b/pkg/auth/authenticator/password/htpasswd/htpasswd_test.go @@ -0,0 +1,184 @@ +package htpasswd + +import "testing" + +func TestPasswordHashes(t *testing.T) { + testCases := []struct { + Name string + Password string + Hash string + Match bool + Error bool + }{ + // htpasswd -n -b "" "" + // ":$apr1$AQEmuiLe$2lPjK2hL6mnTakRWskgaQ1" + { + Name: "md5 empty", + Password: "", + Hash: "$apr1$AQEmuiLe$2lPjK2hL6mnTakRWskgaQ1", + Match: true, + }, + // htpasswd -n -b "username" "password" + // username:$apr1$6TMtuxUJ$0M76TkGjp0qVg/e7rfk22. + { + Name: "md5 password", + Password: "password", + Hash: "$apr1$6TMtuxUJ$0M76TkGjp0qVg/e7rfk22.", + Match: true, + }, + { + Name: "md5 mismatch", + Password: "mypassword", + Hash: "$apr1$6TMtuxUJ$0M76TkGjp0qVg/e7rfk22.", + Match: false, + }, + { + Name: "md5 missing salt", + Password: "password", + Hash: "$apr1$$0M76TkGjp0qVg/e7rfk22.", + Match: false, + Error: true, + }, + // htpasswd -n -b "username" "passwordthatisreallyreallyreallyreallyreallyreallyreallylong" + // username:$apr1$6VmuPCYl$OvuHDqaS59nsRov9HnsGc1 + { + Name: "md5 with long password", + Password: "passwordthatisreallyreallyreallyreallyreallyreallyreallylong", + Hash: "$apr1$6VmuPCYl$OvuHDqaS59nsRov9HnsGc1", + Match: true, + }, + + // htpasswd -d -n -b "" "" + // :lNO4S8u4F4oNo + { + Name: "crypt empty", + Password: "", + Hash: "lNO4S8u4F4oNo", + Match: false, // TODO: change to true if/when we add crypt support + Error: true, // TODO: change to false if/when we add crypt support + }, + // htpasswd -d -n -b "username" "password" + // username:.zs/E.NK2vwFs + { + Name: "crypt match", + Password: "password", + Hash: ".zs/E.NK2vwFs", + Match: false, // TODO: change to true if/when we add crypt support + Error: true, // TODO: change to false if/when we add crypt support + }, + { + Name: "crypt mismatch", + Password: "mypassword", + Hash: ".zs/E.NK2vwFs", + Match: false, // TODO: change to true if/when we add crypt support + Error: true, // TODO: change to false if/when we add crypt support + }, + { + Name: "crypt missing salt", + Password: "password", + Hash: "s", + Match: false, + Error: true, + }, + + // htpasswd -s -n -b "" "" + // :{SHA}2jmj7l5rSw0yVb/vlWAYkK/YBwk= + { + Name: "sha empty", + Password: "", + Hash: "{SHA}2jmj7l5rSw0yVb/vlWAYkK/YBwk=", + Match: true, + }, + // htpasswd -s -n -b "username" "password" + // username:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= + { + Name: "sha match", + Password: "password", + Hash: "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=", + Match: true, + }, + { + Name: "sha mismatch", + Password: "mypassword", + Hash: "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=", + Match: false, + }, + { + Name: "sha invalid", + Password: "mypassword", + Hash: "{SHA}", + Match: false, + Error: true, + }, + + // htpasswd -B -n -b "" "" + // :$2y$05$Edf.Eeznh19sIYYcTc7YOeltcWjzFuvrcYp57lq78diiJr512GILm + { + Name: "bcrypt strength 5 empty", + Password: "", + Hash: "$2y$05$Edf.Eeznh19sIYYcTc7YOeltcWjzFuvrcYp57lq78diiJr512GILm", + Match: false, // TODO: change to true if/when we add bcrypt support + Error: true, // TODO: change to false if/when we add bcrypt support + }, + // htpasswd -B -n -b "username" "password" + // username:$2y$05$Vfd6hjeQXB6nTFTVMkoFE.CAItk2W8akuomafFBakd0n/mHqIzoUO + { + Name: "bcrypt strength 5 match", + Password: "password", + Hash: "$2y$05$Vfd6hjeQXB6nTFTVMkoFE.CAItk2W8akuomafFBakd0n/mHqIzoUO", + Match: false, // TODO: change to true if/when we add bcrypt support + Error: true, // TODO: change to false if/when we add bcrypt support + }, + { + Name: "bcrypt strength 5 mismatch", + Password: "mypassword", + Hash: "$2y$05$Vfd6hjeQXB6nTFTVMkoFE.CAItk2W8akuomafFBakd0n/mHqIzoUO", + Match: false, + Error: true, // TODO: change to false if/when we add bcrypt support + }, + + // htpasswd -C 10 -B -n -b "" "" + // :$2y$10$v0c.7wrYEv2AZnLsPXO57.48Qc5widamyKkmwrUolKwYW0Zw8zhJ. + { + Name: "bcrypt strength 10 empty", + Password: "", + Hash: "$2y$10$v0c.7wrYEv2AZnLsPXO57.48Qc5widamyKkmwrUolKwYW0Zw8zhJ.", + Match: false, // TODO: change to true if/when we add bcrypt support + Error: true, // TODO: change to false if/when we add bcrypt support + }, + // htpasswd -C 10 -B -n -b "username" "password" + // username:$2y$10$Fk32bQky/.91nbecGjFfPO1m97V12d.ickjAzpNF22NgMKs4qWDOK + { + Name: "bcrypt strength 10 match", + Password: "password", + Hash: "$2y$10$Fk32bQky/.91nbecGjFfPO1m97V12d.ickjAzpNF22NgMKs4qWDOK", + Match: false, // TODO: change to true if/when we add bcrypt support + Error: true, // TODO: change to false if/when we add bcrypt support + }, + { + Name: "bcrypt strength 10 mismatch", + Password: "mypassword", + Hash: "$2y$10$Fk32bQky/.91nbecGjFfPO1m97V12d.ickjAzpNF22NgMKs4qWDOK", + Match: false, + Error: true, // TODO: change to false if/when we add bcrypt support + }, + + { + Name: "bcrypt missing strength", + Password: "password", + Hash: "$2y$$Fk32bQky/.91nbecGjFfPO1m97V12d.ickjAzpNF22NgMKs4qWDOK", + Match: false, + Error: true, + }, + } + + for _, testCase := range testCases { + match, err := testPassword(testCase.Password, testCase.Hash) + if testCase.Error != (err != nil) { + t.Errorf("%s: Expected error=%v, got %v", testCase.Name, testCase.Error, err) + } + if match != testCase.Match { + t.Errorf("%s: Expected match=%v, got %v", testCase.Name, testCase.Match, match) + } + } +} diff --git a/pkg/auth/authenticator/password/htpasswd/md5.go b/pkg/auth/authenticator/password/htpasswd/md5.go new file mode 100644 index 000000000000..729956ad289c --- /dev/null +++ b/pkg/auth/authenticator/password/htpasswd/md5.go @@ -0,0 +1,144 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * The apr_md5_encode() routine uses much code obtained from the FreeBSD 3.0 + * MD5 crypt() function, which is licenced as follows: + * ---------------------------------------------------------------------------- + * "THE BEER-WARE LICENSE" (Revision 42): + * wrote this file. As long as you retain this notice you + * can do whatever you want with this stuff. If we meet some day, and you think + * this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp + * ---------------------------------------------------------------------------- + */ + +package htpasswd + +import "crypto/md5" + +const itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// word_outputs is a slice of tuples to be combined into a single uint64 and passed to to64. +// Each tuple is a slice of chunks. +// Each chunk is a pair of an offset and a number of bits to shift. +// +// l = (final[ 0]<<16) | (final[ 6]<<8) | final[12]; to64(p, l, 4); p += 4; +// l = (final[ 1]<<16) | (final[ 7]<<8) | final[13]; to64(p, l, 4); p += 4; +// l = (final[ 2]<<16) | (final[ 8]<<8) | final[14]; to64(p, l, 4); p += 4; +// l = (final[ 3]<<16) | (final[ 9]<<8) | final[15]; to64(p, l, 4); p += 4; +// l = (final[ 4]<<16) | (final[10]<<8) | final[ 5]; to64(p, l, 4); p += 4; +// l = final[11] ; to64(p, l, 2); p += 2; +var word_outputs = [][][2]int{ + {{0, 16}, {6, 8}, {12, 0}}, + {{1, 16}, {7, 8}, {13, 0}}, + {{2, 16}, {8, 8}, {14, 0}}, + {{3, 16}, {9, 8}, {15, 0}}, + {{4, 16}, {10, 8}, {5, 0}}, + {{11, 0}}, +} + +var magic = []byte("$apr1$") + +// From http://svn.apache.org/viewvc/apr/apr-util/branches/1.3.x/crypto/apr_md5.c +func apr_md5(password, salt []byte) []byte { + // Time to make the doughnuts... + ctx := md5.New() + // The password first, since that is what is most unknown + ctx.Write(password) + // Then our magic string + ctx.Write(magic) + // Then the raw salt + ctx.Write(salt) + + // Then just as many characters of the MD5(pw, salt, pw) + ctx1 := md5.New() + ctx1.Write(password) + ctx1.Write(salt) + ctx1.Write(password) + final := ctx1.Sum(nil) + for i := len(password); i > 0; i -= md5.Size { + if i > md5.Size { + ctx.Write(final) + } else { + ctx.Write(final[:i]) + } + } + + // Then something really weird... + for i := len(password); i != 0; i >>= 1 { + if i&1 != 0 { + ctx.Write([]byte{0}) + } else { + ctx.Write([]byte{password[0]}) + } + } + + // And now, just to make sure things don't run too fast.. + // On a 60 Mhz Pentium this takes 34 msec, so you would + // need 30 seconds to build a 1000 entry dictionary... + final = ctx.Sum(nil) + for i := 0; i < 1000; i++ { + ctx1 := md5.New() + + if i&1 != 0 { + ctx1.Write(password) + } else { + ctx1.Write(final) + } + + if i%3 != 0 { + ctx1.Write(salt) + } + + if i%7 != 0 { + ctx1.Write(password) + } + + if i&1 != 0 { + ctx1.Write(final) + } else { + ctx1.Write(password) + } + + final = ctx1.Sum(nil) + } + + result := []byte{} + result = append(result, magic...) + result = append(result, salt...) + result = append(result, '$') + + for _, word := range word_outputs { + l := uint64(0) + for _, chunk := range word { + index := chunk[0] + offset := chunk[1] + l |= (uint64(final[index]) << uint(offset)) + } + result = append(result, to64(l, len(word)+1)...) + } + + return result +} + +func to64(v uint64, n int) []byte { + r := make([]byte, n) + for i := 0; i < n; i++ { + r[i] = itoa64[v&0x3f] + v >>= 6 + } + return r +} diff --git a/pkg/cmd/server/origin/auth.go b/pkg/cmd/server/origin/auth.go index 9b61ba2019cf..f515c66606cf 100644 --- a/pkg/cmd/server/origin/auth.go +++ b/pkg/cmd/server/origin/auth.go @@ -23,6 +23,7 @@ import ( "github.com/openshift/origin/pkg/auth/authenticator/password/allowanypassword" "github.com/openshift/origin/pkg/auth/authenticator/password/basicauthpassword" "github.com/openshift/origin/pkg/auth/authenticator/password/denypassword" + "github.com/openshift/origin/pkg/auth/authenticator/password/htpasswd" "github.com/openshift/origin/pkg/auth/authenticator/request/basicauthrequest" "github.com/openshift/origin/pkg/auth/authenticator/request/bearertoken" "github.com/openshift/origin/pkg/auth/authenticator/request/headerrequest" @@ -127,6 +128,8 @@ const ( PasswordAuthAnyPassword PasswordAuthType = "anypassword" // PasswordAuthBasicAuthURL validates password credentials by making a request to a remote url using basic auth. See basicauthpassword.Authenticator PasswordAuthBasicAuthURL PasswordAuthType = "basicauthurl" + // PasswordAuthHTPasswd validates usernames and passwords against an htpasswd file + PasswordAuthHTPasswd PasswordAuthType = "htpasswd" // PasswordAuthDeny treats any username and password combination as an unsuccessful authentication PasswordAuthDeny PasswordAuthType = "deny" ) @@ -182,6 +185,8 @@ type AuthConfig struct { PasswordAuth PasswordAuthType // BasicAuthURL specifies the remote URL to validate username/passwords against using basic auth. Used by PasswordAuthBasicAuthURL. BasicAuthURL string + // HTPasswdFile specifies the path to an htpasswd file to validate username/passwords against. Used by PasswordAuthHTPasswd. + HTPasswdFile string // TokenStore specifies how to validate bearer tokens. Used by AuthRequestHandlerBearer. TokenStore TokenStoreType @@ -479,6 +484,17 @@ func (c *AuthConfig) getPasswordAuthenticator() authenticator.Password { case PasswordAuthDeny: // Deny any username and password passwordAuth = denypassword.New() + case PasswordAuthHTPasswd: + htpasswdFile := c.HTPasswdFile + if len(htpasswdFile) == 0 { + glog.Fatalf("HTPasswdFile is required to support htpasswd auth") + } + if htpasswordAuth, err := htpasswd.New(htpasswdFile, identityMapper); err != nil { + glog.Fatalf("Error loading htpasswd file %s: %v", htpasswdFile, err) + } else { + passwordAuth = htpasswordAuth + } + default: glog.Fatalf("No password auth found that matches %v. The oauth server cannot start!", passwordAuthType) } diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index 448306fe67cd..8494366fa94e 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -554,6 +554,7 @@ func start(cfg *config, args []string) error { // Password config PasswordAuth: origin.PasswordAuthType(env("OPENSHIFT_OAUTH_PASSWORD_AUTH", string(origin.PasswordAuthAnyPassword))), BasicAuthURL: env("OPENSHIFT_OAUTH_BASIC_AUTH_URL", ""), + HTPasswdFile: env("OPENSHIFT_OAUTH_HTPASSWD_FILE", ""), // Token config TokenStore: origin.TokenStoreType(env("OPENSHIFT_OAUTH_TOKEN_STORE", string(origin.TokenStoreOAuth))), TokenFilePath: env("OPENSHIFT_OAUTH_TOKEN_FILE_PATH", ""),