diff --git a/cli/cli.go b/cli/cli.go index bf54fb31..4021c759 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -27,6 +27,13 @@ type CliOptions struct { StorageDirectory string } +type passwordType string + +const ( + passwordTypeWebUI passwordType = "webui" + passwordTypeServer passwordType = "server" +) + func ParseArguments() CliOptions { fenv := os.Getenv("FAKTORY_ENV") @@ -146,10 +153,14 @@ func BuildServer(opts *CliOptions) (*server.Server, func() error, error) { return nil, nil, err } - pwd, err := fetchPassword(globalConfig, opts.Environment) + pwd, err := fetchPassword(globalConfig, opts.Environment, passwordTypeServer) if err != nil { return nil, nil, err } + webPwd, err := fetchPassword(globalConfig, opts.Environment, passwordTypeWebUI) + if err != nil { + util.Warnf("unable to fetch Web UI password, defaulting to server's: %v", err) + } sock := fmt.Sprintf("%s/redis.sock", opts.StorageDirectory) stopper, err := storage.Boot(opts.StorageDirectory, sock) @@ -172,6 +183,7 @@ func BuildServer(opts *CliOptions) (*server.Server, func() error, error) { RedisSock: sock, GlobalConfig: globalConfig, Password: pwd, + WebUIPassword: webPwd, PoolSize: server.DefaultMaxPoolSize, } @@ -247,33 +259,54 @@ func readConfig(cdir string, env string) (map[string]any, error) { // [faktory] // password = "foobar" # or... // password = "/run/secrets/my_faktory_password" -func fetchPassword(cfg map[string]any, env string) (string, error) { +// +// [web] +// password = "foobar" # or... +// password = "/run/secrets/my_faktory_password" +func fetchPassword(cfg map[string]any, env string, pwdtype passwordType) (string, error) { password := "" + var envKey string + var pwdPath string + cfgPath := struct { + Subsys string + Elm string + }{} + if pwdtype == passwordTypeServer { + envKey = "FAKTORY_PASSWORD" + pwdPath = "/etc/faktory/password" + cfgPath.Subsys = "faktory" + cfgPath.Elm = "password" + } else { + envKey = "FAKTORY_WEBUI_PASSWORD" + pwdPath = "/etc/faktory/webui_password" + cfgPath.Subsys = "web" + cfgPath.Elm = "password" + } + // allow the password to be injected via ENV rather than committed // to filesystem. Note if this value starts with a /, then it is // considered a pointer to a file on the filesystem with the password // value, e.g. FAKTORY_PASSWORD=/run/secrets/my_faktory_password. - val, ok := os.LookupEnv("FAKTORY_PASSWORD") + val, ok := os.LookupEnv(envKey) if ok { password = val } else { - val := stringConfig(cfg, "faktory", "password", "") + val := stringConfig(cfg, cfgPath.Subsys, cfgPath.Elm, "") if val != "" { password = val // clear password so we can log it safely - x := cfg["faktory"].(map[string]any) - x["password"] = "********" + x := cfg[cfgPath.Subsys].(map[string]any) + x[cfgPath.Elm] = "********" } } if env != "development" && !skip() && password == "" { - ok, _ := util.FileExists("/etc/faktory/password") + ok, _ := util.FileExists(pwdPath) if ok { - //nolint:gosec - password = "/etc/faktory/password" + password = pwdPath } } @@ -288,7 +321,7 @@ func fetchPassword(cfg map[string]any, env string) (string, error) { password = strings.TrimSpace(string(data)) } - if env != "development" && !skip() && password == "" { + if env != "development" && !skip() && password == "" && pwdtype == passwordTypeServer { return "", fmt.Errorf("faktory requires a password to be set in staging or production, see the Security wiki page") } diff --git a/cli/security_test.go b/cli/security_test.go index 42779b91..1545c2b7 100644 --- a/cli/security_test.go +++ b/cli/security_test.go @@ -14,6 +14,13 @@ func pwdCfg(value string) map[string]any { }, } } +func webPwdCfg(value string) map[string]any { + return map[string]any{ + "web": map[string]any{ + "password": value, + }, + } +} func TestPasswords(t *testing.T) { emptyCfg := map[string]any{} @@ -22,21 +29,30 @@ func TestPasswords(t *testing.T) { t.Run("DevWithPassword", func(t *testing.T) { cfg := pwdCfg(pwd) - pwd, err := fetchPassword(cfg, "development") + pwd, err := fetchPassword(cfg, "development", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, 16, len(pwd)) assert.Equal(t, "cce29d6565ab7376", pwd) assert.Equal(t, "********", cfg["faktory"].(map[string]any)["password"]) }) + t.Run("DevWithWebPassword", func(t *testing.T) { + cfg := webPwdCfg(pwd) + pwd, err := fetchPassword(cfg, "development", passwordTypeWebUI) + assert.NoError(t, err) + assert.Equal(t, 16, len(pwd)) + assert.Equal(t, "cce29d6565ab7376", pwd) + assert.Equal(t, "********", cfg["web"].(map[string]any)["password"]) + }) + t.Run("DevWithoutPassword", func(t *testing.T) { - pwd, err := fetchPassword(emptyCfg, "development") + pwd, err := fetchPassword(emptyCfg, "development", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "", pwd) }) t.Run("ProductionWithoutPassword", func(t *testing.T) { - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.Error(t, err) assert.Equal(t, "", pwd) }) @@ -46,14 +62,14 @@ func TestPasswords(t *testing.T) { err := os.WriteFile("/tmp/test-password", []byte("foobar"), os.FileMode(0o666)) assert.NoError(t, err) cfg := pwdCfg("/tmp/test-password") - pwd, err := fetchPassword(cfg, "production") + pwd, err := fetchPassword(cfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "foobar", pwd) }) t.Run("ProductionWithPassword", func(t *testing.T) { cfg := pwdCfg(pwd) - pwd, err := fetchPassword(cfg, "production") + pwd, err := fetchPassword(cfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, 16, len(pwd)) assert.Equal(t, "cce29d6565ab7376", pwd) @@ -63,20 +79,31 @@ func TestPasswords(t *testing.T) { t.Run("ProductionEnvPassword", func(t *testing.T) { os.Setenv("FAKTORY_PASSWORD", "abc123") - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "abc123", pwd) }) os.Unsetenv("FAKTORY_PASSWORD") + t.Run("ProductionEnvPassword", func(t *testing.T) { + os.Setenv("FAKTORY_WEBUI_PASSWORD", "webuipass") + + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeWebUI) + assert.NoError(t, err) + assert.Equal(t, "webuipass", pwd) + }) + + os.Unsetenv("FAKTORY_WEBUI_PASSWORD") + t.Run("ProductionSkipPassword", func(t *testing.T) { os.Setenv("FAKTORY_SKIP_PASSWORD", "yes") - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "", pwd) }) os.Unsetenv("FAKTORY_SKIP_PASSWORD") + } diff --git a/example/config.toml b/example/config.toml index f146a117..d6fec8de 100644 --- a/example/config.toml +++ b/example/config.toml @@ -1,3 +1,15 @@ +[faktory] +# Required for production. Can use `FAKTORY_PASSWORD` env var instead +password = "a-password-for-clients-to-connect-with" + +[web] +# Optional. Specify to make the Web UI's HTTP Basic auth password different +# from the Faktory server password clients connect with. Supports PHC-formatted +# argon2id, bcrypt, scrypt, and pbkdf2 hashes and not just plaintext. +# e.g. "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW" +# Can use `FAKTORY_WEBUI_PASSWORD` env var instead. +password = "an-optionally-different-http-basic-webui-password-from-faktory-server" + [queues] # disable backpressure by default backpressure = 0 diff --git a/go.mod b/go.mod index 3ef478f2..03c65bd4 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.24 require ( github.com/BurntSushi/toml v1.5.0 - github.com/contribsys/faktory_worker_go v1.7.0 github.com/justinas/nosurf v1.2.0 github.com/redis/go-redis/v9 v9.7.3 + golang.org/x/crypto v0.39.0 ) require ( @@ -14,6 +14,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) require ( diff --git a/go.sum b/go.sum index aaf99687..077e3ee0 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,10 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/contribsys/faktory_worker_go v1.7.0 h1:YSZRj/nSjn+2ms9ooHIOHAahHJehXutgD46b+0lHDP4= -github.com/contribsys/faktory_worker_go v1.7.0/go.mod h1:JRw4PvanwLgX5IIQazw6W5zg/Rg7/Fg6YrkdH4gJJPc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= -github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -22,6 +18,10 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/password/password.go b/password/password.go new file mode 100644 index 00000000..968e6eea --- /dev/null +++ b/password/password.go @@ -0,0 +1,260 @@ +package password + +import ( + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "hash" + "math" + "strings" + + "github.com/contribsys/faktory/util" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" +) + +type hashID string + +const ( + hashIDBCrypt2 hashID = "2" // technically a major ver only + hashIDArgon2id hashID = "argon2id" + hashIDScrypt hashID = "scrypt" + hashIDPBKDF2 hashID = "pbkdf2" // technically the first part of the identifier +) + +// PasswordType interface describes the common way to verify a password for a +// given particular hashing/algorithm strategy +type PasswordType interface { + Verify(candidate string) (bool, error) +} + +type basePasswordType struct { + Hashed string +} +type plainPasswordType struct { + basePasswordType +} + +func (p plainPasswordType) Verify(candidate string) (bool, error) { + return subtle.ConstantTimeCompare([]byte(p.Hashed), []byte(candidate)) == 1, nil +} + +type bcryptPasswordType struct { + basePasswordType +} + +func (p bcryptPasswordType) Verify(candidate string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(p.Hashed), []byte(candidate)) + if err != nil { + return false, err + } else { + return true, nil + } +} + +type argon2idPasswordType struct { + basePasswordType + Version int + Memory uint32 + Time uint32 + Threads uint8 + Salt []byte + Key []byte + KeyLen int32 +} + +func (p argon2idPasswordType) Verify(candidate string) (bool, error) { + candidateKey := argon2.IDKey([]byte(candidate), + p.Salt, + p.Time, + p.Memory, + p.Threads, + uint32(p.KeyLen), + ) + + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil +} + +type scryptPasswordType struct { + basePasswordType + Salt []byte + LogCost int + BlockSize int + Parallelism int + Key []byte + KeyLen int +} + +func (p scryptPasswordType) Verify(candidate string) (bool, error) { + candidateKey, err := scrypt.Key( + []byte(candidate), + p.Salt, + int(math.Pow(2, float64(p.LogCost))), + p.BlockSize, + p.Parallelism, + p.KeyLen, + ) + + if err != nil { + return false, err + } + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil +} + +type pbkdf2PasswordType struct { + basePasswordType + DigestFunc func() hash.Hash + Salt []byte + Rounds int + Key []byte + KeyLen int +} + +func (p pbkdf2PasswordType) Verify(candidate string) (bool, error) { + candidateKey, err := pbkdf2.Key( + p.DigestFunc, + candidate, + p.Salt, + p.Rounds, + p.KeyLen, + ) + + if err != nil { + return false, err + } + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil +} + +// Verify returns true if a `candidate` password matches the `configured` one, +// which may or may not by hashed with different standardized hashing +// algorithms. If an algorithm cannot be detected, it is assumed the +// `configured` password is in plaintext. +// +// An error is returned when unable to decode the hash correctly or if the +// underlying hashing library returns an error during verification. +func Verify(candidate string, configured string) (bool, error) { + algo, err := detectHashAlgorithm(configured) + if err != nil { + return false, err + } + return algo.Verify(candidate) +} + +func detectHashAlgorithm(pwd string) (PasswordType, error) { + // TODO: do a fulsome parsing of PHC format + parts := strings.Split(pwd, "$") + ppt := plainPasswordType{} + ppt.Hashed = pwd + if parts[0] != "" || len(parts) < 2 || len(parts[1]) < 1 { + util.Warn("Not a recognizable password hash format, assuming plaintext") + return ppt, nil + } + if hashID(parts[1][0]) == hashIDBCrypt2 { + pt := bcryptPasswordType{} + pt.Hashed = pwd + return pt, nil + } + if hashID(parts[1]) == hashIDArgon2id { + pt := argon2idPasswordType{} + pt.Hashed = pwd + _, err := fmt.Sscanf(parts[2], "v=%d", &pt.Version) + if pt.Version != argon2.Version { + return nil, fmt.Errorf("Password hash uses incompatible version of Argon2id (want %d, given %d)", argon2.Version, pt.Version) + } + _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pt.Memory, &pt.Time, &pt.Threads) + if err != nil { + return nil, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return nil, err + } + pt.Salt = salt + key, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = int32(len(pt.Key)) + return pt, nil + } + if hashID(parts[1]) == hashIDScrypt { + pt := scryptPasswordType{} + pt.Hashed = pwd + _, err := fmt.Sscanf(parts[2], "ln=%d,r=%d,p=%d", &pt.LogCost, &pt.BlockSize, &pt.Parallelism) + if err != nil { + return nil, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[3]) + if err != nil { + return nil, err + } + pt.Salt = salt + + key, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = len(pt.Key) + return pt, nil + } + if strings.HasPrefix(parts[1], string(hashIDPBKDF2)) { + digestIdent, found := strings.CutPrefix(parts[1], string(hashIDPBKDF2)) + if !found { + return nil, errors.New("Could not find digest algo from PBKDF2 hash") + } + pt := pbkdf2PasswordType{} + pt.Hashed = pwd + switch digestIdent { + case "", "-sha1": + pt.DigestFunc = sha1.New + case "-sha256": + pt.DigestFunc = sha256.New + case "-sha512": + pt.DigestFunc = sha512.New + default: + return nil, fmt.Errorf("Unsupported digest ID %s for PBKDF2 hash", digestIdent) + } + _, err := fmt.Sscanf(parts[2], "%d", &pt.Rounds) + if err != nil { + return nil, err + } + salt, err := ab64Decode(parts[3]) + if err != nil { + return nil, err + } + pt.Salt = salt + key, err := ab64Decode(parts[4]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = len(pt.Key) + return pt, nil + } + util.Warnf("Unknown password hash algorithm ID %s, assuming plaintext", parts[1]) + return ppt, nil +} + +// ab64Decode from strict and raw base64 format which omits padding & +// whitespace. Supports both custom ./ and normal +/ altchars. +func ab64Decode(s string) ([]byte, error) { + b64s := strings.ReplaceAll(s, ".", "+") + return base64.RawStdEncoding.Strict().DecodeString(b64s) +} diff --git a/password/password_test.go b/password/password_test.go new file mode 100644 index 00000000..1565e60f --- /dev/null +++ b/password/password_test.go @@ -0,0 +1,137 @@ +package password + +import ( + "testing" +) + +// PHC-formatted hashes produced using Python's `passlib` library: +// +// from passlib import hash +// hash.bcrypt.hash("a") # => "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW" +// hash.scrypt.hash("a") # => "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw" +func TestPasswordVerify(t *testing.T) { + tests := + []struct { + name string + candidate string + configured string + verified bool + expectErr bool + errMessage string + }{ + { + name: "bcrypt correct", + candidate: "a", + configured: "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW", + verified: true, + }, + { + name: "bcrypt incorrect", + candidate: "wrong", + configured: "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW", + verified: false, + expectErr: true, + errMessage: "crypto/bcrypt: hashedPassword is not the hash of the given password", + }, + { + name: "argon2id correct", + candidate: "a", + configured: "$argon2id$v=19$m=65536,t=3,p=4$VSrlfE9pLcW4977XGiOklA$GIIN05JoObiRMLBpP+iPBHeemyovXJvM4Zi2JU82XFg", + verified: true, + }, + { + name: "argon2id incorrect", + candidate: "wrong", + configured: "$argon2id$v=19$m=65536,t=3,p=4$VSrlfE9pLcW4977XGiOklA$GIIN05JoObiRMLBpP+iPBHeemyovXJvM4Zi2JU82XFg", + verified: false, + }, + { + name: "scrypt correct", + candidate: "a", + configured: "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw", + verified: true, + }, + { + name: "scrypt incorrect", + candidate: "wrong", + configured: "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw", + verified: false, + }, + { + name: "pbkdf2-hmac-sha1 correct", + candidate: "a", + configured: "$pbkdf2$131000$j3FurVUqxbiXUuqdc865Fw$khjQ9RJHk0901AZmqtUnudHQmDg", + verified: true, + }, + { + name: "pbkdf2-hmac-sha1 incorrect", + candidate: "wrong", + configured: "$pbkdf2$131000$j3FurVUqxbiXUuqdc865Fw$khjQ9RJHk0901AZmqtUnudHQmDg", + verified: false, + }, + { + name: "pbkdf2-hmac-sha256 correct", + candidate: "a", + configured: "$pbkdf2-sha256$29000$EqJUqlXqfa/13hsDYGyNsQ$mySn2pP1vbxyIA2/ExJqoHDc0ywnwf4SSJPavT6n3oA", + verified: true, + }, + { + name: "pbkdf2-hmac-sha256 incorrect", + candidate: "wrong", + configured: "$pbkdf2-sha256$29000$EqJUqlXqfa/13hsDYGyNsQ$mySn2pP1vbxyIA2/ExJqoHDc0ywnwf4SSJPavT6n3oA", + verified: false, + }, + { + name: "pbkdf2-hmac-sha512 correct", + candidate: "a", + configured: "$pbkdf2-sha512$25000$QmhtjZEyJuR8r3UOoVRKaQ$EiqzPjoOZkEt3SKVZv9g31/kaj8WXIaey5pNWWVczZrJXXeuA9CU.vlJ3AgYS6CqojXtpgC1P0kJwkevKDMqMw", + verified: true, + }, + { + name: "pbkdf2-hmac-sha512 incorrect", + candidate: "wrong", + configured: "$pbkdf2-sha512$25000$QmhtjZEyJuR8r3UOoVRKaQ$EiqzPjoOZkEt3SKVZv9g31/kaj8WXIaey5pNWWVczZrJXXeuA9CU.vlJ3AgYS6CqojXtpgC1P0kJwkevKDMqMw", + verified: false, + }, + { + name: "plaintext correct", + candidate: "a", + configured: "a", + verified: true, + }, + { + name: "plaintext incorrect", + candidate: "wrong", + configured: "a", + verified: false, + }, + { + name: "PHC-ish looking plaintext", + candidate: "$bcrypt$tography", + configured: "$bcrypt$tography", + verified: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := Verify(tt.candidate, tt.configured) + + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + if err.Error() != tt.errMessage { + t.Errorf("expected error %q, got %q", tt.errMessage, err.Error()) + } + } else { + if err != nil { + t.Errorf("did not expect error, got %q", err.Error()) + } + if v != tt.verified { + t.Errorf("Verify(\"%s\", \"%s\") = %v; want %v", tt.candidate, tt.configured, v, tt.verified) + } + } + }) + } +} diff --git a/server/config.go b/server/config.go index 3287a8b0..01e8f784 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,8 @@ package server -import "github.com/contribsys/faktory/util" +import ( + "github.com/contribsys/faktory/util" +) // This is the ultimate scalability limitation in Faktory, // we only allow this many connections to Redis. @@ -14,6 +16,7 @@ type ServerOptions struct { ConfigDirectory string Environment string Password string + WebUIPassword string PoolSize uint64 } diff --git a/webui/web.go b/webui/web.go index c332a116..bcaf16ad 100644 --- a/webui/web.go +++ b/webui/web.go @@ -2,7 +2,6 @@ package webui import ( "context" - "crypto/subtle" "embed" "encoding/json" "fmt" @@ -14,6 +13,7 @@ import ( "time" "github.com/contribsys/faktory/client" + "github.com/contribsys/faktory/password" "github.com/contribsys/faktory/server" "github.com/contribsys/faktory/util" "github.com/justinas/nosurf" @@ -205,8 +205,9 @@ func (l *Lifecycle) opts(s *server.Server) Options { } // Allow the Web UI to have a different password from the command port // so you can rotate user-used passwords and machine-used passwords separately - pwd := s.Options.String("web", "password", "") + pwd := s.Options.WebUIPassword if pwd == "" { + util.Info("Defaulting Web UI password to server's") pwd = s.Options.Password } opts.Password = pwd @@ -324,15 +325,20 @@ func setup(ui *WebUI, pass http.HandlerFunc, debug bool) http.HandlerFunc { return genericSetup } -func basicAuth(pwd string, pass http.HandlerFunc) http.HandlerFunc { +func basicAuth(srvPwd string, pass http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _, password, ok := r.BasicAuth() + _, pwd, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="Faktory"`) http.Error(w, "Authorization required", http.StatusUnauthorized) return } - if subtle.ConstantTimeCompare([]byte(password), []byte(pwd)) != 1 { + // Web UI's configured password is actually a hash. + verified, err := password.Verify(pwd, srvPwd) + if !verified { + if err != nil { + util.Error("Error during password verification", err) + } w.Header().Set("WWW-Authenticate", `Basic realm="Faktory"`) http.Error(w, "Authorization failed", http.StatusUnauthorized) return