diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index d903a7942f81b..1e8bc116eabb7 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -508,7 +508,6 @@ num_repos: 0 is_active: true - - id: 30 lower_name: user30 @@ -525,3 +524,20 @@ avatar_email: user30@example.com num_repos: 2 is_active: true + +- + id: 31 + lower_name: user31 + name: user31 + full_name: User ThirtyOne + email: user31@example.com + passwd_hash_algo: argon2 + passwd: "$argon2$65536$2$8$50$a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b" + type: 0 # individual + salt: ZogKvWdyEx + is_admin: false + is_restricted: true + avatar: avatar29 + avatar_email: user31@example.com + num_repos: 0 + is_active: true diff --git a/models/user.go b/models/user.go index 495fed1ff4d03..78b404af1c3f5 100644 --- a/models/user.go +++ b/models/user.go @@ -17,6 +17,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" "unicode/utf8" @@ -52,10 +53,13 @@ const ( ) const ( - algoBcrypt = "bcrypt" - algoScrypt = "scrypt" - algoArgon2 = "argon2" - algoPbkdf2 = "pbkdf2" + algoBcrypt = "bcrypt" + algoScrypt = "scrypt" + formatScrypt = "$%s$%d$%d$%d$%d$%x" + algoArgon2 = "argon2" + formatArgon2 = "$%s$%d$%d$%d$%d$%x" + algoPbkdf2 = "pbkdf2" + formatPbkdf2 = "$%s$%d$%d$%x" // EmailNotificationsEnabled indicates that the user would like to receive all email notifications EmailNotificationsEnabled = "enabled" @@ -374,24 +378,137 @@ func (u *User) NewGitSig() *git.Signature { } } -func hashPassword(passwd, salt, algo string) string { +func hashPasswordBcrypt(passwd string, params structs.CryptBCrypt) (string, string) { var tempPasswd []byte + tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), params.Cost) + return string(tempPasswd), + string(tempPasswd) +} +func hashPasswordScrypt(passwd string, salt string, params structs.CryptSCrypt) (string, string) { + var tempPasswd []byte + tempPasswd, _ = scrypt.Key([]byte(passwd), []byte(salt), params.N, params.R, params.P, params.KeyLength) + return fmt.Sprintf(formatScrypt, algoScrypt, params.N, params.R, params.P, params.KeyLength, tempPasswd), + fmt.Sprintf("%x", tempPasswd) +} +func hashPasswordArgon2(passwd string, salt string, params structs.CryptArgon2) (string, string) { + var tempPasswd = argon2.IDKey([]byte(passwd), []byte(salt), params.Iterations, params.Memory, params.Parallelism, params.KeyLength) + return fmt.Sprintf(formatArgon2, algoArgon2, params.Memory, params.Iterations, params.Parallelism, params.KeyLength, tempPasswd), + fmt.Sprintf("%x", tempPasswd) +} +func hashPasswordPbkdf2(passwd string, salt string, params structs.CryptPbkdf2) (string, string) { + var tempPasswd = pbkdf2.Key([]byte(passwd), []byte(salt), params.Iterations, params.KeyLength, sha256.New) + return fmt.Sprintf(formatPbkdf2, algoPbkdf2, params.Iterations, params.KeyLength, tempPasswd), + fmt.Sprintf("%x", tempPasswd) +} +func hashPasswordWithConfig(passwd, salt, algo string) (string, string) { switch algo { case algoBcrypt: - tempPasswd, _ = bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost) - return string(tempPasswd) + return hashPasswordBcrypt(passwd, setting.BCryptParams) case algoScrypt: - tempPasswd, _ = scrypt.Key([]byte(passwd), []byte(salt), 65536, 16, 2, 50) + return hashPasswordScrypt(passwd, salt, setting.SCryptParams) case algoArgon2: - tempPasswd = argon2.IDKey([]byte(passwd), []byte(salt), 2, 65536, 8, 50) + return hashPasswordArgon2(passwd, salt, setting.Argon2Params) case algoPbkdf2: fallthrough default: - tempPasswd = pbkdf2.Key([]byte(passwd), []byte(salt), 10000, 50, sha256.New) + return hashPasswordPbkdf2(passwd, salt, setting.Pbkdf2Params) } +} +func hashPasswordWithFallbackDefaults(passwd, salt, algo string) (string, string) { + switch algo { + case algoBcrypt: + return hashPasswordBcrypt(passwd, structs.BCryptFallback) + case algoScrypt: + return hashPasswordScrypt(passwd, salt, structs.SCryptFallback) + case algoArgon2: + return hashPasswordArgon2(passwd, salt, structs.Argon2Fallback) + case algoPbkdf2: + fallthrough + default: + return hashPasswordPbkdf2(passwd, salt, structs.Pbkdf2Fallback) + } +} + +func hashPasswordWithCurrentDefaults(passwd string, u *User) (string, string, bool) { + currentPasswd := u.Passwd + salt := u.Salt + algo := u.PasswdHashAlgo + split := strings.Split(currentPasswd, "$") + + var hash string + var matchesActiveSettings bool + + switch algo { + case algoBcrypt: + cost, _ := strconv.Atoi(split[2]) + + params := structs.CryptBCrypt{ + Cost: cost, + } + matchesActiveSettings = u.PasswdHashAlgo == algo && setting.BCryptParams == params + hash, _ = hashPasswordBcrypt(passwd, params) + + case algoScrypt: + var n, r, p, keyLength int + + n, _ = strconv.Atoi(split[2]) + r, _ = strconv.Atoi(split[3]) + p, _ = strconv.Atoi(split[4]) + keyLength, _ = strconv.Atoi(split[5]) + + params := structs.CryptSCrypt{ + N: n, + R: r, + P: p, + KeyLength: keyLength, + } + matchesActiveSettings = u.PasswdHashAlgo == algo && setting.SCryptParams == params + hash, _ = hashPasswordScrypt(passwd, salt, params) - return fmt.Sprintf("%x", tempPasswd) + case algoArgon2: + var iterations, memory, keyLength uint32 + var parallelism uint8 + var tempInt int + + tempInt, _ = strconv.Atoi(split[2]) + memory = uint32(tempInt) + + tempInt, _ = strconv.Atoi(split[3]) + iterations = uint32(tempInt) + + tempInt, _ = strconv.Atoi(split[4]) + parallelism = uint8(tempInt) + + tempInt, _ = strconv.Atoi(split[5]) + keyLength = uint32(tempInt) + + params := structs.CryptArgon2{ + Iterations: iterations, + Memory: memory, + Parallelism: parallelism, + KeyLength: keyLength, + } + matchesActiveSettings = u.PasswdHashAlgo == algo && setting.Argon2Params == params + hash, _ = hashPasswordArgon2(passwd, salt, params) + + case algoPbkdf2: + fallthrough + default: + var iterations, keyLength int + + iterations, _ = strconv.Atoi(split[2]) + keyLength, _ = strconv.Atoi(split[3]) + + params := structs.CryptPbkdf2{ + Iterations: iterations, + KeyLength: keyLength, + } + matchesActiveSettings = u.PasswdHashAlgo == algo && setting.Pbkdf2Params == params + hash, _ = hashPasswordPbkdf2(passwd, salt, params) + } + + return hash, algo, matchesActiveSettings } // SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO @@ -407,23 +524,42 @@ func (u *User) SetPassword(passwd string) (err error) { if u.Salt, err = GetUserSalt(); err != nil { return err } + + // Force algo Update u.PasswdHashAlgo = setting.PasswordHashAlgo - u.Passwd = hashPassword(passwd, u.Salt, setting.PasswordHashAlgo) + u.Passwd, _ = hashPasswordWithConfig(passwd, u.Salt, u.PasswdHashAlgo) return nil } -// ValidatePassword checks if given password matches the one belongs to the user. +// ValidatePassword checks if given password matches the one belonging to the user. func (u *User) ValidatePassword(passwd string) bool { - tempHash := hashPassword(passwd, u.Salt, u.PasswdHashAlgo) + var tempHash, algo string + var matchesSettings bool + if u.Passwd[:1] == "$" { + // Hash with known settings + tempHash, algo, matchesSettings = hashPasswordWithCurrentDefaults(passwd, u) + } else { + // Hash with defaults + algo = u.PasswdHashAlgo + matchesSettings = false // always false, because new password format should be enforced + _, tempHash = hashPasswordWithFallbackDefaults(passwd, u.Salt, u.PasswdHashAlgo) + } - if u.PasswdHashAlgo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 { - return true + matches := false + if algo != algoBcrypt && subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(tempHash)) == 1 { + matches = true } - if u.PasswdHashAlgo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil { - return true + if algo == algoBcrypt && bcrypt.CompareHashAndPassword([]byte(u.Passwd), []byte(passwd)) == nil { + matches = true } - return false + + if matches && setting.PasswordUpdateAlgoToCurrent && (!matchesSettings || algo != setting.PasswordHashAlgo) { + // @todo: need to handle this error? As far as I can see, passwd is always set at this point... + _ = u.SetPassword(passwd) + } + + return matches } // IsPasswordSet checks if the password is set or left empty diff --git a/models/user_test.go b/models/user_test.go index ac40015969aed..089a33af61251 100644 --- a/models/user_test.go +++ b/models/user_test.go @@ -136,13 +136,13 @@ func TestSearchUsers(t *testing.T) { } testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}}, - []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30}) + []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 31}) testUserSuccess(&SearchUserOptions{ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolFalse}, []int64{9}) testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue}, - []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28, 29, 30}) + []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28, 29, 30, 31}) testUserSuccess(&SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue}, []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) @@ -243,6 +243,66 @@ func TestHashPasswordDeterministic(t *testing.T) { } } +func TestOldPasswordMatchAndUpdate(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + u := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) + + setting.PasswordHashAlgo = algoArgon2 + + matchingPass := "password" + oldPass := u.Passwd + + // Without update function + setting.PasswordUpdateAlgoToCurrent = false + validates := u.ValidatePassword(matchingPass) + newPass := u.Passwd + // Should match with old algo + assert.True(t, validates) + // Should not be updated to new format + assert.Equal(t, oldPass, newPass) + + // With update function + setting.PasswordUpdateAlgoToCurrent = true + validates = u.ValidatePassword(matchingPass) + newPass = u.Passwd + + // Should match with old algo + assert.True(t, validates) + // Should be updated to new format + assert.NotEqual(t, oldPass, newPass) +} + +func TestNewPasswordMatch(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + u := AssertExistsAndLoadBean(t, &User{ID: 31}).(*User) + + setting.PasswordHashAlgo = algoArgon2 + + matchingPass := "password" + oldPass := u.Passwd + + // Without update function + setting.PasswordUpdateAlgoToCurrent = false + validates := u.ValidatePassword(matchingPass) + newPass := u.Passwd + + // Should match + assert.True(t, validates) + // Should not be updated + assert.Equal(t, oldPass, newPass) + + // With update function and different default HashAlgo + setting.PasswordUpdateAlgoToCurrent = true + setting.PasswordHashAlgo = algoBcrypt + passwordMatches := u.ValidatePassword(matchingPass) + newPass = u.Passwd + + // Should match + assert.True(t, passwordMatches) + // Should not be updated + assert.NotEqual(t, oldPass, newPass) +} + func BenchmarkHashPassword(b *testing.B) { // BenchmarkHashPassword ensures that it takes a reasonable amount of time // to hash a password - in order to protect from brute-force attacks. diff --git a/modules/setting/setting.go b/modules/setting/setting.go index be7ec16e10cc1..f2dd4e3f415a2 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -25,11 +25,13 @@ import ( "code.gitea.io/gitea/modules/generate" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/util" shellquote "github.com/kballard/go-shellquote" "github.com/unknwon/com" + "golang.org/x/crypto/bcrypt" gossh "golang.org/x/crypto/ssh" ini "gopkg.in/ini.v1" ) @@ -161,6 +163,34 @@ var ( PasswordComplexity []string PasswordHashAlgo string PasswordCheckPwn bool + PasswordUpdateAlgoToCurrent bool + + // BCryptParams stores parameters for bcrypt algo + BCryptParams = structs.CryptBCrypt{ + Cost: bcrypt.DefaultCost, + } + + // SCryptParams stores parameters for scrypt algo + SCryptParams = structs.CryptSCrypt{ + N: 65536, + R: 16, + P: 2, + KeyLength: 50, + } + + // Argon2Params stores params for argon2 algo + Argon2Params = structs.CryptArgon2{ + Iterations: 2, + Memory: 65536, + Parallelism: 8, + KeyLength: 50, + } + + // Pbkdf2Params stores parameters for pbkdf2 algo + Pbkdf2Params = structs.CryptPbkdf2{ + Iterations: 10000, + KeyLength: 50, + } // UI settings UI = struct { diff --git a/modules/structs/crypt.go b/modules/structs/crypt.go new file mode 100644 index 0000000000000..f9ebe94ac8677 --- /dev/null +++ b/modules/structs/crypt.go @@ -0,0 +1,67 @@ +// Copyright 2021 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package structs + +// @todo: are the swagger models needed? + +// CryptBCrypt represents a BCrypt parameter set +// swagger:model +type CryptBCrypt struct { + Cost int +} + +// CryptSCrypt represents a SCrypt parameter set +// swagger:model +type CryptSCrypt struct { + N int + R int + P int + KeyLength int +} + +// CryptArgon2 represents a Argon2 parameter set +// swagger:model +type CryptArgon2 struct { + Iterations uint32 + Memory uint32 + Parallelism uint8 + KeyLength uint32 +} + +// CryptPbkdf2 represents a Pbkdf2 parameter set +// swagger:model +type CryptPbkdf2 struct { + Iterations int + KeyLength int +} + +var ( + // BCryptFallback stores parameters for bcrypt algo + BCryptFallback = CryptBCrypt{ + Cost: 10, + } + + // SCryptFallback stores parameters for scrypt algo + SCryptFallback = CryptSCrypt{ + N: 65536, + R: 16, + P: 2, + KeyLength: 50, + } + + // Argon2Fallback stores params for argon2 algo + Argon2Fallback = CryptArgon2{ + Iterations: 2, + Memory: 65536, + Parallelism: 8, + KeyLength: 50, + } + + // Pbkdf2Fallback stores parameters for pbkdf2 algo + Pbkdf2Fallback = CryptPbkdf2{ + Iterations: 10000, + KeyLength: 50, + } +)