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);
+ }
+}