diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
commit | dd136858f1ea40ad3c94191d647487fa4f31926c (patch) | |
tree | 58fec94a7b2a12510c9664b21793f1ed560c6518 /modules/auth | |
parent | Initial commit. (diff) | |
download | forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip |
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
25 files changed, 1635 insertions, 0 deletions
diff --git a/modules/auth/common.go b/modules/auth/common.go new file mode 100644 index 0000000..77361f6 --- /dev/null +++ b/modules/auth/common.go @@ -0,0 +1,22 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" +) + +func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, error) { + groupTeamMapping := make(map[string]map[string][]string) + if raw == "" { + return groupTeamMapping, nil + } + err := json.Unmarshal([]byte(raw), &groupTeamMapping) + if err != nil { + log.Error("Failed to unmarshal group team mapping: %v", err) + return nil, err + } + return groupTeamMapping, nil +} diff --git a/modules/auth/openid/discovery_cache.go b/modules/auth/openid/discovery_cache.go new file mode 100644 index 0000000..3a8d119 --- /dev/null +++ b/modules/auth/openid/discovery_cache.go @@ -0,0 +1,57 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openid + +import ( + "sync" + "time" + + "github.com/yohcop/openid-go" +) + +type timedDiscoveredInfo struct { + info openid.DiscoveredInfo + time time.Time +} + +type timedDiscoveryCache struct { + cache map[string]timedDiscoveredInfo + ttl time.Duration + mutex *sync.Mutex +} + +func newTimedDiscoveryCache(ttl time.Duration) *timedDiscoveryCache { + return &timedDiscoveryCache{cache: map[string]timedDiscoveredInfo{}, ttl: ttl, mutex: &sync.Mutex{}} +} + +func (s *timedDiscoveryCache) Put(id string, info openid.DiscoveredInfo) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.cache[id] = timedDiscoveredInfo{info: info, time: time.Now()} +} + +// Delete timed-out cache entries +func (s *timedDiscoveryCache) cleanTimedOut() { + now := time.Now() + for k, e := range s.cache { + diff := now.Sub(e.time) + if diff > s.ttl { + delete(s.cache, k) + } + } +} + +func (s *timedDiscoveryCache) Get(id string) openid.DiscoveredInfo { + s.mutex.Lock() + defer s.mutex.Unlock() + + // Delete old cached while we are at it. + s.cleanTimedOut() + + if info, has := s.cache[id]; has { + return info.info + } + return nil +} diff --git a/modules/auth/openid/discovery_cache_test.go b/modules/auth/openid/discovery_cache_test.go new file mode 100644 index 0000000..5a7f450 --- /dev/null +++ b/modules/auth/openid/discovery_cache_test.go @@ -0,0 +1,49 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openid + +import ( + "testing" + "time" +) + +type testDiscoveredInfo struct{} + +func (s *testDiscoveredInfo) ClaimedID() string { + return "claimedID" +} + +func (s *testDiscoveredInfo) OpEndpoint() string { + return "opEndpoint" +} + +func (s *testDiscoveredInfo) OpLocalID() string { + return "opLocalID" +} + +func TestTimedDiscoveryCache(t *testing.T) { + dc := newTimedDiscoveryCache(1 * time.Second) + + // Put some initial values + dc.Put("foo", &testDiscoveredInfo{}) // openid.opEndpoint: "a", openid.opLocalID: "b", openid.claimedID: "c"}) + + // Make sure we can retrieve them + if di := dc.Get("foo"); di == nil { + t.Errorf("Expected a result, got nil") + } else if di.OpEndpoint() != "opEndpoint" || di.OpLocalID() != "opLocalID" || di.ClaimedID() != "claimedID" { + t.Errorf("Expected opEndpoint opLocalID claimedID, got %v %v %v", di.OpEndpoint(), di.OpLocalID(), di.ClaimedID()) + } + + // Attempt to get a non-existent value + if di := dc.Get("bar"); di != nil { + t.Errorf("Expected nil, got %v", di) + } + + // Sleep one second and try retrieve again + time.Sleep(1 * time.Second) + + if di := dc.Get("foo"); di != nil { + t.Errorf("Expected a nil, got a result") + } +} diff --git a/modules/auth/openid/openid.go b/modules/auth/openid/openid.go new file mode 100644 index 0000000..249ce02 --- /dev/null +++ b/modules/auth/openid/openid.go @@ -0,0 +1,37 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openid + +import ( + "time" + + "github.com/yohcop/openid-go" +) + +// For the demo, we use in-memory infinite storage nonce and discovery +// cache. In your app, do not use this as it will eat up memory and +// never +// free it. Use your own implementation, on a better database system. +// If you have multiple servers for example, you may need to share at +// least +// the nonceStore between them. +var ( + nonceStore = openid.NewSimpleNonceStore() + discoveryCache = newTimedDiscoveryCache(24 * time.Hour) +) + +// Verify handles response from OpenID provider +func Verify(fullURL string) (id string, err error) { + return openid.Verify(fullURL, discoveryCache, nonceStore) +} + +// Normalize normalizes an OpenID URI +func Normalize(url string) (id string, err error) { + return openid.Normalize(url) +} + +// RedirectURL redirects browser +func RedirectURL(id, callbackURL, realm string) (string, error) { + return openid.RedirectURL(id, callbackURL, realm) +} diff --git a/modules/auth/pam/pam.go b/modules/auth/pam/pam.go new file mode 100644 index 0000000..cca1482 --- /dev/null +++ b/modules/auth/pam/pam.go @@ -0,0 +1,43 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build pam + +package pam + +import ( + "errors" + + "github.com/msteinert/pam" +) + +// Supported is true when built with PAM +var Supported = true + +// Auth pam auth service +func Auth(serviceName, userName, passwd string) (string, error) { + t, err := pam.StartFunc(serviceName, userName, func(s pam.Style, msg string) (string, error) { + switch s { + case pam.PromptEchoOff: + return passwd, nil + case pam.PromptEchoOn, pam.ErrorMsg, pam.TextInfo: + return "", nil + } + return "", errors.New("Unrecognized PAM message style") + }) + if err != nil { + return "", err + } + + if err = t.Authenticate(0); err != nil { + return "", err + } + + if err = t.AcctMgmt(0); err != nil { + return "", err + } + + // PAM login names might suffer transformations in the PAM stack. + // We should take whatever the PAM stack returns for it. + return t.GetItem(pam.User) +} diff --git a/modules/auth/pam/pam_stub.go b/modules/auth/pam/pam_stub.go new file mode 100644 index 0000000..3631eee --- /dev/null +++ b/modules/auth/pam/pam_stub.go @@ -0,0 +1,22 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !pam + +package pam + +import ( + "errors" +) + +// Supported is false when built without PAM +var Supported = false + +// Auth not supported lack of pam tag +func Auth(serviceName, userName, passwd string) (string, error) { + // bypass the lint on callers: SA4023: this comparison is always true (staticcheck) + if !Supported { + return "", errors.New("PAM not supported") + } + return "", nil +} diff --git a/modules/auth/pam/pam_test.go b/modules/auth/pam/pam_test.go new file mode 100644 index 0000000..e9b844e --- /dev/null +++ b/modules/auth/pam/pam_test.go @@ -0,0 +1,20 @@ +//go:build pam + +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pam + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPamAuth(t *testing.T) { + result, err := Auth("gitea", "user1", "false-pwd") + require.Error(t, err) + assert.EqualError(t, err, "Authentication failure") + assert.Len(t, result, 0) +} diff --git a/modules/auth/password/hash/argon2.go b/modules/auth/password/hash/argon2.go new file mode 100644 index 0000000..0cd6472 --- /dev/null +++ b/modules/auth/password/hash/argon2.go @@ -0,0 +1,80 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/argon2" +) + +func init() { + MustRegister("argon2", NewArgon2Hasher) +} + +// Argon2Hasher implements PasswordHasher +// and uses the Argon2 key derivation function, hybrant variant +type Argon2Hasher struct { + time uint32 + memory uint32 + threads uint8 + keyLen uint32 +} + +// HashWithSaltBytes a provided password and salt +func (hasher *Argon2Hasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + return hex.EncodeToString(argon2.IDKey([]byte(password), salt, hasher.time, hasher.memory, hasher.threads, hasher.keyLen)) +} + +// NewArgon2Hasher is a factory method to create an Argon2Hasher +// The provided config should be either empty or of the form: +// "<time>$<memory>$<threads>$<keyLen>", where <x> is the string representation +// of an integer +func NewArgon2Hasher(config string) *Argon2Hasher { + // This default configuration uses the following parameters: + // time=2, memory=64*1024, threads=8, keyLen=50. + // It will make two passes through the memory, using 64MiB in total. + // This matches the original configuration for `argon2` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &Argon2Hasher{ + time: 2, + memory: 1 << 16, + threads: 8, + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 4) + if len(vals) != 4 { + log.Error("invalid argon2 hash spec %s", config) + return nil + } + + parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil) + hasher.time = uint32(parsed) + + parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err) + hasher.memory = uint32(parsed) + + parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err) + hasher.threads = uint8(parsed) + + parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err) + hasher.keyLen = uint32(parsed) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/bcrypt.go b/modules/auth/password/hash/bcrypt.go new file mode 100644 index 0000000..4607c16 --- /dev/null +++ b/modules/auth/password/hash/bcrypt.go @@ -0,0 +1,54 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "golang.org/x/crypto/bcrypt" +) + +func init() { + MustRegister("bcrypt", NewBcryptHasher) +} + +// BcryptHasher implements PasswordHasher +// and uses the bcrypt password hash function. +type BcryptHasher struct { + cost int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *BcryptHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), hasher.cost) + return string(hashedPassword) +} + +func (hasher *BcryptHasher) VerifyPassword(password, hashedPassword, salt string) bool { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil +} + +// NewBcryptHasher is a factory method to create an BcryptHasher +// The provided config should be either empty or the string representation of the "<cost>" +// as an integer +func NewBcryptHasher(config string) *BcryptHasher { + // This matches the original configuration for `bcrypt` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &BcryptHasher{ + cost: 10, // cost=10. i.e. 2^10 rounds of key expansion. + } + + if config == "" { + return hasher + } + var err error + hasher.cost, err = parseIntParam(config, "cost", "bcrypt", config, nil) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/common.go b/modules/auth/password/hash/common.go new file mode 100644 index 0000000..487c073 --- /dev/null +++ b/modules/auth/password/hash/common.go @@ -0,0 +1,28 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "strconv" + + "code.gitea.io/gitea/modules/log" +) + +func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) { + parsed, err := strconv.Atoi(value) + if err != nil { + log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config) + return 0, err + } + return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed +} + +func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) { //nolint:unparam + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config) + return 0, err + } + return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed +} diff --git a/modules/auth/password/hash/dummy.go b/modules/auth/password/hash/dummy.go new file mode 100644 index 0000000..22f2e2f --- /dev/null +++ b/modules/auth/password/hash/dummy.go @@ -0,0 +1,33 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" +) + +// DummyHasher implements PasswordHasher and is a dummy hasher that simply +// puts the password in place with its salt +// This SHOULD NOT be used in production and is provided to make the integration +// tests faster only +type DummyHasher struct{} + +// HashWithSaltBytes a provided password and salt +func (hasher *DummyHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + + if len(salt) == 10 { + return string(salt) + ":" + password + } + + return hex.EncodeToString(salt) + ":" + password +} + +// NewDummyHasher is a factory method to create a DummyHasher +// Any provided configuration is ignored +func NewDummyHasher(_ string) *DummyHasher { + return &DummyHasher{} +} diff --git a/modules/auth/password/hash/dummy_test.go b/modules/auth/password/hash/dummy_test.go new file mode 100644 index 0000000..35d1249 --- /dev/null +++ b/modules/auth/password/hash/dummy_test.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDummyHasher(t *testing.T) { + dummy := &PasswordHashAlgorithm{ + PasswordSaltHasher: NewDummyHasher(""), + Specification: "dummy", + } + + password, salt := "password", "ZogKvWdyEx" + + hash, err := dummy.Hash(password, salt) + require.NoError(t, err) + assert.Equal(t, hash, salt+":"+password) + + assert.True(t, dummy.VerifyPassword(password, hash, salt)) +} diff --git a/modules/auth/password/hash/hash.go b/modules/auth/password/hash/hash.go new file mode 100644 index 0000000..459320e --- /dev/null +++ b/modules/auth/password/hash/hash.go @@ -0,0 +1,189 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "crypto/subtle" + "encoding/hex" + "fmt" + "strings" + "sync/atomic" + + "code.gitea.io/gitea/modules/log" +) + +// This package takes care of hashing passwords, verifying passwords, defining +// available password algorithms, defining recommended password algorithms and +// choosing the default password algorithm. + +// PasswordSaltHasher will hash a provided password with the provided saltBytes +type PasswordSaltHasher interface { + HashWithSaltBytes(password string, saltBytes []byte) string +} + +// PasswordHasher will hash a provided password with the salt +type PasswordHasher interface { + Hash(password, salt string) (string, error) +} + +// PasswordVerifier will ensure that a providedPassword matches the hashPassword when hashed with the salt +type PasswordVerifier interface { + VerifyPassword(providedPassword, hashedPassword, salt string) bool +} + +// PasswordHashAlgorithms are named PasswordSaltHashers with a default verifier and hash function +type PasswordHashAlgorithm struct { + PasswordSaltHasher + Specification string // The specification that is used to create the internal PasswordSaltHasher +} + +// Hash the provided password with the salt and return the hash +func (algorithm *PasswordHashAlgorithm) Hash(password, salt string) (string, error) { + var saltBytes []byte + + // There are two formats for the salt value: + // * The new format is a (32+)-byte hex-encoded string + // * The old format was a 10-byte binary format + // We have to tolerate both here. + if len(salt) == 10 { + saltBytes = []byte(salt) + } else { + var err error + saltBytes, err = hex.DecodeString(salt) + if err != nil { + return "", err + } + } + + return algorithm.HashWithSaltBytes(password, saltBytes), nil +} + +// Verify the provided password matches the hashPassword when hashed with the salt +func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool { + // Some PasswordSaltHashers have their own specialised compare function that takes into + // account the stored parameters within the hash. e.g. bcrypt + if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok { + return verifier.VerifyPassword(providedPassword, hashedPassword, salt) + } + + // Compute the hash of the password. + providedPasswordHash, err := algorithm.Hash(providedPassword, salt) + if err != nil { + log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err) + return false + } + + // Compare it against the hashed password in constant-time. + return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1 +} + +var ( + lastNonDefaultAlgorithm atomic.Value + availableHasherFactories = map[string]func(string) PasswordSaltHasher{} +) + +// MustRegister registers a PasswordSaltHasher with the availableHasherFactories +// Caution: This is not thread safe. +func MustRegister[T PasswordSaltHasher](name string, newFn func(config string) T) { + if err := Register(name, newFn); err != nil { + panic(err) + } +} + +// Register registers a PasswordSaltHasher with the availableHasherFactories +// Caution: This is not thread safe. +func Register[T PasswordSaltHasher](name string, newFn func(config string) T) error { + if _, has := availableHasherFactories[name]; has { + return fmt.Errorf("duplicate registration of password salt hasher: %s", name) + } + + availableHasherFactories[name] = func(config string) PasswordSaltHasher { + n := newFn(config) + return n + } + return nil +} + +// In early versions of gitea the password hash algorithm field of a user could be +// empty. At that point the default was `pbkdf2` without configuration values +// +// Please note this is not the same as the DefaultAlgorithm which is used +// to determine what an empty PASSWORD_HASH_ALGO setting in the app.ini means. +// These are not the same even if they have the same apparent value and they mean different things. +// +// DO NOT COALESCE THESE VALUES +const defaultEmptyHashAlgorithmSpecification = "pbkdf2" + +// Parse will convert the provided algorithm specification in to a PasswordHashAlgorithm +// If the provided specification matches the DefaultHashAlgorithm Specification it will be +// used. +// In addition the last non-default hasher will be cached to help reduce the load from +// parsing specifications. +// +// NOTE: No de-aliasing is done in this function, thus any specification which does not +// contain a configuration will use the default values for that hasher. These are not +// necessarily the same values as those obtained by dealiasing. This allows for +// seamless backwards compatibility with the original configuration. +// +// To further labour this point, running `Parse("pbkdf2")` does not obtain the +// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to. +// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm +// Users will be migrated automatically as they log-in to have the complete specification stored +// in their `password_hash_algo` fields by other code. +func Parse(algorithmSpec string) *PasswordHashAlgorithm { + if algorithmSpec == "" { + algorithmSpec = defaultEmptyHashAlgorithmSpecification + } + + if DefaultHashAlgorithm != nil && algorithmSpec == DefaultHashAlgorithm.Specification { + return DefaultHashAlgorithm + } + + ptr := lastNonDefaultAlgorithm.Load() + if ptr != nil { + hashAlgorithm, ok := ptr.(*PasswordHashAlgorithm) + if ok && hashAlgorithm.Specification == algorithmSpec { + return hashAlgorithm + } + } + + // Now convert the provided specification in to a hasherType +/- some configuration parameters + vals := strings.SplitN(algorithmSpec, "$", 2) + var hasherType string + var config string + + if len(vals) == 0 { + // This should not happen as algorithmSpec should not be empty + // due to it being assigned to defaultEmptyHashAlgorithmSpecification above + // but we should be absolutely cautious here + return nil + } + + hasherType = vals[0] + if len(vals) > 1 { + config = vals[1] + } + + newFn, has := availableHasherFactories[hasherType] + if !has { + // unknown hasher type + return nil + } + + ph := newFn(config) + if ph == nil { + // The provided configuration is likely invalid - it will have been logged already + // but we cannot hash safely + return nil + } + + hashAlgorithm := &PasswordHashAlgorithm{ + PasswordSaltHasher: ph, + Specification: algorithmSpec, + } + + lastNonDefaultAlgorithm.Store(hashAlgorithm) + + return hashAlgorithm +} diff --git a/modules/auth/password/hash/hash_test.go b/modules/auth/password/hash/hash_test.go new file mode 100644 index 0000000..03d08a8 --- /dev/null +++ b/modules/auth/password/hash/hash_test.go @@ -0,0 +1,191 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testSaltHasher string + +func (t testSaltHasher) HashWithSaltBytes(password string, salt []byte) string { + return password + "$" + string(salt) + "$" + string(t) +} + +func Test_registerHasher(t *testing.T) { + MustRegister("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + }) + + assert.Panics(t, func() { + MustRegister("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + }) + }) + + require.Error(t, Register("Test_registerHasher", func(config string) testSaltHasher { + return testSaltHasher(config) + })) + + assert.Equal(t, "password$salt$", + Parse("Test_registerHasher").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt"))) + + assert.Equal(t, "password$salt$config", + Parse("Test_registerHasher$config").PasswordSaltHasher.HashWithSaltBytes("password", []byte("salt"))) + + delete(availableHasherFactories, "Test_registerHasher") +} + +func TestParse(t *testing.T) { + hashAlgorithmsToTest := []string{} + for plainHashAlgorithmNames := range availableHasherFactories { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames) + } + for _, aliased := range aliasAlgorithmNames { + if strings.Contains(aliased, "$") { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased) + } + } + for _, algorithmName := range hashAlgorithmsToTest { + t.Run(algorithmName, func(t *testing.T) { + algo := Parse(algorithmName) + assert.NotNil(t, algo, "Algorithm %s resulted in an empty algorithm", algorithmName) + }) + } +} + +func TestHashing(t *testing.T) { + hashAlgorithmsToTest := []string{} + for plainHashAlgorithmNames := range availableHasherFactories { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, plainHashAlgorithmNames) + } + for _, aliased := range aliasAlgorithmNames { + if strings.Contains(aliased, "$") { + hashAlgorithmsToTest = append(hashAlgorithmsToTest, aliased) + } + } + + runTests := func(password, salt string, shouldPass bool) { + for _, algorithmName := range hashAlgorithmsToTest { + t.Run(algorithmName, func(t *testing.T) { + output, err := Parse(algorithmName).Hash(password, salt) + if shouldPass { + require.NoError(t, err) + assert.NotEmpty(t, output, "output for %s was empty", algorithmName) + } else { + require.Error(t, err) + } + + assert.Equal(t, Parse(algorithmName).VerifyPassword(password, output, salt), shouldPass) + }) + } + } + + // Test with new salt format. + runTests(strings.Repeat("a", 16), hex.EncodeToString([]byte{0x01, 0x02, 0x03}), true) + + // Test with legacy salt format. + runTests(strings.Repeat("a", 16), strings.Repeat("b", 10), true) + + // Test with invalid salt. + runTests(strings.Repeat("a", 16), "a", false) +} + +// vectors were generated using the current codebase. +var vectors = []struct { + algorithms []string + password string + salt string + output string + shouldfail bool +}{ + { + algorithms: []string{"bcrypt", "bcrypt$10"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "$2a$10$fjtm8BsQ2crym01/piJroenO3oSVUBhSLKaGdTYJ4tG0ePVCrU0G2", + shouldfail: false, + }, + { + algorithms: []string{"scrypt", "scrypt$65536$16$2$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "3b571d0c07c62d42b7bad3dbf18fb0cd67d4d8cd4ad4c6928e1090e5b2a4a84437c6fd2627d897c0e7e65025ca62b67a0002", + shouldfail: false, + }, + { + algorithms: []string{"argon2", "argon2$2$65536$8$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "551f089f570f989975b6f7c6a8ff3cf89bc486dd7bbe87ed4d80ad4362f8ee599ec8dda78dac196301b98456402bcda775dc", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: strings.Repeat("a", 10), + output: "ab48d5471b7e6ed42d10001db88c852ff7303c788e49da5c3c7b63d5adf96360303724b74b679223a3dea8a242d10abb1913", + shouldfail: false, + }, + { + algorithms: []string{"bcrypt", "bcrypt$10"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "$2a$10$qhgm32w9ZpqLygugWJsLjey8xRGcaq9iXAfmCeNBXxddgyoaOC3Gq", + shouldfail: false, + }, + { + algorithms: []string{"scrypt", "scrypt$65536$16$2$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "25fe5f66b43fa4eb7b6717905317cd2223cf841092dc8e0a1e8c75720ad4846cb5d9387303e14bc3c69faa3b1c51ef4b7de1", + shouldfail: false, + }, + { + algorithms: []string{"argon2", "argon2$2$65536$8$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "9c287db63a91d18bb1414b703216da4fc431387c1ae7c8acdb280222f11f0929831055dbfd5126a3b48566692e83ec750d2a", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "45d6cdc843d65cf0eda7b90ab41435762a282f7df013477a1c5b212ba81dbdca2edf1ecc4b5cb05956bb9e0c37ab29315d78", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2$320000$50"}, + password: "abcdef", + salt: hex.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04}), + output: "84e233114499e8721da80e85568e5b7b5900b3e49a30845fcda9d1e1756da4547d70f8740ac2b4a5d82f88cebcd27f21bfe2", + shouldfail: false, + }, + { + algorithms: []string{"pbkdf2", "pbkdf2$10000$50"}, + password: "abcdef", + salt: "", + output: "", + shouldfail: true, + }, +} + +// Ensure that the current code will correctly verify against the test vectors. +func TestVectors(t *testing.T) { + for i, vector := range vectors { + for _, algorithm := range vector.algorithms { + t.Run(strconv.Itoa(i)+": "+algorithm, func(t *testing.T) { + pa := Parse(algorithm) + assert.Equal(t, !vector.shouldfail, pa.VerifyPassword(vector.password, vector.output, vector.salt)) + }) + } + } +} diff --git a/modules/auth/password/hash/pbkdf2.go b/modules/auth/password/hash/pbkdf2.go new file mode 100644 index 0000000..27382fe --- /dev/null +++ b/modules/auth/password/hash/pbkdf2.go @@ -0,0 +1,67 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/pbkdf2" +) + +func init() { + MustRegister("pbkdf2", NewPBKDF2Hasher) +} + +// PBKDF2Hasher implements PasswordHasher +// and uses the PBKDF2 key derivation function. +type PBKDF2Hasher struct { + iter, keyLen int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *PBKDF2Hasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + return hex.EncodeToString(pbkdf2.Key([]byte(password), salt, hasher.iter, hasher.keyLen, sha256.New)) +} + +// NewPBKDF2Hasher is a factory method to create an PBKDF2Hasher +// config should be either empty or of the form: +// "<iter>$<keyLen>", where <x> is the string representation +// of an integer +func NewPBKDF2Hasher(config string) *PBKDF2Hasher { + // This default configuration uses the following parameters: + // iter=10000, keyLen=50. + // This matches the original configuration for `pbkdf2` prior to storing parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &PBKDF2Hasher{ + iter: 10_000, + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 2) + if len(vals) != 2 { + log.Error("invalid pbkdf2 hash spec %s", config) + return nil + } + + var err error + hasher.iter, err = parseIntParam(vals[0], "iter", "pbkdf2", config, nil) + hasher.keyLen, err = parseIntParam(vals[1], "keyLen", "pbkdf2", config, err) + if err != nil { + return nil + } + + return hasher +} diff --git a/modules/auth/password/hash/scrypt.go b/modules/auth/password/hash/scrypt.go new file mode 100644 index 0000000..f3d38f7 --- /dev/null +++ b/modules/auth/password/hash/scrypt.go @@ -0,0 +1,67 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "encoding/hex" + "strings" + + "code.gitea.io/gitea/modules/log" + + "golang.org/x/crypto/scrypt" +) + +func init() { + MustRegister("scrypt", NewScryptHasher) +} + +// ScryptHasher implements PasswordHasher +// and uses the scrypt key derivation function. +type ScryptHasher struct { + n, r, p, keyLen int +} + +// HashWithSaltBytes a provided password and salt +func (hasher *ScryptHasher) HashWithSaltBytes(password string, salt []byte) string { + if hasher == nil { + return "" + } + hashedPassword, _ := scrypt.Key([]byte(password), salt, hasher.n, hasher.r, hasher.p, hasher.keyLen) + return hex.EncodeToString(hashedPassword) +} + +// NewScryptHasher is a factory method to create an ScryptHasher +// The provided config should be either empty or of the form: +// "<n>$<r>$<p>$<keyLen>", where <x> is the string representation +// of an integer +func NewScryptHasher(config string) *ScryptHasher { + // This matches the original configuration for `scrypt` prior to storing hash parameters + // in the database. + // THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK + hasher := &ScryptHasher{ + n: 1 << 16, + r: 16, + p: 2, // 2 passes through memory - this default config will use 128MiB in total. + keyLen: 50, + } + + if config == "" { + return hasher + } + + vals := strings.SplitN(config, "$", 4) + if len(vals) != 4 { + log.Error("invalid scrypt hash spec %s", config) + return nil + } + var err error + hasher.n, err = parseIntParam(vals[0], "n", "scrypt", config, nil) + hasher.r, err = parseIntParam(vals[1], "r", "scrypt", config, err) + hasher.p, err = parseIntParam(vals[2], "p", "scrypt", config, err) + hasher.keyLen, err = parseIntParam(vals[3], "keyLen", "scrypt", config, err) + if err != nil { + return nil + } + return hasher +} diff --git a/modules/auth/password/hash/setting.go b/modules/auth/password/hash/setting.go new file mode 100644 index 0000000..05cd36f --- /dev/null +++ b/modules/auth/password/hash/setting.go @@ -0,0 +1,76 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +// DefaultHashAlgorithmName represents the default value of PASSWORD_HASH_ALGO +// configured in app.ini. +// +// It is NOT the same and does NOT map to the defaultEmptyHashAlgorithmSpecification. +// +// It will be dealiased as per aliasAlgorithmNames whereas +// defaultEmptyHashAlgorithmSpecification does not undergo dealiasing. +const DefaultHashAlgorithmName = "pbkdf2_hi" + +var DefaultHashAlgorithm *PasswordHashAlgorithm + +// aliasAlgorithNames provides a mapping between the value of PASSWORD_HASH_ALGO +// configured in the app.ini and the parameters used within the hashers internally. +// +// If it is necessary to change the default parameters for any hasher in future you +// should change these values and not those in argon2.go etc. +var aliasAlgorithmNames = map[string]string{ + "argon2": "argon2$2$65536$8$50", + "bcrypt": "bcrypt$10", + "scrypt": "scrypt$65536$16$2$50", + "pbkdf2": "pbkdf2_v2", // pbkdf2 should default to pbkdf2_v2 + "pbkdf2_v1": "pbkdf2$10000$50", + // The latest PBKDF2 password algorithm is used as the default since it doesn't + // use a lot of memory and is safer to use on less powerful devices. + "pbkdf2_v2": "pbkdf2$50000$50", + // The pbkdf2_hi password algorithm is offered as a stronger alternative to the + // slightly improved pbkdf2_v2 algorithm + "pbkdf2_hi": "pbkdf2$320000$50", +} + +var RecommendedHashAlgorithms = []string{ + "pbkdf2", + "argon2", + "bcrypt", + "scrypt", + "pbkdf2_hi", +} + +// hashAlgorithmToSpec converts an algorithm name or a specification to a full algorithm specification +func hashAlgorithmToSpec(algorithmName string) string { + if algorithmName == "" { + algorithmName = DefaultHashAlgorithmName + } + alias, has := aliasAlgorithmNames[algorithmName] + for has { + algorithmName = alias + alias, has = aliasAlgorithmNames[algorithmName] + } + return algorithmName +} + +// SetDefaultPasswordHashAlgorithm will take a provided algorithmName and de-alias it to +// a complete algorithm specification. +func SetDefaultPasswordHashAlgorithm(algorithmName string) (string, *PasswordHashAlgorithm) { + algoSpec := hashAlgorithmToSpec(algorithmName) + // now we get a full specification, e.g. pbkdf2$50000$50 rather than pbdkf2 + DefaultHashAlgorithm = Parse(algoSpec) + return algoSpec, DefaultHashAlgorithm +} + +// ConfigHashAlgorithm will try to find a "recommended algorithm name" defined by RecommendedHashAlgorithms for config +// This function is not fast and is only used for the installation page +func ConfigHashAlgorithm(algorithm string) string { + algorithm = hashAlgorithmToSpec(algorithm) + for _, recommAlgo := range RecommendedHashAlgorithms { + if algorithm == hashAlgorithmToSpec(recommAlgo) { + return recommAlgo + } + } + return algorithm +} diff --git a/modules/auth/password/hash/setting_test.go b/modules/auth/password/hash/setting_test.go new file mode 100644 index 0000000..548d87c --- /dev/null +++ b/modules/auth/password/hash/setting_test.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hash + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckSettingPasswordHashAlgorithm(t *testing.T) { + t.Run("pbkdf2 is pbkdf2_v2", func(t *testing.T) { + pbkdf2v2Config, pbkdf2v2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2_v2") + pbkdf2Config, pbkdf2Algo := SetDefaultPasswordHashAlgorithm("pbkdf2") + + assert.Equal(t, pbkdf2v2Config, pbkdf2Config) + assert.Equal(t, pbkdf2v2Algo.Specification, pbkdf2Algo.Specification) + }) + + for a, b := range aliasAlgorithmNames { + t.Run(a+"="+b, func(t *testing.T) { + aConfig, aAlgo := SetDefaultPasswordHashAlgorithm(a) + bConfig, bAlgo := SetDefaultPasswordHashAlgorithm(b) + + assert.Equal(t, bConfig, aConfig) + assert.Equal(t, aAlgo.Specification, bAlgo.Specification) + }) + } + + t.Run("pbkdf2_hi is the default when default password hash algorithm is empty", func(t *testing.T) { + emptyConfig, emptyAlgo := SetDefaultPasswordHashAlgorithm("") + pbkdf2hiConfig, pbkdf2hiAlgo := SetDefaultPasswordHashAlgorithm("pbkdf2_hi") + + assert.Equal(t, pbkdf2hiConfig, emptyConfig) + assert.Equal(t, pbkdf2hiAlgo.Specification, emptyAlgo.Specification) + }) +} diff --git a/modules/auth/password/password.go b/modules/auth/password/password.go new file mode 100644 index 0000000..85f9780 --- /dev/null +++ b/modules/auth/password/password.go @@ -0,0 +1,136 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package password + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "html/template" + "math/big" + "strings" + "sync" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" +) + +var ( + ErrComplexity = errors.New("password not complex enough") + ErrMinLength = errors.New("password not long enough") +) + +// complexity contains information about a particular kind of password complexity +type complexity struct { + ValidChars string + TrNameOne string +} + +var ( + matchComplexityOnce sync.Once + validChars string + requiredList []complexity + + charComplexities = map[string]complexity{ + "lower": { + `abcdefghijklmnopqrstuvwxyz`, + "form.password_lowercase_one", + }, + "upper": { + `ABCDEFGHIJKLMNOPQRSTUVWXYZ`, + "form.password_uppercase_one", + }, + "digit": { + `0123456789`, + "form.password_digit_one", + }, + "spec": { + ` !"#$%&'()*+,-./:;<=>?@[\]^_{|}~` + "`", + "form.password_special_one", + }, + } +) + +// NewComplexity for preparation +func NewComplexity() { + matchComplexityOnce.Do(func() { + setupComplexity(setting.PasswordComplexity) + }) +} + +func setupComplexity(values []string) { + if len(values) != 1 || values[0] != "off" { + for _, val := range values { + if complexity, ok := charComplexities[val]; ok { + validChars += complexity.ValidChars + requiredList = append(requiredList, complexity) + } + } + if len(requiredList) == 0 { + // No valid character classes found; use all classes as default + for _, complexity := range charComplexities { + validChars += complexity.ValidChars + requiredList = append(requiredList, complexity) + } + } + } + if validChars == "" { + // No complexities to check; provide a sensible default for password generation + validChars = charComplexities["lower"].ValidChars + charComplexities["upper"].ValidChars + charComplexities["digit"].ValidChars + } +} + +// IsComplexEnough return True if password meets complexity settings +func IsComplexEnough(pwd string) bool { + NewComplexity() + if len(validChars) > 0 { + for _, req := range requiredList { + if !strings.ContainsAny(req.ValidChars, pwd) { + return false + } + } + } + return true +} + +// Generate a random password +func Generate(n int) (string, error) { + NewComplexity() + buffer := make([]byte, n) + max := big.NewInt(int64(len(validChars))) + for { + for j := 0; j < n; j++ { + rnd, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + buffer[j] = validChars[rnd.Int64()] + } + + if err := IsPwned(context.Background(), string(buffer)); err != nil { + if errors.Is(err, ErrIsPwned) { + continue + } + return "", err + } + if IsComplexEnough(string(buffer)) && string(buffer[0]) != " " && string(buffer[n-1]) != " " { + return string(buffer), nil + } + } +} + +// BuildComplexityError builds the error message when password complexity checks fail +func BuildComplexityError(locale translation.Locale) template.HTML { + var buffer bytes.Buffer + buffer.WriteString(locale.TrString("form.password_complexity")) + buffer.WriteString("<ul>") + for _, c := range requiredList { + buffer.WriteString("<li>") + buffer.WriteString(locale.TrString(c.TrNameOne)) + buffer.WriteString("</li>") + } + buffer.WriteString("</ul>") + return template.HTML(buffer.String()) +} diff --git a/modules/auth/password/password_test.go b/modules/auth/password/password_test.go new file mode 100644 index 0000000..1fe3fb5 --- /dev/null +++ b/modules/auth/password/password_test.go @@ -0,0 +1,77 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package password + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComplexity_IsComplexEnough(t *testing.T) { + matchComplexityOnce.Do(func() {}) + + testlist := []struct { + complexity []string + truevalues []string + falsevalues []string + }{ + {[]string{"off"}, []string{"1", "-", "a", "A", "ñ", "日本語"}, []string{}}, + {[]string{"lower"}, []string{"abc", "abc!"}, []string{"ABC", "123", "=!$", ""}}, + {[]string{"upper"}, []string{"ABC"}, []string{"abc", "123", "=!$", "abc!", ""}}, + {[]string{"digit"}, []string{"123"}, []string{"abc", "ABC", "=!$", "abc!", ""}}, + {[]string{"spec"}, []string{"=!$", "abc!"}, []string{"abc", "ABC", "123", ""}}, + {[]string{"off"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}, nil}, + {[]string{"lower", "spec"}, []string{"abc!"}, []string{"abc", "ABC", "123", "=!$", "abcABC123", ""}}, + {[]string{"lower", "upper", "digit"}, []string{"abcABC123"}, []string{"abc", "ABC", "123", "=!$", "abc!", ""}}, + {[]string{""}, []string{"abC=1", "abc!9D"}, []string{"ABC", "123", "=!$", ""}}, + } + + for _, test := range testlist { + testComplextity(test.complexity) + for _, val := range test.truevalues { + assert.True(t, IsComplexEnough(val)) + } + for _, val := range test.falsevalues { + assert.False(t, IsComplexEnough(val)) + } + } + + // Remove settings for other tests + testComplextity([]string{"off"}) +} + +func TestComplexity_Generate(t *testing.T) { + matchComplexityOnce.Do(func() {}) + + const maxCount = 50 + const pwdLen = 50 + + test := func(t *testing.T, modes []string) { + testComplextity(modes) + for i := 0; i < maxCount; i++ { + pwd, err := Generate(pwdLen) + require.NoError(t, err) + assert.Len(t, pwd, pwdLen) + assert.True(t, IsComplexEnough(pwd), "Failed complexities with modes %+v for generated: %s", modes, pwd) + } + } + + test(t, []string{"lower"}) + test(t, []string{"upper"}) + test(t, []string{"lower", "upper", "spec"}) + test(t, []string{"off"}) + test(t, []string{""}) + + // Remove settings for other tests + testComplextity([]string{"off"}) +} + +func testComplextity(values []string) { + // Cleanup previous values + validChars = "" + requiredList = make([]complexity, 0, len(values)) + setupComplexity(values) +} diff --git a/modules/auth/password/pwn.go b/modules/auth/password/pwn.go new file mode 100644 index 0000000..e00205e --- /dev/null +++ b/modules/auth/password/pwn.go @@ -0,0 +1,52 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package password + +import ( + "context" + "errors" + "fmt" + + "code.gitea.io/gitea/modules/auth/password/pwn" + "code.gitea.io/gitea/modules/setting" +) + +var ErrIsPwned = errors.New("password has been pwned") + +type ErrIsPwnedRequest struct { + err error +} + +func IsErrIsPwnedRequest(err error) bool { + _, ok := err.(ErrIsPwnedRequest) + return ok +} + +func (err ErrIsPwnedRequest) Error() string { + return fmt.Sprintf("using Have-I-Been-Pwned service failed: %v", err.err) +} + +func (err ErrIsPwnedRequest) Unwrap() error { + return err.err +} + +// IsPwned checks whether a password has been pwned +// If a password has not been pwned, no error is returned. +func IsPwned(ctx context.Context, password string) error { + if !setting.PasswordCheckPwn { + return nil + } + + client := pwn.New(pwn.WithContext(ctx)) + count, err := client.CheckPassword(password, true) + if err != nil { + return ErrIsPwnedRequest{err} + } + + if count > 0 { + return ErrIsPwned + } + + return nil +} diff --git a/modules/auth/password/pwn/pwn.go b/modules/auth/password/pwn/pwn.go new file mode 100644 index 0000000..f77ce9f --- /dev/null +++ b/modules/auth/password/pwn/pwn.go @@ -0,0 +1,118 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pwn + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/setting" +) + +const passwordURL = "https://api.pwnedpasswords.com/range/" + +// ErrEmptyPassword is an empty password error +var ErrEmptyPassword = errors.New("password cannot be empty") + +// Client is a HaveIBeenPwned client +type Client struct { + ctx context.Context + http *http.Client +} + +// New returns a new HaveIBeenPwned Client +func New(options ...ClientOption) *Client { + client := &Client{ + ctx: context.Background(), + http: http.DefaultClient, + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// ClientOption is a way to modify a new Client +type ClientOption func(*Client) + +// WithHTTP will set the http.Client of a Client +func WithHTTP(httpClient *http.Client) func(pwnClient *Client) { + return func(pwnClient *Client) { + pwnClient.http = httpClient + } +} + +// WithContext will set the context.Context of a Client +func WithContext(ctx context.Context) func(pwnClient *Client) { + return func(pwnClient *Client) { + pwnClient.ctx = ctx + } +} + +func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Add("User-Agent", "Gitea "+setting.AppVer) + return req, nil +} + +// CheckPassword returns the number of times a password has been compromised +// Adding padding will make requests more secure, however is also slower +// because artificial responses will be added to the response +// For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/ +func (c *Client) CheckPassword(pw string, padding bool) (int, error) { + if pw == "" { + return -1, ErrEmptyPassword + } + + sha := sha1.New() + sha.Write([]byte(pw)) + enc := hex.EncodeToString(sha.Sum(nil)) + prefix, suffix := enc[:5], enc[5:] + + req, err := newRequest(c.ctx, http.MethodGet, fmt.Sprintf("%s%s", passwordURL, prefix), nil) + if err != nil { + return -1, nil + } + if padding { + req.Header.Add("Add-Padding", "true") + } + + resp, err := c.http.Do(req) + if err != nil { + return -1, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + for _, pair := range strings.Split(string(body), "\n") { + parts := strings.Split(pair, ":") + if len(parts) != 2 { + continue + } + if strings.EqualFold(suffix, parts[0]) { + count, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err != nil { + return -1, err + } + return int(count), nil + } + } + return 0, nil +} diff --git a/modules/auth/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go new file mode 100644 index 0000000..e510815 --- /dev/null +++ b/modules/auth/password/pwn/pwn_test.go @@ -0,0 +1,51 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pwn + +import ( + "net/http" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var client = New(WithHTTP(&http.Client{ + Timeout: time.Second * 2, +})) + +func TestPassword(t *testing.T) { + defer gock.Off() + + count, err := client.CheckPassword("", false) + require.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword") + assert.Equal(t, -1, count) + + gock.New("https://api.pwnedpasswords.com").Get("/range/5c1d8").Times(1).Reply(200).BodyString("EAF2F254732680E8AC339B84F3266ECCBB5:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2") + count, err = client.CheckPassword("pwned", false) + require.NoError(t, err) + assert.Equal(t, 1, count) + + gock.New("https://api.pwnedpasswords.com").Get("/range/ba189").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4") + count, err = client.CheckPassword("notpwned", false) + require.NoError(t, err) + assert.Equal(t, 0, count) + + gock.New("https://api.pwnedpasswords.com").Get("/range/a1733").Times(1).Reply(200).BodyString("C4CE0F1F0062B27B9E2F41AF0C08218017C:1\r\nFC446EB88938834178CB9322C1EE273C2A7:2\r\nFE81480327C992FE62065A827429DD1318B:0") + count, err = client.CheckPassword("paddedpwned", true) + require.NoError(t, err) + assert.Equal(t, 1, count) + + gock.New("https://api.pwnedpasswords.com").Get("/range/5617b").Times(1).Reply(200).BodyString("FD4CB34F0378BCB15D23F6FFD28F0775C9E:3\r\nFDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0") + count, err = client.CheckPassword("paddednotpwned", true) + require.NoError(t, err) + assert.Equal(t, 0, count) + + gock.New("https://api.pwnedpasswords.com").Get("/range/79082").Times(1).Reply(200).BodyString("FDF342FCD8C3611DAE4D76E8A992A3E4169:4\r\nFE81480327C992FE62065A827429DD1318B:0\r\nAFEF386F56EB0B4BE314E07696E5E6E6536:0") + count, err = client.CheckPassword("paddednotpwnedzero", true) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go new file mode 100644 index 0000000..189d197 --- /dev/null +++ b/modules/auth/webauthn/webauthn.go @@ -0,0 +1,77 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webauthn + +import ( + "encoding/binary" + "encoding/gob" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// WebAuthn represents the global WebAuthn instance +var WebAuthn *webauthn.WebAuthn + +// Init initializes the WebAuthn instance from the config. +func Init() { + gob.Register(&webauthn.SessionData{}) + + appURL, _ := protocol.FullyQualifiedOrigin(setting.AppURL) + + WebAuthn = &webauthn.WebAuthn{ + Config: &webauthn.Config{ + RPDisplayName: setting.AppName, + RPID: setting.Domain, + RPOrigins: []string{appURL}, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + UserVerification: "discouraged", + }, + AttestationPreference: protocol.PreferDirectAttestation, + }, + } +} + +// User represents an implementation of webauthn.User based on User model +type User user_model.User + +// WebAuthnID implements the webauthn.User interface +func (u *User) WebAuthnID() []byte { + id := make([]byte, 8) + binary.PutVarint(id, u.ID) + return id +} + +// WebAuthnName implements the webauthn.User interface +func (u *User) WebAuthnName() string { + if u.LoginName == "" { + return u.Name + } + return u.LoginName +} + +// WebAuthnDisplayName implements the webauthn.User interface +func (u *User) WebAuthnDisplayName() string { + return (*user_model.User)(u).DisplayName() +} + +// WebAuthnIcon implements the webauthn.User interface +func (u *User) WebAuthnIcon() string { + return (*user_model.User)(u).AvatarLink(db.DefaultContext) +} + +// WebAuthnCredentials implementns the webauthn.User interface +func (u *User) WebAuthnCredentials() []webauthn.Credential { + dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID) + if err != nil { + return nil + } + + return dbCreds.ToCredentials() +} diff --git a/modules/auth/webauthn/webauthn_test.go b/modules/auth/webauthn/webauthn_test.go new file mode 100644 index 0000000..15a8d71 --- /dev/null +++ b/modules/auth/webauthn/webauthn_test.go @@ -0,0 +1,25 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webauthn + +import ( + "testing" + + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + setting.Domain = "domain" + setting.AppName = "AppName" + setting.AppURL = "https://domain/" + rpOrigin := []string{"https://domain"} + + Init() + + assert.Equal(t, setting.Domain, WebAuthn.Config.RPID) + assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName) + assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins) +} |