summaryrefslogtreecommitdiffstats
path: root/modules/auth
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /modules/auth
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/auth/common.go22
-rw-r--r--modules/auth/openid/discovery_cache.go57
-rw-r--r--modules/auth/openid/discovery_cache_test.go49
-rw-r--r--modules/auth/openid/openid.go37
-rw-r--r--modules/auth/pam/pam.go43
-rw-r--r--modules/auth/pam/pam_stub.go22
-rw-r--r--modules/auth/pam/pam_test.go20
-rw-r--r--modules/auth/password/hash/argon2.go80
-rw-r--r--modules/auth/password/hash/bcrypt.go54
-rw-r--r--modules/auth/password/hash/common.go28
-rw-r--r--modules/auth/password/hash/dummy.go33
-rw-r--r--modules/auth/password/hash/dummy_test.go26
-rw-r--r--modules/auth/password/hash/hash.go189
-rw-r--r--modules/auth/password/hash/hash_test.go191
-rw-r--r--modules/auth/password/hash/pbkdf2.go67
-rw-r--r--modules/auth/password/hash/scrypt.go67
-rw-r--r--modules/auth/password/hash/setting.go76
-rw-r--r--modules/auth/password/hash/setting_test.go38
-rw-r--r--modules/auth/password/password.go136
-rw-r--r--modules/auth/password/password_test.go77
-rw-r--r--modules/auth/password/pwn.go52
-rw-r--r--modules/auth/password/pwn/pwn.go118
-rw-r--r--modules/auth/password/pwn/pwn_test.go51
-rw-r--r--modules/auth/webauthn/webauthn.go77
-rw-r--r--modules/auth/webauthn/webauthn_test.go25
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)
+}