diff --git a/server/channels/app/authentication_test.go b/server/channels/app/authentication_test.go index f53a7d40294..375dc981adc 100644 --- a/server/channels/app/authentication_test.go +++ b/server/channels/app/authentication_test.go @@ -469,7 +469,7 @@ func TestCheckUserPassword(t *testing.T) { }) t.Run("successful migration from PBKDF2 with old parameter to new parameter", func(t *testing.T) { - // Create a PBKDF2 hasher with work factor = 10000 instead of the default 60000 + // Create a PBKDF2 hasher with work factor = 10000 instead of the default oldParamPBKDF2, err := hashers.NewPBKDF2(10000, 32) require.NoError(t, err) @@ -488,8 +488,8 @@ func TestCheckUserPassword(t *testing.T) { require.Nil(t, appErr) require.NotEqual(t, pwdBcrypt, updatedUser.Password) require.Contains(t, updatedUser.Password, "$pbkdf2") - // The new user hash contains the new parameter - require.Contains(t, updatedUser.Password, "w=60000") + // The new user hash should NOT contain the old parameter + require.NotContains(t, updatedUser.Password, "w=10000") // Re-check with updated password appErr = th.App.checkUserPassword(user, pwd, false) diff --git a/server/channels/app/password/hashers/hashers.go b/server/channels/app/password/hashers/hashers.go index 1f07ce6507f..64eacf6d887 100644 --- a/server/channels/app/password/hashers/hashers.go +++ b/server/channels/app/password/hashers/hashers.go @@ -135,17 +135,17 @@ func GetHasherFromPHCString(phcString string) (PasswordHasher, phcparser.PHC, er // Hash hashes the provided password with the latest hashing method. func Hash(password string) (string, error) { - return latestHasher.Hash(password) + return getLatestHasher().Hash(password) } // CompareHashAndPassword compares the parsed [phcparser.PHC] and the provided // password using the latest hashing method. func CompareHashAndPassword(phc phcparser.PHC, password string) error { - return latestHasher.CompareHashAndPassword(phc, password) + return getLatestHasher().CompareHashAndPassword(phc, password) } // IsLatestHasher verifies that the provided hasher is the latest one. This // function is useful for identifying stored hashes that require a migration. func IsLatestHasher(hasher PasswordHasher) bool { - return latestHasher == hasher + return getLatestHasher() == hasher } diff --git a/server/channels/app/password/hashers/hashers_dev.go b/server/channels/app/password/hashers/hashers_dev.go new file mode 100644 index 00000000000..948d33e52ed --- /dev/null +++ b/server/channels/app/password/hashers/hashers_dev.go @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !production + +package hashers + +import "testing" + +// testHasher is used during tests to override the latestHasher with a faster +// alternative. This should only be set via SetTestHasher and only in test code. +var testHasher PasswordHasher + +// getLatestHasher returns the hasher to use for password operations. +// In non-production builds, if a test hasher has been set via SetTestHasher, +// it will be returned instead of the production latestHasher. +func getLatestHasher() PasswordHasher { + if testHasher != nil { + return testHasher + } + return latestHasher +} + +// SetTestHasher sets a hasher to be used instead of the latestHasher during tests. +// This is useful for speeding up tests that create many users, as password hashing +// is computationally expensive. Pass nil to restore normal behavior. +// +// This function is only available in non-production builds and should only be +// called from test code, typically in TestMain. It will panic if called outside +// of a test context. +// +// Example usage: +// +// func TestMain(m *testing.M) { +// hashers.SetTestHasher(hashers.FastTestHasher()) +// os.Exit(m.Run()) +// } +func SetTestHasher(h PasswordHasher) { + if !testing.Testing() { + panic("SetTestHasher called outside of test context") + } + testHasher = h +} + +// FastTestHasher returns a PBKDF2 hasher configured with minimal work factor +// for use in tests while still producing valid password hashes that can be +// verified. +// +// This function is only available in non-production builds. +func FastTestHasher() PasswordHasher { + h, err := NewPBKDF2(1, defaultKeyLength) + if err != nil { + panic("failed to create fast test hasher: " + err.Error()) + } + return h +} diff --git a/server/channels/app/password/hashers/hashers_dev_test.go b/server/channels/app/password/hashers/hashers_dev_test.go new file mode 100644 index 00000000000..6a7053d21f0 --- /dev/null +++ b/server/channels/app/password/hashers/hashers_dev_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !production + +package hashers + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetTestHasher(t *testing.T) { + // Ensure testHasher starts as nil + SetTestHasher(nil) + + // Hash should work with nil testHasher (uses latestHasher) + hash1, err := Hash("password") + require.NoError(t, err) + require.NotEmpty(t, hash1) + + // Set a fast test hasher + fastHasher := FastTestHasher() + SetTestHasher(fastHasher) + defer SetTestHasher(nil) + + // Hash should now use the fast test hasher + hash2, err := Hash("password") + require.NoError(t, err) + require.NotEmpty(t, hash2) + + // Verify the hash was generated with different parameters + // The fast hasher uses work factor 1, so the hash should contain w=1 + require.Contains(t, hash2, "w=1") + + // Verify the password can be verified against the hash + hasher, phc, err := GetHasherFromPHCString(hash2) + require.NoError(t, err) + require.NoError(t, hasher.CompareHashAndPassword(phc, "password")) + require.Error(t, hasher.CompareHashAndPassword(phc, "wrongpassword")) +} + +func TestFastTestHasher(t *testing.T) { + hasher := FastTestHasher() + require.NotNil(t, hasher) + + // Verify it's a PBKDF2 hasher with work factor 1 + pbkdf2Hasher, ok := hasher.(PBKDF2) + require.True(t, ok, "FastTestHasher should return a PBKDF2 hasher") + require.Equal(t, 1, pbkdf2Hasher.workFactor) + + // Test that it produces valid hashes + hash, err := hasher.Hash("testpassword") + require.NoError(t, err) + require.Contains(t, hash, "w=1") + + // Verify the hash can be validated + parsedHasher, phc, err := GetHasherFromPHCString(hash) + require.NoError(t, err) + require.NoError(t, parsedHasher.CompareHashAndPassword(phc, "testpassword")) +} + +func TestGetLatestHasher(t *testing.T) { + // Ensure testHasher starts as nil + SetTestHasher(nil) + + // Without test hasher, should return latestHasher + require.Equal(t, latestHasher, getLatestHasher()) + + // Set a fast test hasher + fastHasher := FastTestHasher() + SetTestHasher(fastHasher) + defer SetTestHasher(nil) + + // With test hasher set, should return the test hasher + require.Equal(t, fastHasher, getLatestHasher()) + require.NotEqual(t, latestHasher, getLatestHasher()) +} + +func BenchmarkFastTestHasher(b *testing.B) { + hasher := FastTestHasher() + for b.Loop() { + _, _ = hasher.Hash("password") + } +} diff --git a/server/channels/app/password/hashers/hashers_production.go b/server/channels/app/password/hashers/hashers_production.go new file mode 100644 index 00000000000..17520c85246 --- /dev/null +++ b/server/channels/app/password/hashers/hashers_production.go @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build production + +package hashers + +// getLatestHasher returns the hasher to use for password operations. +// In production builds, this always returns the latestHasher. +func getLatestHasher() PasswordHasher { + return latestHasher +} diff --git a/server/channels/app/password/hashers/hashers_test.go b/server/channels/app/password/hashers/hashers_test.go index f2955c2669c..9fe8a7107d1 100644 --- a/server/channels/app/password/hashers/hashers_test.go +++ b/server/channels/app/password/hashers/hashers_test.go @@ -136,3 +136,10 @@ func TestIsLatestHasher(t *testing.T) { require.Equal(t, tc.expectedOutput, actualOutput) } } + +func BenchmarkDefaultHasher(b *testing.B) { + hasher := DefaultPBKDF2() + for b.Loop() { + _, _ = hasher.Hash("password") + } +} diff --git a/server/channels/testlib/hashers_dev.go b/server/channels/testlib/hashers_dev.go new file mode 100644 index 00000000000..a04bfae6591 --- /dev/null +++ b/server/channels/testlib/hashers_dev.go @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !production + +package testlib + +import "github.com/mattermost/mattermost/server/v8/channels/app/password/hashers" + +func setupFastTestHasher() { + hashers.SetTestHasher(hashers.FastTestHasher()) +} diff --git a/server/channels/testlib/hashers_production.go b/server/channels/testlib/hashers_production.go new file mode 100644 index 00000000000..0e03966bc3b --- /dev/null +++ b/server/channels/testlib/hashers_production.go @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build production + +package testlib + +func setupFastTestHasher() {} diff --git a/server/channels/testlib/helper.go b/server/channels/testlib/helper.go index 98a20dfebf9..ea8b7932daa 100644 --- a/server/channels/testlib/helper.go +++ b/server/channels/testlib/helper.go @@ -87,6 +87,9 @@ func NewMainHelperWithOptions(options *HelperOptions) *MainHelper { log.Fatal(err) } + // Use a fast password hasher during tests to speed up user creation. + setupFastTestHasher() + if options != nil { mainHelper.Options = *options