diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 640c05837..c98fe65d6 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -100,6 +100,10 @@ internal static class TestServerInitializer } World.Configure(); + // Registers the Accounts entity persistence. Production reaches this through + // AssemblyHandler.Invoke("Configure"); the curated subset here must call it so that + // Accounts.NewAccount resolves and tests can construct an Account. + Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs new file mode 100644 index 000000000..b1659fa56 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -0,0 +1,47 @@ +using Server.Accounting; +using Server.Accounting.Security; +using Xunit; + +namespace Server.Tests.Accounting; + +[Collection("Sequential UOContent Tests")] +public class AccountPasswordTests +{ + private const string Password = "hunter2"; + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + [InlineData(PasswordProtectionAlgorithm.Argon2)] + public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm) + { + AccountSecurity.CurrentAlgorithm = algorithm; + var account = new Account($"new-{algorithm}-user", Password); + + Assert.Equal(algorithm, account.PasswordAlgorithm); + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from) + { + AccountSecurity.CurrentAlgorithm = from; + var account = new Account($"upgrade-{from}-user", Password); + Assert.True(account.CheckPassword(Password)); + + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + + // First login verifies under the old algorithm and rehashes under the new one. + Assert.True(account.CheckPassword(Password)); + Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm); + + // Second login must verify against what the first one wrote. + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } +} diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs index d79d70b1a..03ce9a2af 100644 --- a/Projects/UOContent/Accounting/Account.cs +++ b/Projects/UOContent/Accounting/Account.cs @@ -376,21 +376,31 @@ public partial class Account : IAccount, IComparable return true; } + /// + /// SHA1 and SHA2 are the ServUO-compatible algorithms; they salt the password with the + /// username. Argon2 and PBKDF2 carry their own salt and do not. + /// + private static bool UsesUsernamePhrase(PasswordProtectionAlgorithm algorithm) => + algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2; + public void SetPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; + // The phrase must match how CheckPassword will rebuild it *after* the algorithm changes, + // so it is derived from the target algorithm rather than the outgoing one. Deriving it + // from _passwordAlgorithm stored a username-salted hash under an algorithm that never + // re-adds the username, locking the account out on its next login -- and, because this + // runs from the constructor before _passwordAlgorithm is assigned, it also produced + // brand-new SHA1/SHA2 accounts that could never log in at all. + var algorithm = AccountSecurity.CurrentAlgorithm; + var phrase = UsesUsernamePhrase(algorithm) ? $"{_username}{plainPassword}" : plainPassword; - Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); - PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; + Password = AccountSecurity.GetPasswordProtection(algorithm).EncryptPassword(phrase); + PasswordAlgorithm = algorithm; } public bool CheckPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; + var phrase = UsesUsernamePhrase(_passwordAlgorithm) ? $"{_username}{plainPassword}" : plainPassword; var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase); if (!ok)