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/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/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..d55994a2c --- /dev/null +++ b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs @@ -0,0 +1,43 @@ +/* + * 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; } + + [JsonConstructor] + public PasswordChangeRequest(List oldVersionedPasswords, VersionedPassword newVersionedPassword) + { + OldVersionedPasswords = oldVersionedPasswords; + NewVersionedPassword = newVersionedPassword; + } +} 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..6b338dc56 --- /dev/null +++ b/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs @@ -0,0 +1,168 @@ +/* + * 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 MediatR; +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 IPublisher _publisher; + private readonly ServerPasswordSettings _serverPasswordSettings; + + + public ChangeUserPasswordCommandHandler( + DataContext dataContext, + IPasswordHashService passwordHashService, + IPublisher publisher, + IOptions serverPasswordSettings) + { + _dataContext = dataContext; + _passwordHashService = passwordHashService; + _publisher = publisher; + _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) + 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 + .Where(x => x.Id == userId) + .FirstOrDefaultAsync(); + + if (foundUser is null) + { + return PasswordChangeError.UnknownError; + } + + return foundUser; + } + + private async Task> VerifyAndChangePasswordAsync(ValidPasswordChangeRequest validChangePasswordRequest, UserEntity userEntity) + { + bool requestContainsRequiredOldPasswordVersions = validChangePasswordRequest.OldVersionedPasswords.ContainsKey(userEntity.ClientPasswordVersion) + && validChangePasswordRequest.OldVersionedPasswords.ContainsKey(_serverPasswordSettings.ClientVersion); + if (!requestContainsRequiredOldPasswordVersions) + { + 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; + + await _dataContext.SaveChangesAsync(); + + 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); + } +} 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..ffe419a00 100644 --- a/Crypter.Test/Integration_Tests/TestData.cs +++ b/Crypter.Test/Integration_Tests/TestData.cs @@ -115,37 +115,38 @@ 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 VersionedPassword GetVersionedPassword(string password, short version) + { + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + return new VersionedPassword(passwordBytes, version); + } + internal static (byte[] masterKey, InsertMasterKeyRequest request) GetInsertMasterKeyRequest(string password) { Random random = new Random(); @@ -161,8 +162,7 @@ internal static (byte[] masterKey, InsertMasterKeyRequest request) GetInsertMast byte[] randomBytesRecoveryProof = new byte[32]; random.NextBytes(randomBytesRecoveryProof); - InsertMasterKeyRequest request = new InsertMasterKeyRequest(passwordBytes, randomBytesMasterKey, - randomBytesNonce, randomBytesRecoveryProof); + InsertMasterKeyRequest request = new InsertMasterKeyRequest(passwordBytes, randomBytesMasterKey, randomBytesNonce, randomBytesRecoveryProof); return (randomBytesMasterKey, request); } 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..1e0083e74 --- /dev/null +++ b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs @@ -0,0 +1,193 @@ +/* + * 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.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); + }); + + List oldPasswords = [TestData.GetVersionedPassword(TestData.DefaultPassword, 1)]; + VersionedPassword newPassword = TestData.GetVersionedPassword(updatedPassword, 1); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + 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(result.IsRight, Is.True); + 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); + }); + + 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); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword); + await _client!.UserAuthentication.ChangePasswordAsync(request); + + UserEntity newUserEntity = await _dataContext.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); + }); + + List oldPasswords = [TestData.GetVersionedPassword("not the right password", 1)]; + VersionedPassword newPassword = TestData.GetVersionedPassword("new password", 1); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + Assert.That(registrationResult.IsRight, Is.True); + Assert.That(loginResult.IsRight, Is.True); + Assert.That(result.IsLeft, Is.True); + } + + [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); + }); + + List oldPasswords = [TestData.GetVersionedPassword(TestData.DefaultPassword, 0)]; + VersionedPassword newPassword = TestData.GetVersionedPassword("new password", 1); + PasswordChangeRequest request = new PasswordChangeRequest(oldPasswords, newPassword); + Either result = await _client!.UserAuthentication.ChangePasswordAsync(request); + + Assert.That(registrationResult.IsRight, Is.True); + Assert.That(loginResult.IsRight, Is.True); + Assert.That(result.IsLeft, Is.True); + } +}