Skip to content

Commit

Permalink
Add endpoint to support password changes
Browse files Browse the repository at this point in the history
  • Loading branch information
Jack-Edwards committed Oct 13, 2024
1 parent f64c405 commit e5e5351
Show file tree
Hide file tree
Showing 13 changed files with 571 additions and 102 deletions.
38 changes: 36 additions & 2 deletions Crypter.API/Controllers/UserAuthenticationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -208,6 +208,40 @@ IActionResult MakeErrorResponse(PasswordChallengeError error)
MakeErrorResponse(PasswordChallengeError.UnknownError));
}

/// <summary>
/// Handle a request to change the password for an authorized user
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost("password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(void))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))]
public async Task<IActionResult> 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));
}

/// <summary>
/// Clears the provided refresh token from the database, ensuring it cannot be used for subsequent requests.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,14 +78,20 @@ public async Task<Either<RefreshError, RefreshResponse>> RefreshSessionAsync()
return response;
}

public Task<Either<PasswordChallengeError, Unit>> PasswordChallengeAsync(
PasswordChallengeRequest testPasswordRequest)
public Task<Either<PasswordChallengeError, Unit>> PasswordChallengeAsync(PasswordChallengeRequest testPasswordRequest)
{
const string url = "api/user/authentication/password/challenge";
return _crypterAuthenticatedHttpClient.PostEitherUnitResponseAsync(url, testPasswordRequest)
.ExtractErrorCode<PasswordChallengeError, Unit>();
}

public Task<Either<PasswordChangeError, Unit>> ChangePasswordAsync(PasswordChangeRequest passwordChangeRequest)
{
const string url = "api/user/authentication/password";
return _crypterAuthenticatedHttpClient.PostEitherUnitResponseAsync(url, passwordChangeRequest)
.ExtractErrorCode<PasswordChangeError, Unit>();
}

public Task<Either<LogoutError, Unit>> LogoutAsync()
{
const string url = "api/user/authentication/logout";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,5 +39,6 @@ public interface IUserAuthenticationRequests
Task<Either<LoginError, LoginResponse>> LoginAsync(LoginRequest loginRequest);
Task<Either<RefreshError, RefreshResponse>> RefreshSessionAsync();
Task<Either<PasswordChallengeError, Unit>> PasswordChallengeAsync(PasswordChallengeRequest testPasswordRequest);
Task<Either<PasswordChangeError, Unit>> ChangePasswordAsync(PasswordChangeRequest passwordChangeRequest);
Task<Either<LogoutError, Unit>> LogoutAsync();
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* 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
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* 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<VersionedPassword> OldVersionedPasswords { get; set; }
public VersionedPassword NewVersionedPassword { get; set; }

[JsonConstructor]
public PasswordChangeRequest(List<VersionedPassword> oldVersionedPasswords, VersionedPassword newVersionedPassword)
{
OldVersionedPasswords = oldVersionedPasswords;
NewVersionedPassword = newVersionedPassword;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ public UpsertMasterKeyCommandHandler(

public async Task<Either<InsertMasterKeyError, Unit>> 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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* 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<PasswordChangeError, Unit>;

internal class ChangeUserPasswordCommandHandler
: IEitherRequestHandler<ChangeUserPasswordCommand, PasswordChangeError, Unit>
{
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> serverPasswordSettings)
{
_dataContext = dataContext;
_passwordHashService = passwordHashService;
_publisher = publisher;
_serverPasswordSettings = serverPasswordSettings.Value;
}

public async Task<Either<PasswordChangeError, Unit>> 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<short, byte[]> oldVersionedPasswords, byte[] newVersionedPassword)
{
public IDictionary<short, byte[]> OldVersionedPasswords { get; } = oldVersionedPasswords;
public byte[] NewVersionedPassword { get; } = newVersionedPassword;
}

private Either<PasswordChangeError, ValidPasswordChangeRequest> 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<Either<PasswordChangeError, UserEntity>> 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<Either<PasswordChangeError, Unit>> 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<PasswordChangeError, IDictionary<short, byte[]>> GetValidClientPasswords(List<VersionedPassword> 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);
}
}
Loading

0 comments on commit e5e5351

Please sign in to comment.