diff --git a/Crypter.API/Controllers/UserAuthenticationController.cs b/Crypter.API/Controllers/UserAuthenticationController.cs index 612d6aced..d1a371fdb 100644 --- a/Crypter.API/Controllers/UserAuthenticationController.cs +++ b/Crypter.API/Controllers/UserAuthenticationController.cs @@ -31,6 +31,7 @@ using Crypter.API.Methods; using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using Crypter.Core.Features.UserAuthentication.Commands; using Crypter.Core.Features.UserAuthentication.Queries; using EasyMonads; @@ -110,8 +111,7 @@ IActionResult MakeErrorResponse(LoginError error) return error switch { LoginError.UnknownError - or LoginError.PasswordHashFailure => MakeErrorResponseBase(HttpStatusCode.InternalServerError, - error), + or LoginError.PasswordHashFailure => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error), LoginError.InvalidUsername or LoginError.InvalidPassword or LoginError.InvalidTokenTypeRequested @@ -208,6 +208,40 @@ IActionResult MakeErrorResponse(PasswordChallengeError error) MakeErrorResponse(PasswordChallengeError.UnknownError)); } + /// + /// Handle a request to change the password for an authorized user + /// + /// + /// + [HttpPost("password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(void))] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))] + public async Task PasswordChangeAsync([FromBody] PasswordChangeRequest request) + { + IActionResult MakeErrorResponse(PasswordChangeError error) + { +#pragma warning disable CS8524 + return error switch + { + PasswordChangeError.UnknownError + or PasswordChangeError.PasswordHashFailure => MakeErrorResponseBase(HttpStatusCode.InternalServerError, error), + PasswordChangeError.InvalidPassword + or PasswordChangeError.InvalidOldPasswordVersion + or PasswordChangeError.InvalidNewPasswordVersion => MakeErrorResponseBase(HttpStatusCode.BadRequest, error) + }; +#pragma warning restore CS8524 + } + + ChangeUserPasswordCommand command = new ChangeUserPasswordCommand(UserId, request); + return await _sender.Send(command) + .MatchAsync( + MakeErrorResponse, + _ => Ok(), + MakeErrorResponse(PasswordChangeError.UnknownError)); + } + /// /// Clears the provided refresh token from the database, ensuring it cannot be used for subsequent requests. /// diff --git a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs index 74327ec52..f71a28b79 100644 --- a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs +++ b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs @@ -29,6 +29,7 @@ using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Requests; using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using EasyMonads; namespace Crypter.Common.Client.HttpClients.Requests; @@ -77,14 +78,20 @@ public async Task> RefreshSessionAsync() return response; } - public Task> PasswordChallengeAsync( - PasswordChallengeRequest testPasswordRequest) + public Task> PasswordChallengeAsync(PasswordChallengeRequest testPasswordRequest) { const string url = "api/user/authentication/password/challenge"; return _crypterAuthenticatedHttpClient.PostEitherUnitResponseAsync(url, testPasswordRequest) .ExtractErrorCode(); } + public Task> ChangePasswordAsync(PasswordChangeRequest passwordChangeRequest) + { + const string url = "api/user/authentication/password"; + return _crypterAuthenticatedHttpClient.PostEitherUnitResponseAsync(url, passwordChangeRequest) + .ExtractErrorCode(); + } + public Task> LogoutAsync() { const string url = "api/user/authentication/logout"; diff --git a/Crypter.Common.Client/HttpClients/Requests/UserKeyRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserKeyRequests.cs index 1c9fea717..6170d296a 100644 --- a/Crypter.Common.Client/HttpClients/Requests/UserKeyRequests.cs +++ b/Crypter.Common.Client/HttpClients/Requests/UserKeyRequests.cs @@ -55,8 +55,7 @@ public Task> InsertMasterKeyAsync(InsertMaste .ExtractErrorCode(); } - public Task> - GetMasterKeyRecoveryProofAsync(GetMasterKeyRecoveryProofRequest request) + public Task> GetMasterKeyRecoveryProofAsync(GetMasterKeyRecoveryProofRequest request) { const string url = "api/user/key/master/recovery-proof/challenge"; return _crypterAuthenticatedHttpClient diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs index 22f6ea301..d30bdd273 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs @@ -27,6 +27,7 @@ using System; using System.Threading.Tasks; using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using EasyMonads; namespace Crypter.Common.Client.Interfaces.Requests; @@ -38,5 +39,6 @@ public interface IUserAuthenticationRequests Task> LoginAsync(LoginRequest loginRequest); Task> RefreshSessionAsync(); Task> PasswordChallengeAsync(PasswordChallengeRequest testPasswordRequest); + Task> ChangePasswordAsync(PasswordChangeRequest passwordChangeRequest); Task> LogoutAsync(); } diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserKeyRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserKeyRequests.cs index 432634777..2de162c51 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserKeyRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserKeyRequests.cs @@ -35,8 +35,7 @@ public interface IUserKeyRequests Task> GetMasterKeyAsync(); Task> InsertMasterKeyAsync(InsertMasterKeyRequest request); - Task> GetMasterKeyRecoveryProofAsync( - GetMasterKeyRecoveryProofRequest request); + Task> GetMasterKeyRecoveryProofAsync(GetMasterKeyRecoveryProofRequest request); Task> GetPrivateKeyAsync(); Task> InsertKeyPairAsync(InsertKeyPairRequest request); diff --git a/Crypter.Common.Client/Interfaces/Services/IUserKeysService.cs b/Crypter.Common.Client/Interfaces/Services/IUserKeysService.cs index 030ab9462..ff5cfd287 100644 --- a/Crypter.Common.Client/Interfaces/Services/IUserKeysService.cs +++ b/Crypter.Common.Client/Interfaces/Services/IUserKeysService.cs @@ -39,10 +39,7 @@ public interface IUserKeysService Task DownloadExistingKeysAsync(Username username, Password password, bool trustDevice); Task DownloadExistingKeysAsync(byte[] credentialKey, bool trustDevice); - - Task> UploadNewKeysAsync(Username username, Password password, - VersionedPassword versionedPassword, bool trustDevice); - - Task> UploadNewKeysAsync(VersionedPassword versionedPassword, byte[] credentialKey, - bool trustDevice); + + Task> UploadNewKeysAsync(Username username, Password password, VersionedPassword versionedPassword, bool trustDevice); + Task> UploadNewKeysAsync(VersionedPassword versionedPassword, byte[] credentialKey, bool trustDevice); } diff --git a/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs b/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs new file mode 100644 index 000000000..1e73bc5c2 --- /dev/null +++ b/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Threading.Tasks; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Primitives; +using EasyMonads; + +namespace Crypter.Common.Client.Interfaces.Services.UserSettings; + +public interface IUserPasswordChangeService +{ + Task> ChangePasswordAsync(Password oldPassword, Password newPassword); +} diff --git a/Crypter.Common.Client/Services/UserKeysService.cs b/Crypter.Common.Client/Services/UserKeysService.cs index 859a4b9d1..d24fb970f 100644 --- a/Crypter.Common.Client/Services/UserKeysService.cs +++ b/Crypter.Common.Client/Services/UserKeysService.cs @@ -93,16 +93,14 @@ public Task DownloadExistingKeysAsync(byte[] credentialKey, bool trustDevice) private Task> DownloadAndDecryptMasterKey(byte[] credentialKey) { return _crypterApiClient.UserKey.GetMasterKeyAsync() - .MapAsync(x => - _cryptoProvider.Encryption.Decrypt(credentialKey, x.Nonce, x.EncryptedKey)) + .MapAsync(x => _cryptoProvider.Encryption.Decrypt(credentialKey, x.Nonce, x.EncryptedKey)) .ToMaybeTask(); } private Task> DownloadAndDecryptPrivateKey(byte[] masterKey) { return _crypterApiClient.UserKey.GetPrivateKeyAsync() - .MapAsync(x => - _cryptoProvider.Encryption.Decrypt(masterKey, x.Nonce, x.EncryptedKey)) + .MapAsync(x => _cryptoProvider.Encryption.Decrypt(masterKey, x.Nonce, x.EncryptedKey)) .ToMaybeTask(); } @@ -110,16 +108,14 @@ private Task> DownloadAndDecryptPrivateKey(byte[] masterKey) #region Upload New Keys - public Task> UploadNewKeysAsync(Username username, Password password, - VersionedPassword versionedPassword, bool trustDevice) + public Task> UploadNewKeysAsync(Username username, Password password, VersionedPassword versionedPassword, bool trustDevice) { return _userPasswordService .DeriveUserCredentialKeyAsync(username, password, _userPasswordService.CurrentPasswordVersion) .BindAsync(credentialKey => UploadNewKeysAsync(versionedPassword, credentialKey, trustDevice)); } - public Task> UploadNewKeysAsync(VersionedPassword versionedPassword, byte[] credentialKey, - bool trustDevice) + public Task> UploadNewKeysAsync(VersionedPassword versionedPassword, byte[] credentialKey, bool trustDevice) { return UploadNewMasterKeyAsync(versionedPassword, credentialKey) .BindAsync(recoveryKey => UploadNewUserKeyPairAsync(recoveryKey.MasterKey) @@ -134,8 +130,7 @@ private Task> UploadNewMasterKeyAsync(VersionedPassword versi byte[] encryptedMasterKey = _cryptoProvider.Encryption.Encrypt(credentialKey, nonce, newMasterKey); byte[] recoveryProof = _cryptoProvider.Random.GenerateRandomBytes(32); - InsertMasterKeyRequest request = - new InsertMasterKeyRequest(versionedPassword.Password, encryptedMasterKey, nonce, recoveryProof); + InsertMasterKeyRequest request = new InsertMasterKeyRequest(versionedPassword.Password, encryptedMasterKey, nonce, recoveryProof); return _crypterApiClient.UserKey.InsertMasterKeyAsync(request) .ToMaybeTask() .BindAsync(_ => new RecoveryKey(newMasterKey, request.RecoveryProof)); diff --git a/Crypter.Common.Client/Services/UserPasswordService.cs b/Crypter.Common.Client/Services/UserPasswordService.cs index 3da172106..27684621f 100644 --- a/Crypter.Common.Client/Services/UserPasswordService.cs +++ b/Crypter.Common.Client/Services/UserPasswordService.cs @@ -77,8 +77,7 @@ public Task> DeriveUserCredentialKeyAsync(Username username, Passw }; } - public async Task> DeriveUserAuthenticationPasswordAsync(Username username, - Password password, int passwordVersion) + public async Task> DeriveUserAuthenticationPasswordAsync(Username username, Password password, int passwordVersion) { #pragma warning disable CS0618 return passwordVersion switch @@ -101,8 +100,7 @@ private VersionedPassword DeriveSha512AuthenticationPassword(Username username, return new VersionedPassword(hashedPassword, 0); } - private async Task> DeriveArgonAuthenticationPasswordAsync(Username username, - Password password) + private async Task> DeriveArgonAuthenticationPasswordAsync(Username username, Password password) { OnPasswordHashBeginEvent(PasswordHashType.AuthenticationKey); await Task.Delay(1); diff --git a/Crypter.Common.Client/Services/UserRecoveryService.cs b/Crypter.Common.Client/Services/UserRecoveryService.cs index 4893e02f5..6d80fed19 100644 --- a/Crypter.Common.Client/Services/UserRecoveryService.cs +++ b/Crypter.Common.Client/Services/UserRecoveryService.cs @@ -79,15 +79,12 @@ public async Task>> Submit }, x => { - byte[] nonce = - _cryptoProvider.Random.GenerateRandomBytes((int)_cryptoProvider.Encryption.NonceSize); - byte[] encryptedMasterKey = - _cryptoProvider.Encryption.Encrypt(derivatives.CredentialKey, nonce, x.MasterKey); + byte[] nonce = _cryptoProvider.Random.GenerateRandomBytes((int)_cryptoProvider.Encryption.NonceSize); + byte[] encryptedMasterKey = _cryptoProvider.Encryption.Encrypt(derivatives.CredentialKey, nonce, x.MasterKey); byte[] newRecoveryProof = _cryptoProvider.Random.GenerateRandomBytes(32); RecoveryKey newRecoveryKey = new RecoveryKey(x.MasterKey, newRecoveryProof); - ReplacementMasterKeyInformation replacementMasterKeyInformation = new ReplacementMasterKeyInformation(x.Proof, - newRecoveryProof, encryptedMasterKey, nonce); + ReplacementMasterKeyInformation replacementMasterKeyInformation = new ReplacementMasterKeyInformation(x.Proof, newRecoveryProof, encryptedMasterKey, nonce); return new RecoveryParameters { @@ -121,8 +118,7 @@ public Task> DeriveRecoveryKeyAsync(byte[] masterKey, Usernam public Task> DeriveRecoveryKeyAsync(byte[] masterKey, VersionedPassword versionedPassword) { - GetMasterKeyRecoveryProofRequest request = - new GetMasterKeyRecoveryProofRequest(versionedPassword.Password); + GetMasterKeyRecoveryProofRequest request = new GetMasterKeyRecoveryProofRequest(versionedPassword.Password); return _crypterApiClient.UserKey.GetMasterKeyRecoveryProofAsync(request) .ToMaybeTask() .MapAsync(x => new RecoveryKey(masterKey, x.Proof)); diff --git a/Crypter.Common.Client/Services/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index e6f1b31f5..1df181d1f 100644 --- a/Crypter.Common.Client/Services/UserSessionService.cs +++ b/Crypter.Common.Client/Services/UserSessionService.cs @@ -139,22 +139,18 @@ public Task> LoginAsync(Username username, Password pas () => LoginError.PasswordHashFailure, async versionedPassword => { - List versionedPasswords = new List { versionedPassword }; - var loginTask = from loginResponse in LoginRecursiveAsync(username, password, versionedPasswords, - _trustDeviceRefreshTokenTypeMap[rememberUser]) - from unit0 in Either.FromRightAsync(StoreSessionInfo(loginResponse, - rememberUser)) + List versionedPasswords = [versionedPassword]; + var loginTask = from loginResponse in LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser]) + from unit0 in Either.FromRightAsync(StoreSessionInfo(loginResponse, rememberUser)) select loginResponse; Either loginResult = await loginTask; - loginResult.DoRight(x => HandleUserLoggedInEvent(username, password, versionedPassword, - rememberUser, x.UploadNewKeys, x.ShowRecoveryKey)); + loginResult.DoRight(x => HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, x.UploadNewKeys, x.ShowRecoveryKey)); return loginResult.Map(_ => Unit.Default); }); } - private Task> LoginRecursiveAsync(Username username, Password password, - List versionedPasswords, TokenType refreshTokenType) + private Task> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType) { return SendLoginRequestAsync(username, versionedPasswords, refreshTokenType) .MatchAsync( @@ -164,15 +160,13 @@ private Task> LoginRecursiveAsync(Username use if (error == LoginError.InvalidPasswordVersion && oldestPasswordVersionAttempted > 0) { return await _userPasswordService - .DeriveUserAuthenticationPasswordAsync(username, password, - oldestPasswordVersionAttempted - 1) + .DeriveUserAuthenticationPasswordAsync(username, password, oldestPasswordVersionAttempted - 1) .MatchAsync( () => LoginError.PasswordHashFailure, async previousVersionedPassword => { versionedPasswords.Add(previousVersionedPassword); - return await LoginRecursiveAsync(username, password, versionedPasswords, - refreshTokenType); + return await LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType); }); } else @@ -257,15 +251,13 @@ public event EventHandler UserPasswordTestSucc #endregion - private Task> SendLoginRequestAsync(Username username, - List versionedPasswords, TokenType refreshTokenType) + private Task> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType) { LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType); return _crypterApiClient.UserAuthentication.LoginAsync(loginRequest); } - private Task> SendTestPasswordRequestAsync(Username username, - Password password) + private Task> SendTestPasswordRequestAsync(Username username, Password password) { return _userPasswordService .DeriveUserAuthenticationPasswordAsync(username, password, _userPasswordService.CurrentPasswordVersion) @@ -285,7 +277,6 @@ public async Task TestPasswordAsync(Password password) x => Username.From(x.Username)); var response = await SendTestPasswordRequestAsync(username, password); - if (response.IsRight) { HandleTestPasswordSuccessEvent(username, password); @@ -297,7 +288,7 @@ public async Task TestPasswordAsync(Password password) private Task StoreSessionInfo(LoginResponse response, bool rememberUser) { - var sessionInfo = new UserSession(response.Username, rememberUser, UserSession.LATEST_SCHEMA); + UserSession sessionInfo = new UserSession(response.Username, rememberUser, UserSession.LATEST_SCHEMA); Session = sessionInfo; return Task.Run(async () => diff --git a/Crypter.Common.Client/Services/UserSettings/UserNotificationSettingsService.cs b/Crypter.Common.Client/Services/UserSettings/UserNotificationSettingsService.cs index c6e1a89d5..cb9fccb5a 100644 --- a/Crypter.Common.Client/Services/UserSettings/UserNotificationSettingsService.cs +++ b/Crypter.Common.Client/Services/UserSettings/UserNotificationSettingsService.cs @@ -59,11 +59,9 @@ public async Task> GetNotificationSettingsAsync() return _notificationSettings; } - public async Task> UpdateNotificationSettingsAsync( - NotificationSettings newNotificationSettings) + public async Task> UpdateNotificationSettingsAsync(NotificationSettings newNotificationSettings) { - Either result = - await _crypterApiClient.UserSetting.UpdateNotificationSettingsAsync(newNotificationSettings); + Either result = await _crypterApiClient.UserSetting.UpdateNotificationSettingsAsync(newNotificationSettings); _notificationSettings = result.ToMaybe(); return result; } diff --git a/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs b/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs new file mode 100644 index 000000000..7fddbe084 --- /dev/null +++ b/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Crypter.Common.Client.Interfaces.HttpClients; +using Crypter.Common.Client.Interfaces.Services; +using Crypter.Common.Client.Interfaces.Services.UserSettings; +using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Primitives; +using Crypter.Crypto.Common; +using EasyMonads; + +namespace Crypter.Common.Client.Services.UserSettings; + +public class UserPasswordChangeService : IUserPasswordChangeService +{ + private readonly ICrypterApiClient _crypterApiClient; + private readonly ICryptoProvider _cryptoProvider; + private readonly IUserKeysService _userKeysService; + private readonly IUserPasswordService _userPasswordService; + private readonly IUserSessionService _userSessionService; + + public UserPasswordChangeService(ICrypterApiClient crypterApiClient, ICryptoProvider cryptoProvider, IUserKeysService userKeysService, IUserPasswordService userPasswordService, IUserSessionService userSessionService) + { + _crypterApiClient = crypterApiClient; + _cryptoProvider = cryptoProvider; + _userKeysService = userKeysService; + _userPasswordService = userPasswordService; + _userSessionService = userSessionService; + } + + public async Task> ChangePasswordAsync(Password oldPassword, Password newPassword) + { + var changePasswordTask = from Session in _userSessionService.Session.ToEither(PasswordChangeError.UnknownError).AsTask() + let username = Username.From(Session.Username) + from masterKey in _userKeysService.MasterKey.ToEither(PasswordChangeError.UnknownError).AsTask() + from newAuthenticationPassword in _userPasswordService.DeriveUserAuthenticationPasswordAsync(username, newPassword, _userPasswordService.CurrentPasswordVersion) + .ToEitherAsync(PasswordChangeError.PasswordHashFailure) + from credentialKey in _userPasswordService.DeriveUserCredentialKeyAsync(username, newPassword, _userPasswordService.CurrentPasswordVersion) + .ToEitherAsync(PasswordChangeError.PasswordHashFailure) + from oldAuthenticationPassword in _userPasswordService.DeriveUserAuthenticationPasswordAsync(username, oldPassword, _userPasswordService.CurrentPasswordVersion) + .ToEitherAsync(PasswordChangeError.PasswordHashFailure) + let nonce = _cryptoProvider.Random.GenerateRandomBytes((int)_cryptoProvider.Encryption.NonceSize) + let encryptedMasterKey = _cryptoProvider.Encryption.Encrypt(credentialKey, nonce, masterKey) + from passwordChangeResult in ChangePasswordRecursiveAsync(username, oldPassword, [oldAuthenticationPassword], newAuthenticationPassword, encryptedMasterKey, nonce) + select passwordChangeResult; + + return await changePasswordTask; + } + + private async Task> ChangePasswordRecursiveAsync(Username username, Password oldPassword, List oldPasswords, VersionedPassword newPassword, byte[] encryptedMasterKey, byte[] nonce) + { + return await SendPasswordChangeRequest(oldPasswords, newPassword, encryptedMasterKey, nonce) + .MatchAsync( + async error => + { + int oldestPasswordVersionAttempted = oldPasswords.Min(x => x.Version); + if (error == PasswordChangeError.InvalidOldPasswordVersion && oldestPasswordVersionAttempted > 0) + { + return await _userPasswordService + .DeriveUserAuthenticationPasswordAsync(username, oldPassword, oldestPasswordVersionAttempted - 1) + .MatchAsync( + () => PasswordChangeError.PasswordHashFailure, + async previousVersionedPassword => + { + oldPasswords.Add(previousVersionedPassword); + return await ChangePasswordRecursiveAsync(username, oldPassword, oldPasswords, newPassword, encryptedMasterKey, nonce); + }); + } + return error; + }, + response => response, + PasswordChangeError.UnknownError); + } + + private async Task> SendPasswordChangeRequest(List oldPasswords, VersionedPassword newPassword, byte[] encryptedMasterKey, byte[] nonce) + { + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword, encryptedMasterKey, nonce); + return await _crypterApiClient.UserAuthentication.ChangePasswordAsync(request); + } +} diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs new file mode 100644 index 000000000..7fa9694f1 --- /dev/null +++ b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +namespace Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; + +public enum PasswordChangeError +{ + UnknownError, + InvalidPassword, + InvalidOldPasswordVersion, + InvalidNewPasswordVersion, + PasswordHashFailure +} diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs new file mode 100644 index 000000000..9899d3a38 --- /dev/null +++ b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; + +public class PasswordChangeRequest +{ + public List OldVersionedPasswords { get; set; } + public VersionedPassword NewVersionedPassword { get; set; } + public byte[] EncryptedMasterKey { get; set; } + public byte[] Nonce { get; set; } + + [JsonConstructor] + public PasswordChangeRequest(List oldVersionedPasswords, VersionedPassword newVersionedPassword, byte[] encryptedMasterKey, byte[] nonce) + { + OldVersionedPasswords = oldVersionedPasswords; + NewVersionedPassword = newVersionedPassword; + EncryptedMasterKey = encryptedMasterKey; + Nonce = nonce; + } +} diff --git a/Crypter.Core/Features/Keys/Commands/UpsertMasterKeyCommand.cs b/Crypter.Core/Features/Keys/Commands/UpsertMasterKeyCommand.cs index 83dd797eb..8b28fd552 100644 --- a/Crypter.Core/Features/Keys/Commands/UpsertMasterKeyCommand.cs +++ b/Crypter.Core/Features/Keys/Commands/UpsertMasterKeyCommand.cs @@ -63,8 +63,7 @@ public UpsertMasterKeyCommandHandler( public async Task> Handle(UpsertMasterKeyCommand request, CancellationToken cancellationToken) { - if (!MasterKeyValidators.ValidateMasterKeyInformation(request.Data.EncryptedKey, request.Data.Nonce, - request.Data.RecoveryProof)) + if (!MasterKeyValidators.ValidateMasterKeyInformation(request.Data.EncryptedKey, request.Data.Nonce, request.Data.RecoveryProof)) { return InsertMasterKeyError.InvalidMasterKey; } diff --git a/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs new file mode 100644 index 000000000..b53cabf6e --- /dev/null +++ b/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Primitives; +using Crypter.Core.Identity; +using Crypter.Core.MediatorMonads; +using Crypter.Core.Services; +using Crypter.DataAccess; +using Crypter.DataAccess.Entities; +using EasyMonads; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Unit = EasyMonads.Unit; + +namespace Crypter.Core.Features.UserAuthentication.Commands; + +public sealed record ChangeUserPasswordCommand(Guid UserId, PasswordChangeRequest Request) + : IEitherRequest; + +internal class ChangeUserPasswordCommandHandler + : IEitherRequestHandler +{ + private readonly DataContext _dataContext; + private readonly IPasswordHashService _passwordHashService; + private readonly ServerPasswordSettings _serverPasswordSettings; + + + public ChangeUserPasswordCommandHandler(DataContext dataContext, IPasswordHashService passwordHashService, IOptions serverPasswordSettings) + { + _dataContext = dataContext; + _passwordHashService = passwordHashService; + _serverPasswordSettings = serverPasswordSettings.Value; + } + + public async Task> Handle(ChangeUserPasswordCommand request, CancellationToken cancellationToken) + { + return await ValidatePasswordChangeRequest(request.Request) + .BindAsync(async validPasswordChangeRequest => await ( + from foundUser in GetUserAsync(request.UserId) + from passwordVerificationSuccess in VerifyAndChangePasswordAsync(validPasswordChangeRequest, foundUser).AsTask() + from updateMasterKeyResult in Either.From(UpdateUserMasterKey(foundUser, request.Request.EncryptedMasterKey, request.Request.Nonce)).AsTask() + from _ in Either.FromRightAsync(SaveChangesAsync()) + select Unit.Default) + ); + } + + private readonly struct ValidPasswordChangeRequest(IDictionary oldVersionedPasswords, byte[] newVersionedPassword) + { + public IDictionary OldVersionedPasswords { get; } = oldVersionedPasswords; + public byte[] NewVersionedPassword { get; } = newVersionedPassword; + } + + private Either ValidatePasswordChangeRequest(PasswordChangeRequest request) + { + if (request.NewVersionedPassword.Version != _serverPasswordSettings.ClientVersion) + { + return PasswordChangeError.InvalidNewPasswordVersion; + } + + return GetValidClientPasswords(request.OldVersionedPasswords) + .Map(x => new ValidPasswordChangeRequest(x, request.NewVersionedPassword.Password)); + } + + private async Task> GetUserAsync(Guid userId) + { + UserEntity? foundUser = await _dataContext.Users + .Include(x => x.MasterKey) + .Where(x => x.Id == userId) + .FirstOrDefaultAsync(); + + if (foundUser is null) + { + return PasswordChangeError.UnknownError; + } + + return foundUser; + } + + private Either VerifyAndChangePasswordAsync(ValidPasswordChangeRequest validChangePasswordRequest, UserEntity userEntity) + { + bool requestContainsUserClientPasswordVersion = validChangePasswordRequest.OldVersionedPasswords.ContainsKey(userEntity.ClientPasswordVersion); + if (!requestContainsUserClientPasswordVersion) + { + return PasswordChangeError.InvalidOldPasswordVersion; + } + + // Get the appropriate 'old' password for the user, based on the saved 'ClientPasswordVersion' for the user. + // Then hash that password based on the saved 'ServerPasswordVersion' for the user. + byte[] currentClientPassword = validChangePasswordRequest.OldVersionedPasswords[userEntity.ClientPasswordVersion]; + bool isMatchingPassword = AuthenticationPassword.TryFrom(currentClientPassword, out AuthenticationPassword validOldPassword) + && _passwordHashService.VerifySecurePasswordHash(validOldPassword, userEntity.PasswordHash, userEntity.PasswordSalt, userEntity.ServerPasswordVersion); + + // If the hashes do not match, then the wrong password was provided. + if (!isMatchingPassword) + { + return PasswordChangeError.InvalidPassword; + } + + // Now change the password to the newly provided password + if (!AuthenticationPassword.TryFrom(validChangePasswordRequest.NewVersionedPassword, out AuthenticationPassword validNewPassword)) + { + return PasswordChangeError.InvalidPassword; + } + + SecurePasswordHashOutput hashOutput = _passwordHashService.MakeSecurePasswordHash(validNewPassword, _passwordHashService.LatestServerPasswordVersion); + + userEntity.PasswordHash = hashOutput.Hash; + userEntity.PasswordSalt = hashOutput.Salt; + userEntity.ServerPasswordVersion = _passwordHashService.LatestServerPasswordVersion; + userEntity.ClientPasswordVersion = _serverPasswordSettings.ClientVersion; + + return Unit.Default; + } + + private Either> GetValidClientPasswords(List clientPasswords) + { + bool someHasInvalidClientPasswordVersion = clientPasswords.Any(x => x.Version > _serverPasswordSettings.ClientVersion || x.Version < 0); + if (someHasInvalidClientPasswordVersion) + { + return PasswordChangeError.InvalidOldPasswordVersion; + } + + bool duplicateVersionsProvided = clientPasswords.GroupBy(x => x.Version).Any(x => x.Count() > 1); + if (duplicateVersionsProvided) + { + return PasswordChangeError.InvalidOldPasswordVersion; + } + + return clientPasswords.ToDictionary(x => x.Version, x => x.Password); + } + + private Unit UpdateUserMasterKey(UserEntity userEntity, byte[] encryptedMasterKey, byte[] nonce) + { + userEntity.MasterKey!.EncryptedKey = encryptedMasterKey; + userEntity.MasterKey!.Nonce = nonce; + userEntity.MasterKey!.Updated = DateTime.UtcNow; + return Unit.Default; + } + + private async Task SaveChangesAsync() + { + await _dataContext.SaveChangesAsync(); + return Unit.Default; + } +} diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 005a53f3f..752d3eed9 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -87,84 +87,60 @@ public async Task> Handle(UserLoginCommand req { return await ValidateLoginRequest(request.Request) .BindAsync(async validLoginRequest => await ( - from fetchSuccess in FetchUserAsync(validLoginRequest) - from passwordVerificationSuccess in VerifyAndUpgradePassword(validLoginRequest, _foundUserEntity!).AsTask() + from foundUser in GetUserAsync(validLoginRequest) + from passwordVerificationSuccess in VerifyAndUpgradePassword(validLoginRequest, foundUser).AsTask() from loginResponse in Either.FromRightAsync( - CreateLoginResponseAsync(_foundUserEntity!, validLoginRequest.RefreshTokenType, + CreateLoginResponseAsync(foundUser, validLoginRequest.RefreshTokenType, request.DeviceDescription)) select loginResponse) ) .DoRightAsync(async _ => { - SuccessfulUserLoginEvent successfulUserLoginEvent = new SuccessfulUserLoginEvent(_foundUserEntity!.Id, - request.DeviceDescription, _foundUserEntity.LastLogin); + SuccessfulUserLoginEvent successfulUserLoginEvent = new SuccessfulUserLoginEvent(_foundUserEntity!.Id, request.DeviceDescription, _foundUserEntity.LastLogin); await _publisher.Publish(successfulUserLoginEvent, CancellationToken.None); }) - .DoLeftOrNeitherAsync( - async error => - { - FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, - error, request.DeviceDescription, DateTimeOffset.UtcNow); - await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); + .DoLeftOrNeitherAsync(async error => + { + FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, error, request.DeviceDescription, DateTimeOffset.UtcNow); + await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); - if (error == LoginError.InvalidPassword) - { - IncorrectPasswordProvidedEvent incorrectPasswordProvidedEvent = - new IncorrectPasswordProvidedEvent(_foundUserEntity!.Id); - await _publisher.Publish(incorrectPasswordProvidedEvent, CancellationToken.None); - } - }, - async () => + if (error == LoginError.InvalidPassword) { - FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, - LoginError.UnknownError, request.DeviceDescription, DateTimeOffset.UtcNow); - await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); - }); + IncorrectPasswordProvidedEvent incorrectPasswordProvidedEvent = new IncorrectPasswordProvidedEvent(_foundUserEntity!.Id); + await _publisher.Publish(incorrectPasswordProvidedEvent, CancellationToken.None); + } + }, + async () => + { + FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, LoginError.UnknownError, request.DeviceDescription, DateTimeOffset.UtcNow); + await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); + }); } - private readonly struct ValidLoginRequest( - Username username, - IDictionary versionedPasswords, - TokenType refreshTokenType) + private readonly struct ValidLoginRequest(Username username, IDictionary versionedPasswords, TokenType refreshTokenType) { public Username Username { get; } = username; - public IDictionary VersionedPasswords { get; } = versionedPasswords; + public IDictionary VersionedPasswords { get; } = versionedPasswords; public TokenType RefreshTokenType { get; } = refreshTokenType; } private Either ValidateLoginRequest(LoginRequest request) { - if (request.VersionedPasswords.All(x => x.Version != _clientPasswordVersion)) - { - return LoginError.InvalidPasswordVersion; - } - - if (!Username.TryFrom(request.Username, out var validUsername)) + if (!Username.TryFrom(request.Username, out Username? validUsername)) { return LoginError.InvalidUsername; } - Dictionary validVersionedPasswords = new Dictionary(request.VersionedPasswords.Count); - foreach (VersionedPassword versionedPassword in request.VersionedPasswords) - { - if (versionedPassword.Version > _clientPasswordVersion || versionedPassword.Version < 0 || - validVersionedPasswords.ContainsKey(versionedPassword.Version)) - { - return LoginError.InvalidPasswordVersion; - } - - validVersionedPasswords.Add(versionedPassword.Version, versionedPassword.Password); - } - if (!_refreshTokenProviderMap.ContainsKey(request.RefreshTokenType)) { return LoginError.InvalidTokenTypeRequested; } - return new ValidLoginRequest(validUsername, validVersionedPasswords, request.RefreshTokenType); + return GetValidClientPasswords(request.VersionedPasswords) + .Map(x => new ValidLoginRequest(validUsername, x, request.RefreshTokenType)); } - private async Task> FetchUserAsync(ValidLoginRequest validLoginRequest) + private async Task> GetUserAsync(ValidLoginRequest validLoginRequest) { _foundUserEntity = await _dataContext.Users .Where(x => x.Username == validLoginRequest.Username.Value) @@ -184,49 +160,41 @@ private async Task> FetchUserAsync(ValidLoginRequest va return LoginError.ExcessiveFailedLoginAttempts; } - return Unit.Default; + return _foundUserEntity; } - private Either VerifyAndUpgradePassword( - ValidLoginRequest validLoginRequest, - UserEntity userEntity) + private Either VerifyAndUpgradePassword(ValidLoginRequest validLoginRequest, UserEntity userEntity) { - bool requestContainsRequiredPasswordVersions = - validLoginRequest.VersionedPasswords.ContainsKey(userEntity.ClientPasswordVersion) - && validLoginRequest.VersionedPasswords.ContainsKey(_clientPasswordVersion); + bool requestContainsRequiredPasswordVersions = validLoginRequest.VersionedPasswords.ContainsKey(userEntity.ClientPasswordVersion) + && validLoginRequest.VersionedPasswords.ContainsKey(_clientPasswordVersion); if (!requestContainsRequiredPasswordVersions) { return LoginError.InvalidPasswordVersion; } + // Get the appropriate 'existing' password for the user, based on the saved 'ClientPasswordVersion' for the user. + // Then hash that password based on the saved 'ServerPasswordVersion' for the user. byte[] currentClientPassword = validLoginRequest.VersionedPasswords[userEntity.ClientPasswordVersion]; - bool isMatchingPassword = AuthenticationPassword.TryFrom( - currentClientPassword, - out AuthenticationPassword validAuthenticationPassword) - && _passwordHashService.VerifySecurePasswordHash( - validAuthenticationPassword, - userEntity.PasswordHash, - userEntity.PasswordSalt, - userEntity.ServerPasswordVersion); + bool isMatchingPassword = AuthenticationPassword.TryFrom(currentClientPassword, out AuthenticationPassword validAuthenticationPassword) + && _passwordHashService.VerifySecurePasswordHash(validAuthenticationPassword, userEntity.PasswordHash, userEntity.PasswordSalt, userEntity.ServerPasswordVersion); if (!isMatchingPassword) { return LoginError.InvalidPassword; } - if (userEntity.ServerPasswordVersion != _passwordHashService.LatestServerPasswordVersion - || userEntity.ClientPasswordVersion != _clientPasswordVersion) + // Now handle the case where even though the provided password is correct + // the password must be upgraded to the latest 'ClientPasswordVersion' or 'ServerPasswordVersion' + bool serverPasswordVersionIsOld = userEntity.ServerPasswordVersion != _passwordHashService.LatestServerPasswordVersion; + bool clientPasswordVersionIsOld = userEntity.ClientPasswordVersion != _clientPasswordVersion; + if (serverPasswordVersionIsOld || clientPasswordVersionIsOld) { byte[] latestClientPassword = validLoginRequest.VersionedPasswords[_clientPasswordVersion]; - if (!AuthenticationPassword.TryFrom( - latestClientPassword, - out AuthenticationPassword latestValidAuthenticationPassword)) + if (!AuthenticationPassword.TryFrom(latestClientPassword, out AuthenticationPassword latestValidAuthenticationPassword)) { return LoginError.InvalidPassword; } - SecurePasswordHashOutput hashOutput = _passwordHashService.MakeSecurePasswordHash( - latestValidAuthenticationPassword, - _passwordHashService.LatestServerPasswordVersion); + SecurePasswordHashOutput hashOutput = _passwordHashService.MakeSecurePasswordHash(latestValidAuthenticationPassword, _passwordHashService.LatestServerPasswordVersion); userEntity.PasswordHash = hashOutput.Hash; userEntity.PasswordSalt = hashOutput.Salt; @@ -263,4 +231,27 @@ private async Task CreateLoginResponseAsync(UserEntity userEntity return new LoginResponse(userEntity.Username, authToken, refreshToken.Token, userNeedsNewKeys, !userHasConsentedToRecoveryKeyRisks); } + + private Either> GetValidClientPasswords(List clientPasswords) + { + bool noneMatchingCurrentClientPasswordVersion = clientPasswords.All(x => x.Version != _clientPasswordVersion); + if (noneMatchingCurrentClientPasswordVersion) + { + return LoginError.InvalidPasswordVersion; + } + + bool someHasInvalidClientPasswordVersion = clientPasswords.Any(x => x.Version > _clientPasswordVersion || x.Version < 0); + if (someHasInvalidClientPasswordVersion) + { + return LoginError.InvalidPasswordVersion; + } + + bool duplicateVersionsProvided = clientPasswords.GroupBy(x => x.Version).Any(x => x.Count() > 1); + if (duplicateVersionsProvided) + { + return LoginError.InvalidPasswordVersion; + } + + return clientPasswords.ToDictionary(x => x.Version, x => x.Password); + } } diff --git a/Crypter.Core/Features/UserAuthentication/Common.cs b/Crypter.Core/Features/UserAuthentication/Common.cs index f27feb0a2..b5f145209 100644 --- a/Crypter.Core/Features/UserAuthentication/Common.cs +++ b/Crypter.Core/Features/UserAuthentication/Common.cs @@ -25,6 +25,8 @@ */ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Crypter.Common.Contracts.Features.UserAuthentication; diff --git a/Crypter.Test/Integration_Tests/TestData.cs b/Crypter.Test/Integration_Tests/TestData.cs index 29bf960be..701e6a414 100644 --- a/Crypter.Test/Integration_Tests/TestData.cs +++ b/Crypter.Test/Integration_Tests/TestData.cs @@ -115,55 +115,64 @@ internal static byte[] DefaultKeyExchangeNonce internal static (Func?, EncryptionStream> encryptionStreamOpener, byte[] proof) GetDefaultEncryptionStream() { DefaultCryptoProvider cryptoProvider = new DefaultCryptoProvider(); - (byte[] encryptionKey, byte[] proof) = cryptoProvider.KeyExchange.GenerateEncryptionKey( - cryptoProvider.StreamEncryptionFactory.KeySize, DefaultPrivateKey, AlternatePublicKey, - DefaultKeyExchangeNonce); + (byte[] encryptionKey, byte[] proof) = cryptoProvider.KeyExchange.GenerateEncryptionKey(cryptoProvider.StreamEncryptionFactory.KeySize, DefaultPrivateKey, AlternatePublicKey, DefaultKeyExchangeNonce); return (EncryptionStreamOpener, proof); - + MemoryStream PlaintextStreamOpener() => new MemoryStream(DefaultTransferBytes); - + EncryptionStream EncryptionStreamOpener(Action? updateCallback = null) { - return new EncryptionStream(PlaintextStreamOpener, DefaultTransferBytes.Length, encryptionKey, cryptoProvider.StreamEncryptionFactory, - 128, 64, updateCallback); + return new EncryptionStream(PlaintextStreamOpener, DefaultTransferBytes.Length, encryptionKey, cryptoProvider.StreamEncryptionFactory, 128, 64, updateCallback); } } - internal static RegistrationRequest GetRegistrationRequest(string username, string password, - string? emailAddress = null) + internal static RegistrationRequest GetRegistrationRequest(string username, string password, string? emailAddress = null) { byte[] passwordBytes = Encoding.UTF8.GetBytes(password); VersionedPassword versionedPassword = new VersionedPassword(passwordBytes, 1); return new RegistrationRequest(username, versionedPassword, emailAddress); } - internal static LoginRequest GetLoginRequest(string username, string password, - TokenType tokenType = TokenType.Session) + internal static LoginRequest GetLoginRequest(string username, string password, TokenType tokenType = TokenType.Session) { byte[] passwordBytes = Encoding.UTF8.GetBytes(password); VersionedPassword versionedPassword = new VersionedPassword(passwordBytes, 1); return new LoginRequest(username, [versionedPassword], tokenType); } - internal static (byte[] masterKey, InsertMasterKeyRequest request) GetInsertMasterKeyRequest(string password) + internal static VersionedPassword GetVersionedPassword(string password, short version) { - Random random = new Random(); - byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + return new VersionedPassword(passwordBytes, version); + } + internal static (byte[] masterKey, byte[] nonce) GetRandomMasterKey() + { + Random random = new Random(); + byte[] randomBytesMasterKey = new byte[32]; random.NextBytes(randomBytesMasterKey); byte[] randomBytesNonce = new byte[32]; random.NextBytes(randomBytesNonce); + return (randomBytesMasterKey, randomBytesNonce); + } + + internal static (byte[] masterKey, InsertMasterKeyRequest request) GetInsertMasterKeyRequest(string password) + { + Random random = new Random(); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + + (byte[] masterKey, byte[] nonce) = GetRandomMasterKey(); + byte[] randomBytesRecoveryProof = new byte[32]; random.NextBytes(randomBytesRecoveryProof); - InsertMasterKeyRequest request = new InsertMasterKeyRequest(passwordBytes, randomBytesMasterKey, - randomBytesNonce, randomBytesRecoveryProof); - return (randomBytesMasterKey, request); + InsertMasterKeyRequest request = new InsertMasterKeyRequest(passwordBytes, masterKey, nonce, randomBytesRecoveryProof); + return (masterKey, request); } internal static InsertKeyPairRequest GetInsertKeyPairRequest() diff --git a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/Login_Tests.cs b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/Login_Tests.cs index 5b0bdf1e4..7fbd3f06d 100644 --- a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/Login_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/Login_Tests.cs @@ -61,8 +61,7 @@ public async Task TeardownTestAsync() [Test] public async Task Login_Works() { - RegistrationRequest registrationRequest = - TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); @@ -84,8 +83,7 @@ public async Task Login_Fails_Invalid_Username() [Test] public async Task Login_Fails_Invalid_Password() { - RegistrationRequest registrationRequest = - TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); @@ -100,14 +98,12 @@ public async Task Login_Fails_Invalid_Password() [Test] public async Task Login_Fails_Invalid_Password_Version() { - RegistrationRequest registrationRequest = - TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); VersionedPassword correctPassword = loginRequest.VersionedPasswords.First(); - VersionedPassword invalidPassword = - new VersionedPassword(correctPassword.Password, (short)(correctPassword.Version - 1)); + VersionedPassword invalidPassword = new VersionedPassword(correctPassword.Password, (short)(correctPassword.Version - 1)); loginRequest.VersionedPasswords = [invalidPassword]; Either result = await _client!.UserAuthentication.LoginAsync(loginRequest); diff --git a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChallenge_Tests.cs b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChallenge_Tests.cs index 86ddbcbe9..e4162988d 100644 --- a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChallenge_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChallenge_Tests.cs @@ -66,12 +66,10 @@ public async Task TeardownTestAsync() [Test] public async Task Password_Challenge_Works() { - RegistrationRequest registrationRequest = - TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); - LoginRequest loginRequest = - TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); + LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); await loginResult.DoRightAsync(async loginResponse => diff --git a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs new file mode 100644 index 000000000..26bcc8fb6 --- /dev/null +++ b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Crypter.Common.Client.Interfaces.HttpClients; +using Crypter.Common.Client.Interfaces.Repositories; +using Crypter.Common.Contracts.Features.Keys; +using Crypter.Common.Contracts.Features.UserAuthentication; +using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Enums; +using Crypter.DataAccess; +using Crypter.DataAccess.Entities; +using EasyMonads; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Crypter.Test.Integration_Tests.UserAuthentication_Tests; + +[TestFixture] +internal class PasswordChange_Tests +{ + private WebApplicationFactory? _factory; + private ICrypterApiClient? _client; + private ITokenRepository? _clientTokenRepository; + private IServiceScope? _scope; + private DataContext? _dataContext; + + [SetUp] + public async Task SetupTestAsync() + { + _factory = await AssemblySetup.CreateWebApplicationFactoryAsync(); + (_client, _clientTokenRepository) = AssemblySetup.SetupCrypterApiClient(_factory.CreateClient()); + await AssemblySetup.InitializeRespawnerAsync(); + + _scope = _factory.Services.CreateScope(); + _dataContext = _scope.ServiceProvider.GetRequiredService(); + } + + [TearDown] + public async Task TeardownTestAsync() + { + _scope?.Dispose(); + if (_factory is not null) + { + await _factory.DisposeAsync(); + } + await AssemblySetup.ResetServerDataAsync(); + } + + [Test] + public async Task Password_Change_Works() + { + const string updatedPassword = "new password"; + + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); + + LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); + + await loginResult.DoRightAsync(async loginResponse => + { + await _clientTokenRepository!.StoreAuthenticationTokenAsync(loginResponse.AuthenticationToken); + await _clientTokenRepository!.StoreRefreshTokenAsync(loginResponse.RefreshToken, TokenType.Session); + }); + + (_, InsertMasterKeyRequest masterKeyRequest) = TestData.GetInsertMasterKeyRequest(TestData.DefaultPassword); + Either masterKeyResult = await _client!.UserKey.InsertMasterKeyAsync(masterKeyRequest); + + List oldPasswords = [TestData.GetVersionedPassword(TestData.DefaultPassword, 1)]; + VersionedPassword newPassword = TestData.GetVersionedPassword(updatedPassword, 1); + (byte[] masterKey, byte[] nonce) = TestData.GetRandomMasterKey(); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword, masterKey, nonce); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + UserMasterKeyEntity masterKeyEntity = await _dataContext!.UserMasterKeys + .Where(x => x.User!.Username == TestData.DefaultUsername) + .FirstAsync(); + + LoginRequest newLoginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, updatedPassword); + Either newLoginResult = await _client!.UserAuthentication.LoginAsync(newLoginRequest); + + Assert.That(registrationResult.IsRight, Is.True); + Assert.That(loginResult.IsRight, Is.True); + Assert.That(masterKeyResult.IsRight, Is.True); + Assert.That(result.IsRight, Is.True); + Assert.That(masterKeyEntity.EncryptedKey, Is.Not.EqualTo(masterKeyRequest.EncryptedKey)); + Assert.That(masterKeyEntity.Nonce, Is.Not.EqualTo(masterKeyRequest.Nonce)); + Assert.That(masterKeyEntity.EncryptedKey, Is.EqualTo(masterKey)); + Assert.That(masterKeyEntity.Nonce, Is.EqualTo(nonce)); + Assert.That(newLoginResult.IsRight, Is.True); + } + + [Test] + public async Task Password_Change_Implicitly_Upgrades_Client_Password_Version() + { + const string updatedPassword = "new password"; + + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + await _client!.UserAuthentication.RegisterAsync(registrationRequest); + + LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); + + await loginResult.DoRightAsync(async loginResponse => + { + await _clientTokenRepository!.StoreAuthenticationTokenAsync(loginResponse.AuthenticationToken); + await _clientTokenRepository!.StoreRefreshTokenAsync(loginResponse.RefreshToken, TokenType.Session); + }); + + (_, InsertMasterKeyRequest masterKeyRequest) = TestData.GetInsertMasterKeyRequest(TestData.DefaultPassword); + Either masterKeyResult = await _client!.UserKey.InsertMasterKeyAsync(masterKeyRequest); + + UserEntity userEntity = await _dataContext!.Users + .AsTracking() + .Where(x => x.Username == TestData.DefaultUsername) + .FirstAsync(); + + userEntity.ClientPasswordVersion = 0; + await _dataContext.SaveChangesAsync(); + + List oldPasswords = [TestData.GetVersionedPassword(TestData.DefaultPassword, 0)]; + VersionedPassword newPassword = TestData.GetVersionedPassword(updatedPassword, 1); + (byte[] masterKey, byte[] nonce) = TestData.GetRandomMasterKey(); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword, masterKey, nonce); + await _client!.UserAuthentication.ChangePasswordAsync(request); + + IServiceScope newScope = _factory!.Services.CreateScope(); + DataContext newDataContext = newScope.ServiceProvider.GetRequiredService(); + + UserEntity newUserEntity = await newDataContext.Users + .Where(x => x.Username == TestData.DefaultUsername) + .FirstAsync(); + + Assert.That(newUserEntity.ClientPasswordVersion, Is.EqualTo(1)); + } + + [Test] + public async Task Password_Change_Rejects_Incorrect_Old_Password() + { + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); + + LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); + + await loginResult.DoRightAsync(async loginResponse => + { + await _clientTokenRepository!.StoreAuthenticationTokenAsync(loginResponse.AuthenticationToken); + await _clientTokenRepository!.StoreRefreshTokenAsync(loginResponse.RefreshToken, TokenType.Session); + }); + + (_, InsertMasterKeyRequest masterKeyRequest) = TestData.GetInsertMasterKeyRequest(TestData.DefaultPassword); + Either masterKeyResult = await _client!.UserKey.InsertMasterKeyAsync(masterKeyRequest); + + List oldPasswords = [TestData.GetVersionedPassword("not the right password", 1)]; + VersionedPassword newPassword = TestData.GetVersionedPassword("new password", 1); + (byte[] masterKey, byte[] nonce) = TestData.GetRandomMasterKey(); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword, masterKey, nonce); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + UserMasterKeyEntity masterKeyEntity = await _dataContext!.UserMasterKeys + .Where(x => x.User!.Username == TestData.DefaultUsername) + .FirstAsync(); + + Assert.That(registrationResult.IsRight, Is.True); + Assert.That(loginResult.IsRight, Is.True); + Assert.That(result.IsLeft, Is.True); + Assert.That(masterKeyEntity.EncryptedKey, Is.EqualTo(masterKeyRequest.EncryptedKey)); + Assert.That(masterKeyEntity.Nonce, Is.EqualTo(masterKeyRequest.Nonce)); + } + + [Test] + public async Task Password_Change_Rejects_Incorrect_New_Password_Version() + { + RegistrationRequest registrationRequest = TestData.GetRegistrationRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either registrationResult = await _client!.UserAuthentication.RegisterAsync(registrationRequest); + + LoginRequest loginRequest = TestData.GetLoginRequest(TestData.DefaultUsername, TestData.DefaultPassword); + Either loginResult = await _client!.UserAuthentication.LoginAsync(loginRequest); + + await loginResult.DoRightAsync(async loginResponse => + { + await _clientTokenRepository!.StoreAuthenticationTokenAsync(loginResponse.AuthenticationToken); + await _clientTokenRepository!.StoreRefreshTokenAsync(loginResponse.RefreshToken, TokenType.Session); + }); + + (_, InsertMasterKeyRequest masterKeyRequest) = TestData.GetInsertMasterKeyRequest(TestData.DefaultPassword); + Either masterKeyResult = await _client!.UserKey.InsertMasterKeyAsync(masterKeyRequest); + + List oldPasswords = [TestData.GetVersionedPassword(TestData.DefaultPassword, 0)]; + VersionedPassword newPassword = TestData.GetVersionedPassword("new password", 1); + (byte[] masterKey, byte[] nonce) = TestData.GetRandomMasterKey(); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword, masterKey, nonce); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + UserMasterKeyEntity masterKeyEntity = await _dataContext!.UserMasterKeys + .Where(x => x.User!.Username == TestData.DefaultUsername) + .FirstAsync(); + + Assert.That(registrationResult.IsRight, Is.True); + Assert.That(loginResult.IsRight, Is.True); + Assert.That(result.IsLeft, Is.True); + Assert.That(masterKeyEntity.EncryptedKey, Is.EqualTo(masterKeyRequest.EncryptedKey)); + Assert.That(masterKeyEntity.Nonce, Is.EqualTo(masterKeyRequest.Nonce)); + } +} diff --git a/Crypter.Web/Client/Program.cs b/Crypter.Web/Client/Program.cs index 4ea6eb90e..90f2a634e 100644 --- a/Crypter.Web/Client/Program.cs +++ b/Crypter.Web/Client/Program.cs @@ -102,6 +102,7 @@ .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton>(sp => sp.GetRequiredService); diff --git a/Crypter.Web/Pages/Authenticated/UserSettings.razor b/Crypter.Web/Pages/Authenticated/UserSettings.razor index dab23ca6d..bd72ed908 100644 --- a/Crypter.Web/Pages/Authenticated/UserSettings.razor +++ b/Crypter.Web/Pages/Authenticated/UserSettings.razor @@ -1,5 +1,5 @@ @* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2024 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -48,7 +48,7 @@