From d4541d5eac152ac9172bf66f2aed1d1e39c04f6e Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 20:49:27 -0500 Subject: [PATCH 01/10] Rebase --- .../UserAuthenticationController.cs | 12 +- .../Login/ChallengeResponse.cs | 40 +++++ .../UserAuthentication/Login/LoginError.cs | 3 +- .../UserAuthentication/Login/LoginRequest.cs | 22 ++- Crypter.Core/Crypter.Core.csproj | 1 + .../DeleteMultiFactorChallengeCommand.cs | 57 +++++++ .../SendMultiFactorVerificationCodeCommand.cs | 87 +++++++++++ .../Commands/UserLoginCommand.cs | 139 ++++++++++++++---- .../Email/EmailServiceTemplateExtensions.cs | 16 ++ .../Services/HangfireBackgroundService.cs | 30 +++- Crypter.Crypto.Common/Random/IRandom.cs | 1 + .../Wrappers/Random.cs | 8 + .../Wrappers/Random.cs | 6 + Crypter.DataAccess/DataContext.cs | 1 + Crypter.DataAccess/Entities/UserEntity.cs | 8 +- .../UserMultiFactorChallengeEntity.cs | 61 ++++++++ Crypter.Test/Integration_Tests/TestData.cs | 2 +- 17 files changed, 449 insertions(+), 45 deletions(-) create mode 100644 Crypter.Common/Contracts/Features/UserAuthentication/Login/ChallengeResponse.cs create mode 100644 Crypter.Core/Features/UserAuthentication/Commands/DeleteMultiFactorChallengeCommand.cs create mode 100644 Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs create mode 100644 Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs diff --git a/Crypter.API/Controllers/UserAuthenticationController.cs b/Crypter.API/Controllers/UserAuthenticationController.cs index d1a371fdb..8d8f19e9d 100644 --- a/Crypter.API/Controllers/UserAuthenticationController.cs +++ b/Crypter.API/Controllers/UserAuthenticationController.cs @@ -101,6 +101,7 @@ or RegistrationError.InvalidEmailAddress /// [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LoginResponse))] + [ProducesResponseType(StatusCodes.Status307TemporaryRedirect, Type = typeof(ChallengeResponse))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(ErrorResponse))] public async Task LoginAsync([FromBody] LoginRequest request) @@ -116,7 +117,8 @@ IActionResult MakeErrorResponse(LoginError error) or LoginError.InvalidPassword or LoginError.InvalidTokenTypeRequested or LoginError.ExcessiveFailedLoginAttempts - or LoginError.InvalidPasswordVersion => MakeErrorResponseBase(HttpStatusCode.BadRequest, error) + or LoginError.InvalidPasswordVersion + or LoginError.InvalidMultiFactorChallenge => MakeErrorResponseBase(HttpStatusCode.BadRequest, error), }; #pragma warning restore CS8524 } @@ -126,7 +128,9 @@ or LoginError.ExcessiveFailedLoginAttempts return await _sender.Send(command) .MatchAsync( MakeErrorResponse, - Ok, + x => x.Match( + y => RedirectPreserveMethod(y.ChallengeHash), // TODO figure out the real redirect url? + Ok), MakeErrorResponse(LoginError.UnknownError)); } @@ -161,7 +165,6 @@ IActionResult MakeErrorResponse(RefreshError error) #pragma warning restore CS8524 } - string requestUserAgent = HeadersParser.GetUserAgent(HttpContext.Request.Headers); RefreshUserSessionCommand request = new RefreshUserSessionCommand(User, requestUserAgent); return await _sender.Send(request) @@ -182,8 +185,7 @@ IActionResult MakeErrorResponse(RefreshError error) [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(void))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(void))] - public async Task PasswordChallengeAsync([FromBody] PasswordChallengeRequest request, - CancellationToken cancellationToken) + public async Task PasswordChallengeAsync([FromBody] PasswordChallengeRequest request, CancellationToken cancellationToken) { IActionResult MakeErrorResponse(PasswordChallengeError error) { diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/Login/ChallengeResponse.cs b/Crypter.Common/Contracts/Features/UserAuthentication/Login/ChallengeResponse.cs new file mode 100644 index 000000000..e8d58b976 --- /dev/null +++ b/Crypter.Common/Contracts/Features/UserAuthentication/Login/ChallengeResponse.cs @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 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.Text.Json.Serialization; + +namespace Crypter.Common.Contracts.Features.UserAuthentication; + +public class ChallengeResponse +{ + public string ChallengeHash { get; init; } + + [JsonConstructor] + public ChallengeResponse(string challengeHash) + { + ChallengeHash = challengeHash; + } +} diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginError.cs b/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginError.cs index f1125a274..8cbb12468 100644 --- a/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginError.cs +++ b/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginError.cs @@ -34,5 +34,6 @@ public enum LoginError InvalidTokenTypeRequested, ExcessiveFailedLoginAttempts, InvalidPasswordVersion, - PasswordHashFailure + PasswordHashFailure, + InvalidMultiFactorChallenge } diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginRequest.cs b/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginRequest.cs index 9f8696c85..4068cdb01 100644 --- a/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginRequest.cs +++ b/Crypter.Common/Contracts/Features/UserAuthentication/Login/LoginRequest.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2024 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -36,19 +36,35 @@ public class LoginRequest public string Username { get; set; } public List VersionedPasswords { get; set; } public TokenType RefreshTokenType { get; set; } + public MultiFactorVerification? MultiFactorVerification { get; set; } [JsonConstructor] - public LoginRequest(string username, List versionedPasswords, TokenType refreshTokenType) + public LoginRequest(string username, List versionedPasswords, TokenType refreshTokenType, MultiFactorVerification? multiFactorVerification) { Username = username; VersionedPasswords = versionedPasswords; RefreshTokenType = refreshTokenType; + MultiFactorVerification = multiFactorVerification; } - public LoginRequest(Username username, List versionedPasswords, TokenType refreshTokenType) + public LoginRequest(Username username, List versionedPasswords, TokenType refreshTokenType, MultiFactorVerification? multiFactorVerification) { Username = username.Value; VersionedPasswords = versionedPasswords; RefreshTokenType = refreshTokenType; + MultiFactorVerification = multiFactorVerification; + } +} + +public class MultiFactorVerification +{ + public string ChallengeHash { get; set; } + public string VerificationCode { get; set; } + + [JsonConstructor] + public MultiFactorVerification(string challengeHash, string verificationCode) + { + ChallengeHash = challengeHash; + VerificationCode = verificationCode; } } diff --git a/Crypter.Core/Crypter.Core.csproj b/Crypter.Core/Crypter.Core.csproj index 0a2324746..7058d75e8 100644 --- a/Crypter.Core/Crypter.Core.csproj +++ b/Crypter.Core/Crypter.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/Crypter.Core/Features/UserAuthentication/Commands/DeleteMultiFactorChallengeCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/DeleteMultiFactorChallengeCommand.cs new file mode 100644 index 000000000..8287e3ccd --- /dev/null +++ b/Crypter.Core/Features/UserAuthentication/Commands/DeleteMultiFactorChallengeCommand.cs @@ -0,0 +1,57 @@ +/* + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Crypter.DataAccess; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Unit = EasyMonads.Unit; + +namespace Crypter.Core.Features.UserAuthentication.Commands; + +public sealed record DeleteMultiFactorChallengeCommand(Guid MultiFactorChallengeId) : IRequest; + +internal sealed class DeleteMultiFactorChallengeCommandHandler : IRequestHandler +{ + private readonly DataContext _dataContext; + + public DeleteMultiFactorChallengeCommandHandler(DataContext dataContext) + { + _dataContext = dataContext; + } + + public async Task Handle(DeleteMultiFactorChallengeCommand request, CancellationToken cancellationToken) + { + await _dataContext.UserMultiFactorChallenges + .Where(x => x.Id == request.MultiFactorChallengeId) + .ExecuteDeleteAsync(CancellationToken.None); + + return Unit.Default; + } +} diff --git a/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs new file mode 100644 index 000000000..197f98311 --- /dev/null +++ b/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs @@ -0,0 +1,87 @@ +/* + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Crypter.Common.Primitives; +using Crypter.Core.Services.Email; +using Crypter.Crypto.Common; +using Crypter.DataAccess; +using Crypter.DataAccess.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Crypter.Core.Features.UserAuthentication.Commands; + +public sealed record SendMultiFactorVerificationCodeCommand(Guid UserId, Guid MultiFactorChallengeId, int ChallengeExpirationMinutes) : IRequest; + +internal sealed class SendMultiFactorVerificationCodeCommandHandler : IRequestHandler +{ + private readonly ICryptoProvider _cryptoProvider; + private readonly DataContext _dataContext; + private readonly IEmailService _emailService; + + private const int OneTimePasswordLength = 8; + private const string OneTimePasswordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz" + + "0123456789"; + + public SendMultiFactorVerificationCodeCommandHandler(ICryptoProvider cryptoProvider, DataContext dataContext, IEmailService emailService) + { + _cryptoProvider = cryptoProvider; + _dataContext = dataContext; + _emailService = emailService; + } + + public async Task Handle(SendMultiFactorVerificationCodeCommand request, CancellationToken cancellationToken) + { + string verificationCode = _cryptoProvider.Random.GenerateRandomString(OneTimePasswordLength, OneTimePasswordAlphabet); + UserMultiFactorChallengeEntity challengeEntity = new UserMultiFactorChallengeEntity(request.MultiFactorChallengeId, request.UserId, verificationCode, DateTime.UtcNow); + + string? verifiedEmailAddress = await _dataContext.Users + .Where(x => x.Id == request.UserId && x.EmailAddress != null) + .Select(x => x.EmailAddress) + .FirstOrDefaultAsync(CancellationToken.None); + + if (!EmailAddress.TryFrom(verifiedEmailAddress!, out EmailAddress validEmailAddress)) + { + // Return true to indicate we succeeded as much as we could. + // Returning false may indicate to the caller the command failed for some reason outside our control and should be retried. + return true; + } + + bool emailSuccess = await _emailService.SendMultiFactorChallengeEmailAsync(validEmailAddress, verificationCode, request.ChallengeExpirationMinutes); + if (emailSuccess) + { + _dataContext.UserMultiFactorChallenges.Add(challengeEntity); + await _dataContext.SaveChangesAsync(CancellationToken.None); + } + + return emailSuccess; + } +} diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 4d5dd88ad..623e73121 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -39,38 +39,41 @@ using Crypter.DataAccess; using Crypter.DataAccess.Entities; using EasyMonads; +using Hangfire; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using OneOf; using Unit = EasyMonads.Unit; namespace Crypter.Core.Features.UserAuthentication.Commands; -public sealed record UserLoginCommand(LoginRequest Request, string DeviceDescription) - : IEitherRequest; +public sealed record UserLoginCommand(LoginRequest Request, string DeviceDescription) : IEitherRequest>; -internal sealed class UserLoginCommandHandler - : IEitherRequestHandler +internal sealed class UserLoginCommandHandler : IEitherRequestHandler> { + private readonly IBackgroundJobClient _backgroundJobClient; private readonly DataContext _dataContext; + private readonly IHangfireBackgroundService _hangfireBackgroundService; + private readonly HashIdService _hashIdService; private readonly IPasswordHashService _passwordHashService; private readonly IPublisher _publisher; private readonly ITokenService _tokenService; - + + private const int MultiFactorExpirationMinutes = 5; private readonly short _clientPasswordVersion; private const int MaximumFailedLoginAttempts = 3; private readonly Dictionary> _refreshTokenProviderMap; + private readonly DateTimeOffset _currentTime; private UserEntity? _foundUserEntity; - public UserLoginCommandHandler( - DataContext dataContext, - IOptions passwordSettings, - IPasswordHashService passwordHashService, - IPublisher publisher, - ITokenService tokenService) + public UserLoginCommandHandler(IBackgroundJobClient backgroundJobClient, DataContext dataContext, IHangfireBackgroundService hangfireBackgroundService, HashIdService hashIdService, IOptions passwordSettings, IPasswordHashService passwordHashService, IPublisher publisher, ITokenService tokenService) { + _backgroundJobClient = backgroundJobClient; _dataContext = dataContext; + _hangfireBackgroundService = hangfireBackgroundService; + _hashIdService = hashIdService; _passwordHashService = passwordHashService; _publisher = publisher; _tokenService = tokenService; @@ -81,25 +84,40 @@ public UserLoginCommandHandler( { TokenType.Session, _tokenService.NewSessionToken }, { TokenType.Device, _tokenService.NewDeviceToken } }; + + _currentTime = DateTimeOffset.UtcNow; } - public async Task> Handle(UserLoginCommand request, CancellationToken cancellationToken) + public async Task>> Handle(UserLoginCommand request, CancellationToken cancellationToken) { return await ValidateLoginRequest(request.Request) .BindAsync(async validLoginRequest => await ( from foundUser in GetUserAsync(validLoginRequest) - from passwordVerificationSuccess in VerifyAndUpgradePassword(validLoginRequest, foundUser).AsTask() - from loginResponse in CreateLoginResponseAsync(foundUser, validLoginRequest.RefreshTokenType, request.DeviceDescription) - select loginResponse) - ) - .DoRightAsync(async _ => + from passwordVerificationSuccess in VerifyPassword(validLoginRequest, foundUser).AsTask() + from twoFactorAuthenticationRequired in CheckMultiFactorAuthentication(foundUser, validLoginRequest.ValidMultiFactorVerification).AsTask() + select twoFactorAuthenticationRequired.MapT1(_ => validLoginRequest))) + .BindAsync(async preliminaryLoginResult => { + if (preliminaryLoginResult.TryPickT0(out Guid challengeId, out ValidLoginRequest validLoginRequest)) + { + _backgroundJobClient.Enqueue(() => _hangfireBackgroundService.SendMultiFactorVerificationCodeAsync(_foundUserEntity!.Id, challengeId, MultiFactorExpirationMinutes)); + string challengeHash = _hashIdService.Encode(challengeId); + ChallengeResponse challengeResponse = new ChallengeResponse(challengeHash); + return OneOf.FromT0(challengeResponse); + } + + return await ( + from passwordUpgradePerformed in UpgradePasswordIfRequired(validLoginRequest, _foundUserEntity!).AsTask() + from loginResponse in CreateLoginResponseAsync(_foundUserEntity!, validLoginRequest.RefreshTokenType, request.DeviceDescription) + select OneOf.FromT1(loginResponse)); + }) + .DoRightAsync(async _ => { - SuccessfulUserLoginEvent successfulUserLoginEvent = new SuccessfulUserLoginEvent(_foundUserEntity!.Id, request.DeviceDescription, _foundUserEntity.LastLogin); + SuccessfulUserLoginEvent successfulUserLoginEvent = new SuccessfulUserLoginEvent(_foundUserEntity!.Id, request.DeviceDescription, _currentTime); await _publisher.Publish(successfulUserLoginEvent, CancellationToken.None); }) .DoLeftOrNeitherAsync(async error => { - FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, error, request.DeviceDescription, DateTimeOffset.UtcNow); + FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, error, request.DeviceDescription, _currentTime); await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); if (error == LoginError.InvalidPassword) @@ -110,16 +128,23 @@ from loginResponse in CreateLoginResponseAsync(foundUser, validLoginRequest.Refr }, async () => { - FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, LoginError.UnknownError, request.DeviceDescription, DateTimeOffset.UtcNow); + FailedUserLoginEvent failedUserLoginEvent = new FailedUserLoginEvent(request.Request.Username, LoginError.UnknownError, request.DeviceDescription, _currentTime); await _publisher.Publish(failedUserLoginEvent, CancellationToken.None); }); } - private readonly struct ValidLoginRequest(Username username, IDictionary versionedPasswords, TokenType refreshTokenType) + private record ValidLoginRequest(Username Username, IDictionary VersionedPasswords, TokenType RefreshTokenType, ValidMultiFactorVerification? ValidMultiFactorVerification) + { + public Username Username { get; } = Username; + public IDictionary VersionedPasswords { get; } = VersionedPasswords; + public TokenType RefreshTokenType { get; } = RefreshTokenType; + public ValidMultiFactorVerification? ValidMultiFactorVerification { get; } = ValidMultiFactorVerification; + } + + private record ValidMultiFactorVerification(Guid ChallengeId, string VerificationCode) { - public Username Username { get; } = username; - public IDictionary VersionedPasswords { get; } = versionedPasswords; - public TokenType RefreshTokenType { get; } = refreshTokenType; + public Guid ChallengeId { get; } = ChallengeId; + public string VerificationCode { get; } = VerificationCode; } private Either ValidateLoginRequest(LoginRequest request) @@ -133,11 +158,27 @@ private Either ValidateLoginRequest(LoginRequest { return LoginError.InvalidTokenTypeRequested; } - + return GetValidClientPasswords(request.VersionedPasswords) - .Map(x => new ValidLoginRequest(validUsername, x, request.RefreshTokenType)); + .Bind(passwordMap => + { + if (request.MultiFactorVerification is null) + { + return new ValidLoginRequest(validUsername, passwordMap, request.RefreshTokenType, null); + } + + return ValidateMultiFactorVerification(request.MultiFactorVerification) + .Map(validMultiFactorVerification => new ValidLoginRequest(validUsername, passwordMap, request.RefreshTokenType, validMultiFactorVerification)); + }); } + private Either ValidateMultiFactorVerification(MultiFactorVerification multiFactorVerification) + { + return _hashIdService.Decode(multiFactorVerification.ChallengeHash) + .Select(x => new ValidMultiFactorVerification(x, multiFactorVerification.VerificationCode)) + .ToEither(LoginError.InvalidMultiFactorChallenge); + } + private async Task> GetUserAsync(ValidLoginRequest validLoginRequest) { _foundUserEntity = await _dataContext.Users @@ -145,6 +186,7 @@ private async Task> GetUserAsync(ValidLoginReques .Include(x => x.FailedLoginAttempts) .Include(x => x.MasterKey) .Include(x => x.KeyPair) + .Include(x => x.MultiFactorChallenges) .FirstOrDefaultAsync(); if (_foundUserEntity is null) @@ -159,8 +201,8 @@ private async Task> GetUserAsync(ValidLoginReques return _foundUserEntity; } - - private Either VerifyAndUpgradePassword(ValidLoginRequest validLoginRequest, UserEntity userEntity) + + private Either VerifyPassword(ValidLoginRequest validLoginRequest, UserEntity userEntity) { bool requestContainsRequiredPasswordVersions = validLoginRequest.VersionedPasswords.ContainsKey(userEntity.ClientPasswordVersion) && validLoginRequest.VersionedPasswords.ContainsKey(_clientPasswordVersion); @@ -179,6 +221,45 @@ private Either VerifyAndUpgradePassword(ValidLoginRequest vali return LoginError.InvalidPassword; } + return Unit.Default; + } + + /// + /// If the user account requires MFA, and a valid MFA is not provided, then return a new Challenge Id. + /// If the user account does not require MFA, then return an indication of a successful check. + /// If the user account requires MFA, and a valid MFA is provided, then verify the MFA is valid and correct. + /// + /// + /// + /// + private static Either> CheckMultiFactorAuthentication(UserEntity userEntity, ValidMultiFactorVerification? validMultiFactorVerification) + { + if (userEntity.RequireTwoFactorAuthentication) + { + if (validMultiFactorVerification is null) + { + return OneOf.FromT0(Guid.NewGuid()); + } + + UserMultiFactorChallengeEntity? challengeEntity = userEntity.MultiFactorChallenges + .Where(x => x.Id == validMultiFactorVerification.ChallengeId) + .Where(x => x.VerificationCode == validMultiFactorVerification.VerificationCode) + .Where(x => x.Created <= DateTime.UtcNow.AddMinutes(MultiFactorExpirationMinutes)) + .FirstOrDefault(); + + if (challengeEntity is null) + { + return LoginError.InvalidMultiFactorChallenge; + } + + userEntity.MultiFactorChallenges.Remove(challengeEntity); + } + + return OneOf.FromT1(Unit.Default); + } + + private Either UpgradePasswordIfRequired(ValidLoginRequest validLoginRequest, UserEntity userEntity) + { // 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; @@ -204,7 +285,7 @@ private Either VerifyAndUpgradePassword(ValidLoginRequest vali private async Task> CreateLoginResponseAsync(UserEntity userEntity, TokenType refreshTokenType, string deviceDescription) { - userEntity.LastLogin = DateTime.UtcNow; + userEntity.LastLogin = _currentTime.DateTime; RefreshTokenData refreshToken = _refreshTokenProviderMap[refreshTokenType].Invoke(userEntity.Id); UserTokenEntity tokenEntity = new UserTokenEntity( diff --git a/Crypter.Core/Services/Email/EmailServiceTemplateExtensions.cs b/Crypter.Core/Services/Email/EmailServiceTemplateExtensions.cs index ed731dde8..720e1a2ef 100644 --- a/Crypter.Core/Services/Email/EmailServiceTemplateExtensions.cs +++ b/Crypter.Core/Services/Email/EmailServiceTemplateExtensions.cs @@ -104,4 +104,20 @@ internal static async Task SendApplicationAnalyticsReportEmailAsync(this I $"Unique logins: {reportData.UserAnalytics.UniqueLogins}"; return await emailService.SendAsync("Crypter Analytics", message, emailAddress); } + + /// + /// Send a multifactor verification code to the provided recipient. + /// + /// + /// + /// + /// + /// + internal static async Task SendMultiFactorChallengeEmailAsync(this IEmailService emailService, EmailAddress emailAddress, string verificationCode, int expirationMinutes) + { + string message = $"Your verification code is: {verificationCode}\n" + + "If you did not request this verification code, you should consider updating your password.\n\n" + + $"This code will expire in {expirationMinutes} minutes."; + return await emailService.SendAsync("Your verification code", message, emailAddress); + } } diff --git a/Crypter.Core/Services/HangfireBackgroundService.cs b/Crypter.Core/Services/HangfireBackgroundService.cs index b96740910..0efb3138c 100644 --- a/Crypter.Core/Services/HangfireBackgroundService.cs +++ b/Crypter.Core/Services/HangfireBackgroundService.cs @@ -53,7 +53,8 @@ public interface IHangfireBackgroundService Task SendEmailVerificationAsync(Guid userId); Task SendTransferNotificationAsync(Guid itemId, TransferItemType itemType); Task SendRecoveryEmailAsync(string emailAddress); - + Task SendMultiFactorVerificationCodeAsync(Guid userId, Guid multiFactorChallengeId, int challengeExpirationMinutes); + /// /// Delete a transfer from transfer storage and the database. /// @@ -76,6 +77,7 @@ public interface IHangfireBackgroundService Task DeleteRecoveryParametersAsync(Guid userId); Task DeleteUserKeysAsync(Guid userId); Task DeleteReceivedTransfersAsync(Guid userId); + Task DeleteMultiFactorVerificationCodeAsync(Guid multiFactorChallengeId); Task SendApplicationAnalyticsReportAsync(); @@ -162,12 +164,10 @@ public async Task SendRecoveryEmailAsync(string emailAddress) _logger.LogWarning("A user was not found while attempting to send a recovery email."); break; case SendAccountRecoveryEmailError.InvalidSavedUsername: - _logger.LogWarning( - "A user was found to have an invalid username while attempting to send a recovery email."); + _logger.LogWarning("A user was found to have an invalid username while attempting to send a recovery email."); break; case SendAccountRecoveryEmailError.InvalidSavedEmailAddress: - _logger.LogWarning( - "A user was found to have an invalid email address while attempting to send a recovery email."); + _logger.LogWarning("A user was found to have an invalid email address while attempting to send a recovery email."); break; case SendAccountRecoveryEmailError.EmailFailure: _logger.LogWarning("An email failure occurred while trying to send a recovery email."); @@ -178,6 +178,20 @@ public async Task SendRecoveryEmailAsync(string emailAddress) return Unit.Default; } + public async Task SendMultiFactorVerificationCodeAsync(Guid userId, Guid multiFactorChallengeId, int challengeExpirationMinutes) + { + SendMultiFactorVerificationCodeCommand sendRequest = new SendMultiFactorVerificationCodeCommand(userId, multiFactorChallengeId, challengeExpirationMinutes); + bool sendSuccess = await _sender.Send(sendRequest); + + if (!sendSuccess) + { + _logger.LogError("Failed to send multi factor verification code."); + throw new HangfireJobException($"{nameof(SendMultiFactorVerificationCodeAsync)} failed."); + } + + return Unit.Default; + } + public Task DeleteTransferAsync(Guid itemId, TransferItemType itemType, TransferUserType userType, bool deleteFromTransferRepository) { DeleteTransferCommand request = new DeleteTransferCommand(itemId, itemType, userType, deleteFromTransferRepository); @@ -220,6 +234,12 @@ public Task DeleteReceivedTransfersAsync(Guid userId) return _sender.Send(request); } + public Task DeleteMultiFactorVerificationCodeAsync(Guid multiFactorChallengeId) + { + DeleteMultiFactorChallengeCommand request = new DeleteMultiFactorChallengeCommand(multiFactorChallengeId); + return _sender.Send(request); + } + public async Task SendApplicationAnalyticsReportAsync() { ApplicationAnalyticsReportQuery reportRequest = new ApplicationAnalyticsReportQuery(7); diff --git a/Crypter.Crypto.Common/Random/IRandom.cs b/Crypter.Crypto.Common/Random/IRandom.cs index ffc23da83..1f0570b02 100644 --- a/Crypter.Crypto.Common/Random/IRandom.cs +++ b/Crypter.Crypto.Common/Random/IRandom.cs @@ -30,4 +30,5 @@ public interface IRandom { uint GenerateRandomNumber(); byte[] GenerateRandomBytes(int size); + string GenerateRandomString(int length, string alphabet); } diff --git a/Crypter.Crypto.Providers.Browser/Wrappers/Random.cs b/Crypter.Crypto.Providers.Browser/Wrappers/Random.cs index c12d6eeba..b6a710bda 100644 --- a/Crypter.Crypto.Providers.Browser/Wrappers/Random.cs +++ b/Crypter.Crypto.Providers.Browser/Wrappers/Random.cs @@ -24,6 +24,8 @@ * Contact the current copyright holder to discuss commercial license options. */ +using System; +using System.Linq; using System.Runtime.Versioning; using BlazorSodium.Sodium; using Crypter.Crypto.Common.Random; @@ -42,4 +44,10 @@ public uint GenerateRandomNumber() { return RandomBytes.RandomBytes_Random(); } + + public string GenerateRandomString(int length, string alphabet) + { + return new string(Enumerable.Repeat(alphabet, length) + .Select(s => s[Convert.ToInt32(RandomBytes.RandomBytes_Uniform(Convert.ToUInt32(s.Length)))]).ToArray()); + } } diff --git a/Crypter.Crypto.Providers.Default/Wrappers/Random.cs b/Crypter.Crypto.Providers.Default/Wrappers/Random.cs index 76f70650a..5164b7261 100644 --- a/Crypter.Crypto.Providers.Default/Wrappers/Random.cs +++ b/Crypter.Crypto.Providers.Default/Wrappers/Random.cs @@ -25,6 +25,7 @@ */ using System; +using System.Linq; using System.Runtime.Versioning; using System.Security.Cryptography; using Crypter.Crypto.Common.Random; @@ -44,4 +45,9 @@ public uint GenerateRandomNumber() byte[] randomBytes = RandomNumberGenerator.GetBytes(4); return BitConverter.ToUInt32(randomBytes, 0); } + + public string GenerateRandomString(int length, string alphabet) + { + return RandomNumberGenerator.GetString(alphabet, length); + } } diff --git a/Crypter.DataAccess/DataContext.cs b/Crypter.DataAccess/DataContext.cs index d5344c746..f1218487e 100644 --- a/Crypter.DataAccess/DataContext.cs +++ b/Crypter.DataAccess/DataContext.cs @@ -55,6 +55,7 @@ public DataContext(DbContextOptions options) : base(options) public DbSet UserKeyPairs { get; init; } public DbSet UserMasterKeys { get; init; } public DbSet UserMessageTransfers { get; init; } + public DbSet UserMultiFactorChallenges { get; init; } public DbSet UserNotificationSettings { get; init; } public DbSet UserPrivacySettings { get; init; } public DbSet UserProfiles { get; init; } diff --git a/Crypter.DataAccess/Entities/UserEntity.cs b/Crypter.DataAccess/Entities/UserEntity.cs index 960465e7b..609da4ce1 100644 --- a/Crypter.DataAccess/Entities/UserEntity.cs +++ b/Crypter.DataAccess/Entities/UserEntity.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2024 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -40,6 +40,7 @@ public class UserEntity public byte[] PasswordSalt { get; set; } public short ServerPasswordVersion { get; set; } public short ClientPasswordVersion { get; set; } + public bool RequireTwoFactorAuthentication { get; set; } public DateTime Created { get; set; } public DateTime LastLogin { get; set; } @@ -59,6 +60,7 @@ public class UserEntity public List? ReceivedMessageTransfers { get; set; } public List? FailedLoginAttempts { get; set; } public List? Consents { get; set; } + public List MultiFactorChallenges { get; set; } /// /// Please avoid using this. @@ -137,6 +139,10 @@ public void Configure(EntityTypeBuilder builder) .WithOne(x => x.Recipient) .HasForeignKey(x => x.RecipientId); + builder.HasMany(x => x.MultiFactorChallenges) + .WithOne(x => x.User) + .HasForeignKey(x => x.Owner); + builder.HasIndex(x => x.Username) .IsUnique(); diff --git a/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs b/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs new file mode 100644 index 000000000..6a9f5718e --- /dev/null +++ b/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs @@ -0,0 +1,61 @@ +/* + * 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 Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Crypter.DataAccess.Entities; + +public class UserMultiFactorChallengeEntity +{ + public Guid Id { get; set; } + public Guid Owner { get; set; } + public string VerificationCode { get; set; } + public DateTime Created { get; set; } + + public UserEntity User { get; set; } + + public UserMultiFactorChallengeEntity(Guid id, Guid owner, string verificationCode, DateTime created) + { + Id = id; + Owner = owner; + VerificationCode = verificationCode; + Created = created; + } +} + +public class UserMultiFactorChallengeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserMultiFactorChallenge"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.VerificationCode) + .HasMaxLength(8); + } +} diff --git a/Crypter.Test/Integration_Tests/TestData.cs b/Crypter.Test/Integration_Tests/TestData.cs index 701e6a414..27c1589c0 100644 --- a/Crypter.Test/Integration_Tests/TestData.cs +++ b/Crypter.Test/Integration_Tests/TestData.cs @@ -138,7 +138,7 @@ internal static LoginRequest GetLoginRequest(string username, string password, T { byte[] passwordBytes = Encoding.UTF8.GetBytes(password); VersionedPassword versionedPassword = new VersionedPassword(passwordBytes, 1); - return new LoginRequest(username, [versionedPassword], tokenType); + return new LoginRequest(username, [versionedPassword], tokenType, null); } internal static VersionedPassword GetVersionedPassword(string password, short version) From 66b86141640ee2de8cf4345b2be0992dbb0b9ad4 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 12 Nov 2024 20:18:19 -0600 Subject: [PATCH 02/10] wip --- .../Services/UserSessionService.cs | 2 +- .../MultiFactorChangeRequest.cs | 37 +++++++++++++++++++ .../PasswordChange/PasswordChangeError.cs | 2 +- .../PasswordChange/PasswordChangeRequest.cs | 2 +- .../ContactInfoSettings.cs | 4 +- .../Commands/UserLoginCommand.cs | 2 +- .../UserSettingsAccountInfo.razor | 15 ++++++++ .../UserSettingsAccountInfo.razor.cs | 29 ++++++++++++++- 8 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 Crypter.Common/Contracts/Features/UserAuthentication/MultiFactorChange/MultiFactorChangeRequest.cs diff --git a/Crypter.Common.Client/Services/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index 9a6269a1f..7fe32add5 100644 --- a/Crypter.Common.Client/Services/UserSessionService.cs +++ b/Crypter.Common.Client/Services/UserSessionService.cs @@ -254,7 +254,7 @@ public event EventHandler UserPasswordTestSucc private Task> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType) { - LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType); + LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType, null); return _crypterApiClient.UserAuthentication.LoginAsync(loginRequest); } diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/MultiFactorChange/MultiFactorChangeRequest.cs b/Crypter.Common/Contracts/Features/UserAuthentication/MultiFactorChange/MultiFactorChangeRequest.cs new file mode 100644 index 000000000..30bd56d26 --- /dev/null +++ b/Crypter.Common/Contracts/Features/UserAuthentication/MultiFactorChange/MultiFactorChangeRequest.cs @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +namespace Crypter.Common.Contracts.Features.UserAuthentication; + +public class MultiFactorChangeRequest +{ + public bool EnableMultiFactorAuthentication { get; set; } + + public MultiFactorChangeRequest(bool enableMultiFactorAuthentication) + { + EnableMultiFactorAuthentication = enableMultiFactorAuthentication; + } +} diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs index 7fa9694f1..754bba448 100644 --- a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs +++ b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeError.cs @@ -24,7 +24,7 @@ * Contact the current copyright holder to discuss commercial license options. */ -namespace Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +namespace Crypter.Common.Contracts.Features.UserAuthentication; public enum PasswordChangeError { diff --git a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs index 9899d3a38..89e51eaac 100644 --- a/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs +++ b/Crypter.Common/Contracts/Features/UserAuthentication/PasswordChange/PasswordChangeRequest.cs @@ -27,7 +27,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +namespace Crypter.Common.Contracts.Features.UserAuthentication; public class PasswordChangeRequest { diff --git a/Crypter.Common/Contracts/Features/UserSettings/ContactInfoSettings/ContactInfoSettings.cs b/Crypter.Common/Contracts/Features/UserSettings/ContactInfoSettings/ContactInfoSettings.cs index ae420fdd8..bab4761b0 100644 --- a/Crypter.Common/Contracts/Features/UserSettings/ContactInfoSettings/ContactInfoSettings.cs +++ b/Crypter.Common/Contracts/Features/UserSettings/ContactInfoSettings/ContactInfoSettings.cs @@ -30,10 +30,12 @@ public class ContactInfoSettings { public string? EmailAddress { get; init; } public string? PendingEmailAddress { get; init; } + public bool MultiFactorAuthenticationEnabled { get; init; } - public ContactInfoSettings(string? emailAddress, string? pendingEmailAddress) + public ContactInfoSettings(string? emailAddress, string? pendingEmailAddress, bool multiFactorAuthenticationEnabled) { EmailAddress = emailAddress; PendingEmailAddress = pendingEmailAddress; + MultiFactorAuthenticationEnabled = multiFactorAuthenticationEnabled; } } diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 623e73121..2e881d2ad 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -234,7 +234,7 @@ private Either VerifyPassword(ValidLoginRequest validLoginRequ /// private static Either> CheckMultiFactorAuthentication(UserEntity userEntity, ValidMultiFactorVerification? validMultiFactorVerification) { - if (userEntity.RequireTwoFactorAuthentication) + if (userEntity is { RequireTwoFactorAuthentication: true, EmailVerified: true }) { if (validMultiFactorVerification is null) { diff --git a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor index 3036f6254..aff762585 100644 --- a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor +++ b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor @@ -110,4 +110,19 @@ } + +

Multi-factor Authentication

+
+
+ + + @if (!string.IsNullOrEmpty(_multiFactorAuthenticationError)) + { + @_multiFactorAuthenticationError + } +
+ + + +
} diff --git a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs index 6190d3290..887404e9b 100644 --- a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs +++ b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs @@ -49,10 +49,14 @@ public partial class UserSettingsAccountInfo private string _passwordChangeNewPassword = string.Empty; private string _passwordChangeConfirmPassword = string.Empty; + private bool _multiFactorAuthentication = false; + private bool _multiFactorAuthenticationEdit = false; + private bool _isDataReady = false; private bool _isEditingEmailAddress = false; private bool _isEditingPassword = false; - + private bool _isEditingMultiFactorAuthentication = false; + private string _emailAddressError = string.Empty; private string _emailAddressPasswordError = string.Empty; private string _genericEmailAddressError = string.Empty; @@ -62,6 +66,8 @@ public partial class UserSettingsAccountInfo private string _confirmPasswordError = string.Empty; private string _passwordChangeError = string.Empty; + private string _multiFactorAuthenticationError = string.Empty; + protected override async Task OnInitializedAsync() { await UserContactInfoSettingsService.GetContactInfoSettingsAsync() @@ -86,6 +92,11 @@ private void OnChangePasswordClicked() { _isEditingPassword = true; } + + private void OnEditMultiFactorAuthenticationClicked() + { + _isEditingMultiFactorAuthentication = true; + } private void OnCancelForEditContactInfoClicked() { @@ -104,6 +115,12 @@ private void OnCancelForChangePasswordClicked() _isEditingPassword = false; } + + private void OnCancelForMultiFactorAuthenticationClicked() + { + _multiFactorAuthenticationEdit = _multiFactorAuthentication; + _isEditingMultiFactorAuthentication = false; + } private void ResetContactInfoErrors() { @@ -120,6 +137,11 @@ private void ResetPasswordChangeErrors() _confirmPasswordError = string.Empty; _passwordChangeError = string.Empty; } + + private void ResetMultiFactorAuthenticationErrors() + { + _multiFactorAuthenticationError = string.Empty; + } private async Task OnSaveContactInfoClickedAsync() { @@ -184,6 +206,11 @@ await UserPasswordChangeService.ChangePasswordAsync(oldPassword, newPassword) () => HandlePasswordChangeError()); } + private async Task OnSaveMultiFactorAuthenticationClickedAsync() + { + ResetMultiFactorAuthenticationErrors(); + } + private void HandleContactInfoUpdateError(UpdateContactInfoSettingsError error = UpdateContactInfoSettingsError.UnknownError) { _emailAddressPassword = string.Empty; From e61623858def6bddf6cc0069fb7e41ed18378912 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 20:57:38 -0500 Subject: [PATCH 03/10] Rebase fixes --- Crypter.API/Controllers/UserAuthenticationController.cs | 1 - .../HttpClients/Requests/UserAuthenticationRequests.cs | 1 - .../Interfaces/Requests/IUserAuthenticationRequests.cs | 1 - .../Services/UserSettings/IUserPasswordChangeService.cs | 2 +- .../Services/UserSettings/UserPasswordChangeService.cs | 1 - .../UserAuthentication/Commands/ChangeUserPasswordCommand.cs | 1 - .../Features/UserAuthentication/Commands/UserLoginCommand.cs | 2 +- Crypter.Core/Features/UserSettings/Common.cs | 2 +- .../UserAuthentication_Tests/PasswordChange_Tests.cs | 1 - .../Shared/UserSettings/UserSettingsAccountInfo.razor.cs | 3 ++- 10 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Crypter.API/Controllers/UserAuthenticationController.cs b/Crypter.API/Controllers/UserAuthenticationController.cs index 8d8f19e9d..dee9ec9ec 100644 --- a/Crypter.API/Controllers/UserAuthenticationController.cs +++ b/Crypter.API/Controllers/UserAuthenticationController.cs @@ -31,7 +31,6 @@ 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; diff --git a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs index f71a28b79..f62e01f62 100644 --- a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs +++ b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs @@ -29,7 +29,6 @@ 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; diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs index d30bdd273..27fee08e2 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs @@ -27,7 +27,6 @@ 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; diff --git a/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs b/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs index 1e73bc5c2..aa1196b2f 100644 --- a/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs +++ b/Crypter.Common.Client/Interfaces/Services/UserSettings/IUserPasswordChangeService.cs @@ -25,7 +25,7 @@ */ using System.Threading.Tasks; -using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Primitives; using EasyMonads; diff --git a/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs b/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs index 7fddbe084..247a9b7bd 100644 --- a/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs +++ b/Crypter.Common.Client/Services/UserSettings/UserPasswordChangeService.cs @@ -31,7 +31,6 @@ using Crypter.Common.Client.Interfaces.Services; using Crypter.Common.Client.Interfaces.Services.UserSettings; using Crypter.Common.Contracts.Features.UserAuthentication; -using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using Crypter.Common.Primitives; using Crypter.Crypto.Common; using EasyMonads; diff --git a/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs index b53cabf6e..08409670a 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/ChangeUserPasswordCommand.cs @@ -30,7 +30,6 @@ 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; diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 2e881d2ad..dc725220c 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -234,7 +234,7 @@ private Either VerifyPassword(ValidLoginRequest validLoginRequ /// private static Either> CheckMultiFactorAuthentication(UserEntity userEntity, ValidMultiFactorVerification? validMultiFactorVerification) { - if (userEntity is { RequireTwoFactorAuthentication: true, EmailVerified: true }) + if (userEntity is { RequireTwoFactorAuthentication: true, EmailAddress: not null }) { if (validMultiFactorVerification is null) { diff --git a/Crypter.Core/Features/UserSettings/Common.cs b/Crypter.Core/Features/UserSettings/Common.cs index 2911b18a5..14484dc75 100644 --- a/Crypter.Core/Features/UserSettings/Common.cs +++ b/Crypter.Core/Features/UserSettings/Common.cs @@ -46,7 +46,7 @@ internal static Task> GetContactInfoSettingsAsync(Dat { return Maybe.FromNullableAsync(dataContext.Users .Where(x => x.Id == userId) - .Select(x => new ContactInfoSettings(x.EmailAddress, x.EmailChange!.EmailAddress)) + .Select(x => new ContactInfoSettings(x.EmailAddress, x.EmailChange!.EmailAddress, x.RequireTwoFactorAuthentication)) .FirstOrDefaultAsync(cancellationToken)); } diff --git a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs index 26bcc8fb6..556c4eeb7 100644 --- a/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs +++ b/Crypter.Test/Integration_Tests/UserAuthentication_Tests/PasswordChange_Tests.cs @@ -31,7 +31,6 @@ using Crypter.Common.Client.Interfaces.Repositories; using Crypter.Common.Contracts.Features.Keys; using Crypter.Common.Contracts.Features.UserAuthentication; -using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using Crypter.Common.Enums; using Crypter.DataAccess; using Crypter.DataAccess.Entities; diff --git a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs index 887404e9b..0a682d609 100644 --- a/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs +++ b/Crypter.Web/Shared/UserSettings/UserSettingsAccountInfo.razor.cs @@ -26,7 +26,7 @@ using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.Services.UserSettings; -using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; +using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Contracts.Features.UserSettings.ContactInfoSettings; using Crypter.Common.Primitives; using EasyMonads; @@ -208,6 +208,7 @@ await UserPasswordChangeService.ChangePasswordAsync(oldPassword, newPassword) private async Task OnSaveMultiFactorAuthenticationClickedAsync() { + await Task.Delay(1); // TODO implement a real save function ResetMultiFactorAuthenticationErrors(); } From ccbeb3cf225b62f01dfdb1e71fe80e21d51b24a8 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 21:07:44 -0500 Subject: [PATCH 04/10] Fix warnings --- .../Features/UserAuthentication/Commands/UserLoginCommand.cs | 4 ++-- Crypter.DataAccess/Entities/UserEntity.cs | 2 +- Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index dc725220c..9e7637f70 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -241,7 +241,7 @@ private static Either> CheckMultiFactorAuthenticat return OneOf.FromT0(Guid.NewGuid()); } - UserMultiFactorChallengeEntity? challengeEntity = userEntity.MultiFactorChallenges + UserMultiFactorChallengeEntity? challengeEntity = userEntity.MultiFactorChallenges! .Where(x => x.Id == validMultiFactorVerification.ChallengeId) .Where(x => x.VerificationCode == validMultiFactorVerification.VerificationCode) .Where(x => x.Created <= DateTime.UtcNow.AddMinutes(MultiFactorExpirationMinutes)) @@ -252,7 +252,7 @@ private static Either> CheckMultiFactorAuthenticat return LoginError.InvalidMultiFactorChallenge; } - userEntity.MultiFactorChallenges.Remove(challengeEntity); + userEntity.MultiFactorChallenges!.Remove(challengeEntity); } return OneOf.FromT1(Unit.Default); diff --git a/Crypter.DataAccess/Entities/UserEntity.cs b/Crypter.DataAccess/Entities/UserEntity.cs index 609da4ce1..856b3d30b 100644 --- a/Crypter.DataAccess/Entities/UserEntity.cs +++ b/Crypter.DataAccess/Entities/UserEntity.cs @@ -60,7 +60,7 @@ public class UserEntity public List? ReceivedMessageTransfers { get; set; } public List? FailedLoginAttempts { get; set; } public List? Consents { get; set; } - public List MultiFactorChallenges { get; set; } + public List? MultiFactorChallenges { get; set; } /// /// Please avoid using this. diff --git a/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs b/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs index 6a9f5718e..8d019b2db 100644 --- a/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs +++ b/Crypter.DataAccess/Entities/UserMultiFactorChallengeEntity.cs @@ -36,7 +36,7 @@ public class UserMultiFactorChallengeEntity public string VerificationCode { get; set; } public DateTime Created { get; set; } - public UserEntity User { get; set; } + public UserEntity? User { get; set; } public UserMultiFactorChallengeEntity(Guid id, Guid owner, string verificationCode, DateTime created) { From c1efa8921f8015bde58c288220a5078a9accd2e4 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 21:17:24 -0500 Subject: [PATCH 05/10] Inject interface instead of implementation --- .../Features/UserAuthentication/Commands/UserLoginCommand.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 9e7637f70..e1c93ec9a 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -55,7 +55,7 @@ internal sealed class UserLoginCommandHandler : IEitherRequestHandler passwordSettings, IPasswordHashService passwordHashService, IPublisher publisher, ITokenService tokenService) + public UserLoginCommandHandler(IBackgroundJobClient backgroundJobClient, DataContext dataContext, IHangfireBackgroundService hangfireBackgroundService, IHashIdService hashIdService, IOptions passwordSettings, IPasswordHashService passwordHashService, IPublisher publisher, ITokenService tokenService) { _backgroundJobClient = backgroundJobClient; _dataContext = dataContext; From 11f871dd454217478ca002d472e24453257673d0 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 21:30:33 -0500 Subject: [PATCH 06/10] Add migration --- ...dUserMultifactorChallengeTable.Designer.cs | 836 ++++++++++++++++++ ...023000_AddUserMultifactorChallengeTable.cs | 64 ++ .../Migrations/DataContextModelSnapshot.cs | 40 + 3 files changed, 940 insertions(+) create mode 100644 Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.Designer.cs create mode 100644 Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.cs diff --git a/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.Designer.cs b/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.Designer.cs new file mode 100644 index 000000000..2ee944c6a --- /dev/null +++ b/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.Designer.cs @@ -0,0 +1,836 @@ +// +using System; +using System.Text.Json; +using Crypter.DataAccess; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Crypter.DataAccess.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250509023000_AddUserMultifactorChallengeTable")] + partial class AddUserMultifactorChallengeTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("crypter") + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Crypter.DataAccess.Entities.AnonymousFileTransferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyExchangeNonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Parts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Proof") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("AnonymousFileTransfer", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.AnonymousMessageTransferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyExchangeNonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Proof") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AnonymousMessageTransfer", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.ApplicationSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FreeTransferQuota") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSetting", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.EventLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EventLogType") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventLogType"); + + b.ToTable("EventLog", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.TransferTierEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("DefaultForUserCategory") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MaximumMessageLength") + .HasColumnType("integer"); + + b.Property("MaximumUploadSize") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserQuota") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DefaultForUserCategory") + .IsUnique(); + + b.ToTable("TransferTier", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserConsentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Activated") + .HasColumnType("timestamp with time zone"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConsentType") + .HasColumnType("integer"); + + b.Property("Deactivated") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Owner"); + + b.ToTable("UserConsent", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserContactEntity", b => + { + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("Owner"); + + b.Property("ContactId") + .HasColumnType("uuid") + .HasColumnName("Contact"); + + b.HasKey("OwnerId", "ContactId"); + + b.HasIndex("ContactId"); + + b.ToTable("UserContact", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserEmailChangeEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailAddress") + .IsRequired() + .HasColumnType("citext"); + + b.Property("VerificationKey") + .HasColumnType("bytea"); + + b.Property("VerificationSent") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Owner"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("EmailAddress") + .IsUnique(); + + b.ToTable("UserEmailChange", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientPasswordVersion") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailAddress") + .HasColumnType("citext"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RequireTwoFactorAuthentication") + .HasColumnType("boolean"); + + b.Property("ServerPasswordVersion") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.Property("Username") + .IsRequired() + .HasColumnType("citext"); + + b.HasKey("Id"); + + b.HasIndex("EmailAddress") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("User", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserFailedLoginEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Owner"); + + b.ToTable("UserFailedLogin", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserFileTransferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyExchangeNonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Parts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Proof") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .HasColumnType("bytea"); + + b.Property("RecipientId") + .HasColumnType("uuid") + .HasColumnName("Recipient"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("Sender"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId"); + + b.ToTable("UserFileTransfer", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserKeyPairEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Owner"); + + b.ToTable("UserKeyPair", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMasterKeyEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RecoveryProof") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Owner"); + + b.ToTable("UserMasterKey", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMessageTransferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyExchangeNonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Proof") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .HasColumnType("bytea"); + + b.Property("RecipientId") + .HasColumnType("uuid") + .HasColumnName("Recipient"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("Sender"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId"); + + b.ToTable("UserMessageTransfer", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMultiFactorChallengeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("VerificationCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("Owner"); + + b.ToTable("UserMultiFactorChallenge", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserNotificationSettingEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("EmailNotifications") + .HasColumnType("boolean"); + + b.Property("EnableTransferNotifications") + .HasColumnType("boolean"); + + b.HasKey("Owner"); + + b.ToTable("UserNotificationSetting", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserPrivacySettingEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("AllowKeyExchangeRequests") + .HasColumnType("boolean"); + + b.Property("ReceiveFiles") + .HasColumnType("integer"); + + b.Property("ReceiveMessages") + .HasColumnType("integer"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Owner"); + + b.ToTable("UserPrivacySetting", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserProfileEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("About") + .IsRequired() + .HasColumnType("text"); + + b.Property("Alias") + .IsRequired() + .HasColumnType("text"); + + b.Property("Image") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Owner"); + + b.ToTable("UserProfile", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserRecoveryEntity", b => + { + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("VerificationKey") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Owner"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UserRecovery", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Owner"); + + b.ToTable("UserToken", "crypter"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserConsentEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithMany("Consents") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserContactEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Contact") + .WithMany("Contactors") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Owner") + .WithMany("Contacts") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contact"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserEmailChangeEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("EmailChange") + .HasForeignKey("Crypter.DataAccess.Entities.UserEmailChangeEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserFailedLoginEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithMany("FailedLoginAttempts") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserFileTransferEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Recipient") + .WithMany("ReceivedFileTransfers") + .HasForeignKey("RecipientId"); + + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Sender") + .WithMany("SentFileTransfers") + .HasForeignKey("SenderId"); + + b.Navigation("Recipient"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserKeyPairEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("KeyPair") + .HasForeignKey("Crypter.DataAccess.Entities.UserKeyPairEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMasterKeyEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("MasterKey") + .HasForeignKey("Crypter.DataAccess.Entities.UserMasterKeyEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMessageTransferEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Recipient") + .WithMany("ReceivedMessageTransfers") + .HasForeignKey("RecipientId"); + + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "Sender") + .WithMany("SentMessageTransfers") + .HasForeignKey("SenderId"); + + b.Navigation("Recipient"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMultiFactorChallengeEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithMany("MultiFactorChallenges") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserNotificationSettingEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("NotificationSetting") + .HasForeignKey("Crypter.DataAccess.Entities.UserNotificationSettingEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserPrivacySettingEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("PrivacySetting") + .HasForeignKey("Crypter.DataAccess.Entities.UserPrivacySettingEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserProfileEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("Profile") + .HasForeignKey("Crypter.DataAccess.Entities.UserProfileEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserRecoveryEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithOne("Recovery") + .HasForeignKey("Crypter.DataAccess.Entities.UserRecoveryEntity", "Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserTokenEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithMany("Tokens") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Crypter.DataAccess.Entities.UserEntity", b => + { + b.Navigation("Consents"); + + b.Navigation("Contactors"); + + b.Navigation("Contacts"); + + b.Navigation("EmailChange"); + + b.Navigation("FailedLoginAttempts"); + + b.Navigation("KeyPair"); + + b.Navigation("MasterKey"); + + b.Navigation("MultiFactorChallenges"); + + b.Navigation("NotificationSetting"); + + b.Navigation("PrivacySetting"); + + b.Navigation("Profile"); + + b.Navigation("ReceivedFileTransfers"); + + b.Navigation("ReceivedMessageTransfers"); + + b.Navigation("Recovery"); + + b.Navigation("SentFileTransfers"); + + b.Navigation("SentMessageTransfers"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.cs b/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.cs new file mode 100644 index 000000000..7b9bc87c3 --- /dev/null +++ b/Crypter.DataAccess/Migrations/20250509023000_AddUserMultifactorChallengeTable.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Crypter.DataAccess.Migrations +{ + /// + public partial class AddUserMultifactorChallengeTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequireTwoFactorAuthentication", + schema: "crypter", + table: "User", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "UserMultiFactorChallenge", + schema: "crypter", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Owner = table.Column(type: "uuid", nullable: false), + VerificationCode = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserMultiFactorChallenge", x => x.Id); + table.ForeignKey( + name: "FK_UserMultiFactorChallenge_User_Owner", + column: x => x.Owner, + principalSchema: "crypter", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserMultiFactorChallenge_Owner", + schema: "crypter", + table: "UserMultiFactorChallenge", + column: "Owner"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserMultiFactorChallenge", + schema: "crypter"); + + migrationBuilder.DropColumn( + name: "RequireTwoFactorAuthentication", + schema: "crypter", + table: "User"); + } + } +} diff --git a/Crypter.DataAccess/Migrations/DataContextModelSnapshot.cs b/Crypter.DataAccess/Migrations/DataContextModelSnapshot.cs index fd5be462e..b5f190007 100644 --- a/Crypter.DataAccess/Migrations/DataContextModelSnapshot.cs +++ b/Crypter.DataAccess/Migrations/DataContextModelSnapshot.cs @@ -289,6 +289,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("bytea"); + b.Property("RequireTwoFactorAuthentication") + .HasColumnType("boolean"); + b.Property("ServerPasswordVersion") .ValueGeneratedOnAdd() .HasColumnType("smallint") @@ -484,6 +487,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserMessageTransfer", "crypter"); }); + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMultiFactorChallengeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("VerificationCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("Owner"); + + b.ToTable("UserMultiFactorChallenge", "crypter"); + }); + modelBuilder.Entity("Crypter.DataAccess.Entities.UserNotificationSettingEntity", b => { b.Property("Owner") @@ -698,6 +725,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Sender"); }); + modelBuilder.Entity("Crypter.DataAccess.Entities.UserMultiFactorChallengeEntity", b => + { + b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") + .WithMany("MultiFactorChallenges") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Crypter.DataAccess.Entities.UserNotificationSettingEntity", b => { b.HasOne("Crypter.DataAccess.Entities.UserEntity", "User") @@ -769,6 +807,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("MasterKey"); + b.Navigation("MultiFactorChallenges"); + b.Navigation("NotificationSetting"); b.Navigation("PrivacySetting"); From 058f75d2789f86c8667dcad81d7b52572cf74851 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Thu, 8 May 2025 22:09:50 -0500 Subject: [PATCH 07/10] Fix silly timezone bug --- .../Features/UserAuthentication/Commands/UserLoginCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index e1c93ec9a..11e72c088 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -285,7 +285,7 @@ private Either UpgradePasswordIfRequired(ValidLoginRequest val private async Task> CreateLoginResponseAsync(UserEntity userEntity, TokenType refreshTokenType, string deviceDescription) { - userEntity.LastLogin = _currentTime.DateTime; + userEntity.LastLogin = _currentTime.UtcDateTime; RefreshTokenData refreshToken = _refreshTokenProviderMap[refreshTokenType].Invoke(userEntity.Id); UserTokenEntity tokenEntity = new UserTokenEntity( From 599aefad6fcdcc88a95433d5729bf63649e10424 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sat, 10 May 2025 18:17:37 -0500 Subject: [PATCH 08/10] Begin trickling the challenge response to the client --- .../Crypter.Common.Client.csproj | 6 +++ .../CrypterAuthenticatedHttpClient.cs | 49 +++++++++++++++++-- .../HttpClients/CrypterHttpClient.cs | 45 +++++++++++++++-- .../Requests/UserAuthenticationRequests.cs | 10 ++-- .../HttpClients/ICrypterHttpClient.cs | 9 +++- .../Requests/IUserAuthenticationRequests.cs | 3 +- .../Services/IUserSessionService.cs | 3 +- .../Services/UserSessionService.cs | 32 ++++++------ .../SendMultiFactorVerificationCodeCommand.cs | 24 +++++---- .../Commands/UserLoginCommand.cs | 27 +++++----- 10 files changed, 149 insertions(+), 59 deletions(-) diff --git a/Crypter.Common.Client/Crypter.Common.Client.csproj b/Crypter.Common.Client/Crypter.Common.Client.csproj index 80ab0ee7f..64401f1ce 100644 --- a/Crypter.Common.Client/Crypter.Common.Client.csproj +++ b/Crypter.Common.Client/Crypter.Common.Client.csproj @@ -18,4 +18,10 @@ + + + ..\..\..\.nuget\packages\oneof\3.0.271\lib\netstandard2.0\OneOf.dll + + + diff --git a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs index 630ca893a..22a1a43f6 100644 --- a/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterAuthenticatedHttpClient.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2025 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -40,6 +40,7 @@ using Crypter.Common.Contracts; using Crypter.Common.Contracts.Features.UserAuthentication; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.HttpClients; @@ -154,6 +155,16 @@ public async Task> PostEitherAsync(response); } + public async Task>> PostEitherAsync(string uri, TRequest body, HttpStatusCode t0StatusCode, HttpStatusCode t1StatusCode) + where T0 : class + where T1 : class + where TRequest : class + { + Func requestFactory = MakeRequestMessageFactory(HttpMethod.Post, uri, body); + using HttpResponseMessage response = await SendWithAuthenticationAsync(requestFactory, false); + return await DeserializeResponseAsync(response, t0StatusCode, t1StatusCode); + } + public async Task> PostMaybeUnitResponseAsync(string uri, TRequest body) where TRequest : class { @@ -325,8 +336,7 @@ private async Task> DeserializeEitherUnitResponseAsy return Unit.Default; } - private async Task> DeserializeResponseAsync( - HttpResponseMessage response) + private async Task> DeserializeResponseAsync(HttpResponseMessage response) { if (response.StatusCode == HttpStatusCode.Unauthorized) { @@ -339,9 +349,38 @@ private async Task> DeserializeResponseAsync(stream, _jsonSerializerOptions) .ConfigureAwait(false); } + + private async Task>> DeserializeResponseAsync(HttpResponseMessage response, HttpStatusCode t0StatusCode, HttpStatusCode t1StatusCode) + { + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return Either>.Neither; + } + + await using Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + OneOf? result = null; + if (response.StatusCode == t0StatusCode) + { + T0? t0 = await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + result = t0 ?? (OneOf?)null; + } + + if (response.StatusCode == t1StatusCode) + { + T1? t1 = await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + result = t1 ?? (OneOf?)null; + } + + if (result is null) + { + return await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + } + + return result.Value; + } - private async Task> GetStreamResponseAsync( - HttpResponseMessage response) + private async Task> GetStreamResponseAsync(HttpResponseMessage response) { if (response.StatusCode == HttpStatusCode.Unauthorized) { diff --git a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs index 0882d3f41..7357b53ed 100644 --- a/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs +++ b/Crypter.Common.Client/HttpClients/CrypterHttpClient.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2025 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -26,6 +26,7 @@ using System; using System.IO; +using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; @@ -33,6 +34,7 @@ using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Contracts; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.HttpClients; @@ -84,6 +86,15 @@ public async Task> PostEitherAsync(request); } + public async Task>> PostEitherAsync(string uri, TRequest body, HttpStatusCode t0, HttpStatusCode t1) + where T0 : class + where T1 : class + where TRequest : class + { + using HttpRequestMessage request = MakeRequestMessage(HttpMethod.Post, uri, body); + return await SendRequestEitherResponseAsync(request, t0, t1); + } + public async Task> PostMaybeUnitResponseAsync(string uri, TRequest body) where TRequest : class { @@ -171,15 +182,39 @@ private async Task> SendRequestEitherResponseAs private async Task> SendRequestEitherUnitResponseAsync(HttpRequestMessage request) { - using HttpResponseMessage response = - await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); await using Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); return response.IsSuccessStatusCode ? Unit.Default - : await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions) - .ConfigureAwait(false); + : await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); } + private async Task>> SendRequestEitherResponseAsync(HttpRequestMessage request, HttpStatusCode t0StatusCode, HttpStatusCode t1StatusCode) + { + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + await using Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + OneOf? result = null; + if (response.StatusCode == t0StatusCode) + { + T0? t0 = await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + result = t0 ?? (OneOf?)null; + } + + if (response.StatusCode == t1StatusCode) + { + T1? t1 = await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + result = t1 ?? (OneOf?)null; + } + + if (result is null) + { + return await JsonSerializer.DeserializeAsync(stream, _jsonSerializerOptions).ConfigureAwait(false); + } + + return result.Value; + } + private async Task> GetStreamAsync(HttpRequestMessage request) { HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) diff --git a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs index f62e01f62..eeb582b28 100644 --- a/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs +++ b/Crypter.Common.Client/HttpClients/Requests/UserAuthenticationRequests.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2025 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -25,11 +25,13 @@ */ using System; +using System.Net; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.HttpClients; using Crypter.Common.Client.Interfaces.Requests; using Crypter.Common.Contracts.Features.UserAuthentication; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.HttpClients.Requests; @@ -59,11 +61,11 @@ public Task> RegisterAsync(RegistrationRequest r .ExtractErrorCode(); } - public Task> LoginAsync(LoginRequest loginRequest) + public Task>> LoginAsync(LoginRequest loginRequest) { const string url = "api/user/authentication/login"; - return _crypterHttpClient.PostEitherAsync(url, loginRequest) - .ExtractErrorCode(); + return _crypterHttpClient.PostEitherAsync(url, loginRequest, HttpStatusCode.OK, HttpStatusCode.TemporaryRedirect) + .ExtractErrorCode>(); } public async Task> RefreshSessionAsync() diff --git a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs index 83958076d..1625e1037 100644 --- a/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs +++ b/Crypter.Common.Client/Interfaces/HttpClients/ICrypterHttpClient.cs @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Crypter File Transfer + * Copyright (C) 2025 Crypter File Transfer * * This file is part of the Crypter file transfer project. * @@ -25,10 +25,12 @@ */ using System; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using Crypter.Common.Contracts; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.Interfaces.HttpClients; @@ -48,6 +50,11 @@ Task> PostEitherAsync(stri where TRequest : class where TResponse : class; + Task>> PostEitherAsync(string uri, TRequest body, HttpStatusCode t0, HttpStatusCode t1) + where TRequest : class + where T0 : class + where T1 : class; + Task> PostMaybeUnitResponseAsync(string uri); Task> PostMaybeUnitResponseAsync(string uri, TRequest body) diff --git a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs index 27fee08e2..2a48b76c4 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs @@ -28,6 +28,7 @@ using System.Threading.Tasks; using Crypter.Common.Contracts.Features.UserAuthentication; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.Interfaces.Requests; @@ -35,7 +36,7 @@ public interface IUserAuthenticationRequests { EventHandler? RefreshTokenRejectedHandler { set; } Task> RegisterAsync(RegistrationRequest registerRequest); - Task> LoginAsync(LoginRequest loginRequest); + Task>> LoginAsync(LoginRequest loginRequest); Task> RefreshSessionAsync(); Task> PasswordChallengeAsync(PasswordChallengeRequest testPasswordRequest); Task> ChangePasswordAsync(PasswordChangeRequest passwordChangeRequest); diff --git a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs index 7021dd9cb..eefdd95ed 100644 --- a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs +++ b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs @@ -31,6 +31,7 @@ using Crypter.Common.Contracts.Features.UserAuthentication; using Crypter.Common.Primitives; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.Interfaces.Services; @@ -39,7 +40,7 @@ public interface IUserSessionService Maybe Session { get; } Task IsLoggedInAsync(); - Task> LoginAsync(Username username, Password password, bool rememberUser); + Task>> LoginAsync(Username username, Password password, bool rememberUser); Task TestPasswordAsync(Password password); Task LogoutAsync(); diff --git a/Crypter.Common.Client/Services/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index 7fe32add5..1a02c20d3 100644 --- a/Crypter.Common.Client/Services/UserSessionService.cs +++ b/Crypter.Common.Client/Services/UserSessionService.cs @@ -39,6 +39,7 @@ using Crypter.Common.Enums; using Crypter.Common.Primitives; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.Services; @@ -132,7 +133,7 @@ public async Task IsLoggedInAsync() return Session.IsSome; } - public Task> LoginAsync(Username username, Password password, bool rememberUser) + public Task>> LoginAsync(Username username, Password password, bool rememberUser) { return _userPasswordService .DeriveUserAuthenticationPasswordAsync(username, password, _userPasswordService.CurrentPasswordVersion) @@ -141,24 +142,23 @@ public Task> LoginAsync(Username username, Password pas async versionedPassword => { List versionedPasswords = [versionedPassword]; - Task> loginTask = from loginResponse in LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser]) - from unit0 in Either.FromRightAsync(StoreSessionInfo(loginResponse, rememberUser)) - select loginResponse; + Either> loginResponse = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser]); + await loginResponse.DoRightAsync(async x => + { + x.MapT1(y => StoreSessionInfo(y, rememberUser)); + bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() + .MatchAsync( + none: () => false, + some: y => y.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); + HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); + }); - return await loginTask - .DoRightAsync(async x => - { - bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() - .MatchAsync( - none: () => false, - some: y => y.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); - HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); - }) - .BindAsync(_ => Unit.Default); + return loginResponse + .Bind>(x => x.MapT1(_ => Unit.Default)); }); } - private Task> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType) + private Task>> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType) { return SendLoginRequestAsync(username, versionedPasswords, refreshTokenType) .MatchAsync( @@ -252,7 +252,7 @@ public event EventHandler UserPasswordTestSucc #endregion - private Task> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType) + private Task>> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType) { LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType, null); return _crypterApiClient.UserAuthentication.LoginAsync(loginRequest); diff --git a/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs index 197f98311..c5283e28e 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs @@ -63,25 +63,23 @@ public async Task Handle(SendMultiFactorVerificationCodeCommand request, C string verificationCode = _cryptoProvider.Random.GenerateRandomString(OneTimePasswordLength, OneTimePasswordAlphabet); UserMultiFactorChallengeEntity challengeEntity = new UserMultiFactorChallengeEntity(request.MultiFactorChallengeId, request.UserId, verificationCode, DateTime.UtcNow); - string? verifiedEmailAddress = await _dataContext.Users - .Where(x => x.Id == request.UserId && x.EmailAddress != null) + string? emailAddress = await _dataContext.Users + .Where(x => x.Id == request.UserId && !string.IsNullOrEmpty(x.EmailAddress)) .Select(x => x.EmailAddress) .FirstOrDefaultAsync(CancellationToken.None); - if (!EmailAddress.TryFrom(verifiedEmailAddress!, out EmailAddress validEmailAddress)) + if (emailAddress is not null && EmailAddress.TryFrom(emailAddress, out EmailAddress validEmailAddress)) { - // Return true to indicate we succeeded as much as we could. - // Returning false may indicate to the caller the command failed for some reason outside our control and should be retried. - return true; - } + bool emailSuccess = await _emailService.SendMultiFactorChallengeEmailAsync(validEmailAddress, verificationCode, request.ChallengeExpirationMinutes); + if (emailSuccess) + { + _dataContext.UserMultiFactorChallenges.Add(challengeEntity); + await _dataContext.SaveChangesAsync(CancellationToken.None); + } - bool emailSuccess = await _emailService.SendMultiFactorChallengeEmailAsync(validEmailAddress, verificationCode, request.ChallengeExpirationMinutes); - if (emailSuccess) - { - _dataContext.UserMultiFactorChallenges.Add(challengeEntity); - await _dataContext.SaveChangesAsync(CancellationToken.None); + return emailSuccess; } - return emailSuccess; + return true; } } diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 11e72c088..d9d90e947 100644 --- a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs +++ b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs @@ -96,19 +96,20 @@ from foundUser in GetUserAsync(validLoginRequest) from passwordVerificationSuccess in VerifyPassword(validLoginRequest, foundUser).AsTask() from twoFactorAuthenticationRequired in CheckMultiFactorAuthentication(foundUser, validLoginRequest.ValidMultiFactorVerification).AsTask() select twoFactorAuthenticationRequired.MapT1(_ => validLoginRequest))) - .BindAsync(async preliminaryLoginResult => { - if (preliminaryLoginResult.TryPickT0(out Guid challengeId, out ValidLoginRequest validLoginRequest)) - { - _backgroundJobClient.Enqueue(() => _hangfireBackgroundService.SendMultiFactorVerificationCodeAsync(_foundUserEntity!.Id, challengeId, MultiFactorExpirationMinutes)); - string challengeHash = _hashIdService.Encode(challengeId); - ChallengeResponse challengeResponse = new ChallengeResponse(challengeHash); - return OneOf.FromT0(challengeResponse); - } - - return await ( - from passwordUpgradePerformed in UpgradePasswordIfRequired(validLoginRequest, _foundUserEntity!).AsTask() - from loginResponse in CreateLoginResponseAsync(_foundUserEntity!, validLoginRequest.RefreshTokenType, request.DeviceDescription) - select OneOf.FromT1(loginResponse)); + .BindAsync(async preliminaryLoginResult => + { + return await preliminaryLoginResult + .Match>>>(async x => + { + _backgroundJobClient.Enqueue(() => _hangfireBackgroundService.SendMultiFactorVerificationCodeAsync(_foundUserEntity!.Id, x, MultiFactorExpirationMinutes)); + string challengeHash = _hashIdService.Encode(x); + ChallengeResponse challengeResponse = new ChallengeResponse(challengeHash); + return await OneOf.FromT0(challengeResponse).AsTask(); + }, + async x => await ( + from passwordUpgradePerformed in UpgradePasswordIfRequired(x, _foundUserEntity!).AsTask() + from loginResponse in CreateLoginResponseAsync(_foundUserEntity!, x.RefreshTokenType, request.DeviceDescription) + select OneOf.FromT1(loginResponse))); }) .DoRightAsync(async _ => { From 114be3ae8cf936eec0bafd96cde33ae1246c3769 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 3 Jun 2025 20:55:43 -0500 Subject: [PATCH 09/10] Make an attempt at stuff --- .../Services/IUserSessionService.cs | 2 +- .../Services/UserSessionService.cs | 37 +++++++----- Crypter.Web/Shared/LoginComponent.razor.cs | 24 ++++++-- .../Modal/PasswordChallengeModal.razor.cs | 1 - .../Modal/TwoFactorChallengeModal.razor | 48 ++++++++++++++++ .../Modal/TwoFactorChallengeModal.razor.cs | 56 +++++++++++++++++++ 6 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor create mode 100644 Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor.cs diff --git a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs index eefdd95ed..e04c10716 100644 --- a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs +++ b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs @@ -40,7 +40,7 @@ public interface IUserSessionService Maybe Session { get; } Task IsLoggedInAsync(); - Task>> LoginAsync(Username username, Password password, bool rememberUser); + Task>> LoginAsync(Username username, Password password, bool rememberUser, MultiFactorVerification? multiFactorVerification); Task TestPasswordAsync(Password password); Task LogoutAsync(); diff --git a/Crypter.Common.Client/Services/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index 1a02c20d3..61cc72538 100644 --- a/Crypter.Common.Client/Services/UserSessionService.cs +++ b/Crypter.Common.Client/Services/UserSessionService.cs @@ -133,7 +133,7 @@ public async Task IsLoggedInAsync() return Session.IsSome; } - public Task>> LoginAsync(Username username, Password password, bool rememberUser) + public Task>> LoginAsync(Username username, Password password, bool rememberUser, Func>? getMultiFactorVerification) { return _userPasswordService .DeriveUserAuthenticationPasswordAsync(username, password, _userPasswordService.CurrentPasswordVersion) @@ -142,15 +142,18 @@ public Task>> LoginAsync(Usern async versionedPassword => { List versionedPasswords = [versionedPassword]; - Either> loginResponse = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser]); + Either> loginResponse = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser], getMultiFactorVerification); await loginResponse.DoRightAsync(async x => { - x.MapT1(y => StoreSessionInfo(y, rememberUser)); - bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() - .MatchAsync( - none: () => false, - some: y => y.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); - HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); + await x.MapT1(async y => + { + await StoreSessionInfoAsync(y, rememberUser); + bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() + .MatchAsync( + none: () => false, + some: y => y.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); + HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); + }); }); return loginResponse @@ -158,9 +161,15 @@ await loginResponse.DoRightAsync(async x => }); } - private Task>> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType) + private async Task>> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType, Func>? getMultiFactorVerification) { - return SendLoginRequestAsync(username, versionedPasswords, refreshTokenType) + // TODO + // Make sure the password is not updated unless MFA has been submitted + // Update the API to return the challenge response before InvalidPasswordVersion + MultiFactorVerification? multiFactorVerification = getMultiFactorVerification is null + ? null + : await getMultiFactorVerification(); + return await SendLoginRequestAsync(username, versionedPasswords, refreshTokenType, multiFactorVerification) .MatchAsync( async error => { @@ -174,7 +183,7 @@ private Task>> LoginR async previousVersionedPassword => { versionedPasswords.Add(previousVersionedPassword); - return await LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType); + return await LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType, getMultiFactorVerification); }); } @@ -252,9 +261,9 @@ public event EventHandler UserPasswordTestSucc #endregion - private Task>> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType) + private Task>> SendLoginRequestAsync(Username username, List versionedPasswords, TokenType refreshTokenType, MultiFactorVerification? multiFactorVerification) { - LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType, null); + LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType, multiFactorVerification); return _crypterApiClient.UserAuthentication.LoginAsync(loginRequest); } @@ -287,7 +296,7 @@ public async Task TestPasswordAsync(Password password) } - private Task StoreSessionInfo(LoginResponse response, bool rememberUser) + private Task StoreSessionInfoAsync(LoginResponse response, bool rememberUser) { UserSession sessionInfo = new UserSession(response.Username, rememberUser, UserSession.LATEST_SCHEMA); Session = sessionInfo; diff --git a/Crypter.Web/Shared/LoginComponent.razor.cs b/Crypter.Web/Shared/LoginComponent.razor.cs index b9c37cdb1..e26aa6f98 100644 --- a/Crypter.Web/Shared/LoginComponent.razor.cs +++ b/Crypter.Web/Shared/LoginComponent.razor.cs @@ -34,6 +34,7 @@ using Crypter.Web.Models.Forms; using EasyMonads; using Microsoft.AspNetCore.Components; +using OneOf; namespace Crypter.Web.Shared; @@ -62,20 +63,31 @@ public partial class LoginComponent private async Task SubmitLoginAsync() { - Task> loginTask = from username in ValidateUsername().ToEither(LoginError.InvalidUsername).AsTask() + Task>> loginTask = from username in ValidateUsername().ToEither(LoginError.InvalidUsername).AsTask() from password in ValidatePassword().ToEither(LoginError.InvalidPassword).AsTask() - from loginResult in UserSessionService.LoginAsync(username, password, _loginModel.RememberMe) + from loginResult in UserSessionService.LoginAsync(username, password, _loginModel.RememberMe, null) select loginResult; - Either loginTaskResult = await loginTask; + Either> loginTaskResult = await loginTask; - loginTaskResult - .DoRight(_ => + await loginTaskResult + .MapAsync(async x => + { + // Present modal + await Task.Delay(1); + + // Handle challenge response + return x.MapT0(challengeResponse => + { + return Task.FromResult(Either.Neither); + }); + }) + .DoRightAsync(x => { string returnUrl = NavigationManager.GetQueryParameter("returnUrl") ?? UserLandingPage; NavigationManager.NavigateTo(returnUrl); }) - .DoLeftOrNeither( + .DoLeftOrNeitherAsync( HandleLoginFailure, () => HandleLoginFailure(LoginError.UnknownError)); } diff --git a/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs b/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs index eb1515e25..b881c7c3e 100644 --- a/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs +++ b/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs @@ -24,7 +24,6 @@ * Contact the current copyright holder to discuss commercial license options. */ -using System; using System.Threading.Tasks; using Crypter.Common.Client.Interfaces.Services; using Crypter.Common.Primitives; diff --git a/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor new file mode 100644 index 000000000..9cb27fd2e --- /dev/null +++ b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor @@ -0,0 +1,48 @@ +@* + * Copyright (C) 2025 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. + *@ + + + + + + diff --git a/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor.cs b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor.cs new file mode 100644 index 000000000..3e407480a --- /dev/null +++ b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor.cs @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2025 Crypter File Transfer + * + * This file is part of the Crypter file transfer project. + * + * Crypter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Crypter source code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the aforementioned license + * by purchasing a commercial license. Buying such a license is mandatory + * as soon as you develop commercial activities involving the Crypter source + * code without disclosing the source code of your own applications. + * + * Contact the current copyright holder to discuss commercial license options. + */ + +using System.Threading.Tasks; +using Crypter.Web.Shared.Modal.Template; +using EasyMonads; +using Microsoft.AspNetCore.Components; + +namespace Crypter.Web.Shared.Modal; + +public partial class TwoFactorChallengeModal : ComponentBase +{ + [Parameter] public required EventCallback> ModalClosedCallback { get; set; } + + private ModalBehavior ModalBehaviorRef { get; set; } = null!; + + private async Task CloseAsync(Maybe value) + { + await ModalClosedCallback.InvokeAsync(value); + ModalBehaviorRef.Close(); + } + + private async Task OnSubmitClickedAsync() + { + + } + + private async Task OnCancelClickedAsync() + { + await CloseAsync(Maybe.None); + } +} + From f3a7ae232741ccc115e0c3fba32ced88ef9d36dc Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 16 Jun 2025 08:34:47 -0500 Subject: [PATCH 10/10] WIP --- .../Services/IUserSessionService.cs | 2 +- .../Services/UserSessionService.cs | 81 ++++++++++++------- .../Modal/PasswordChallengeModal.razor.cs | 6 +- .../Modal/TwoFactorChallengeModal.razor | 6 +- .../Modal/TwoFactorChallengeModal.razor.cs | 13 ++- 5 files changed, 70 insertions(+), 38 deletions(-) diff --git a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs index e04c10716..38587ed03 100644 --- a/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs +++ b/Crypter.Common.Client/Interfaces/Services/IUserSessionService.cs @@ -40,7 +40,7 @@ public interface IUserSessionService Maybe Session { get; } Task IsLoggedInAsync(); - Task>> LoginAsync(Username username, Password password, bool rememberUser, MultiFactorVerification? multiFactorVerification); + Task> LoginAsync(Username username, Password password, bool rememberUser, Func> multifactorVerificationDelegate); Task TestPasswordAsync(Password password); Task LogoutAsync(); diff --git a/Crypter.Common.Client/Services/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index 61cc72538..d42196f81 100644 --- a/Crypter.Common.Client/Services/UserSessionService.cs +++ b/Crypter.Common.Client/Services/UserSessionService.cs @@ -133,7 +133,7 @@ public async Task IsLoggedInAsync() return Session.IsSome; } - public Task>> LoginAsync(Username username, Password password, bool rememberUser, Func>? getMultiFactorVerification) + public Task> LoginAsync(Username username, Password password, bool rememberUser, Func> multifactorVerificationDelegate) { return _userPasswordService .DeriveUserAuthenticationPasswordAsync(username, password, _userPasswordService.CurrentPasswordVersion) @@ -142,57 +142,76 @@ public Task>> LoginAsync(Usern async versionedPassword => { List versionedPasswords = [versionedPassword]; - Either> loginResponse = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser], getMultiFactorVerification); - await loginResponse.DoRightAsync(async x => + Either> loginResult = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser], null, multifactorVerificationDelegate); + await loginResult.DoRightAsync(async x => { - await x.MapT1(async y => + await x.Match( + _ => Unit.Default.AsTask(), + async loginResponse => { - await StoreSessionInfoAsync(y, rememberUser); + await StoreSessionInfoAsync(loginResponse, rememberUser); bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() .MatchAsync( none: () => false, - some: y => y.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); + some: z => z.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); + return Unit.Default; }); }); - return loginResponse - .Bind>(x => x.MapT1(_ => Unit.Default)); + return loginResult.Bind(x => x.Match>(_ => LoginError.UnknownError, _ => Unit.Default)); }); } - private async Task>> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType, Func>? getMultiFactorVerification) + /// + /// Send sequential login requests to the API. + /// This automatically handles MFA and password upgrade responses from the API. + /// + /// + /// + /// + /// + /// + /// + /// + private async Task>> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType, string? challengeHash, Func> multifactorVerificationDelegate) { // TODO // Make sure the password is not updated unless MFA has been submitted // Update the API to return the challenge response before InvalidPasswordVersion - MultiFactorVerification? multiFactorVerification = getMultiFactorVerification is null + MultiFactorVerification? multiFactorVerification = string.IsNullOrEmpty(challengeHash) ? null - : await getMultiFactorVerification(); + : await multifactorVerificationDelegate(challengeHash); + return await SendLoginRequestAsync(username, versionedPasswords, refreshTokenType, multiFactorVerification) - .MatchAsync( - async error => + .MapAsync(async response => await response.Match>>>( + // Recursive case + challengeResponse => LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType, challengeResponse.ChallengeHash, multifactorVerificationDelegate), + + // Base success case + loginResponse => Either>.FromRight(loginResponse).AsTask())) + .MapLeftAsync(async error => + { + int oldestPasswordVersionAttempted = versionedPasswords.Min(x => x.Version); + if (error == LoginError.InvalidPasswordVersion && oldestPasswordVersionAttempted > 0) { - int oldestPasswordVersionAttempted = versionedPasswords.Min(x => x.Version); - if (error == LoginError.InvalidPasswordVersion && oldestPasswordVersionAttempted > 0) - { - return await _userPasswordService - .DeriveUserAuthenticationPasswordAsync(username, password, oldestPasswordVersionAttempted - 1) - .MatchAsync( - () => LoginError.PasswordHashFailure, - async previousVersionedPassword => - { - versionedPasswords.Add(previousVersionedPassword); - return await LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType, getMultiFactorVerification); - }); - } + // Recursive case + return await _userPasswordService + .DeriveUserAuthenticationPasswordAsync(username, password, oldestPasswordVersionAttempted - 1) + .MatchAsync( + () => LoginError.PasswordHashFailure, + async previousVersionedPassword => + { + versionedPasswords.Add(previousVersionedPassword); + return await LoginRecursiveAsync(username, password, versionedPasswords, refreshTokenType, challengeHash, multifactorVerificationDelegate); + }); + } - return error; - }, - response => response, - LoginError.UnknownError); + // Base error case + return Either>.FromLeft(error); + }); } - + public async Task LogoutAsync() { await _crypterApiClient.UserAuthentication.LogoutAsync(); diff --git a/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs b/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs index b881c7c3e..ee06e11dd 100644 --- a/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs +++ b/Crypter.Web/Shared/Modal/PasswordChallengeModal.razor.cs @@ -75,8 +75,10 @@ private async Task OnSubmitClickedAsync() { await CloseAsync(true); } - - _passwordTestFailed = true; + else + { + _passwordTestFailed = true; + } } private async Task OnCancelClickedAsync() diff --git a/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor index 9cb27fd2e..acc4d36a0 100644 --- a/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor +++ b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor @@ -32,10 +32,10 @@