diff --git a/Crypter.API/Controllers/UserAuthenticationController.cs b/Crypter.API/Controllers/UserAuthenticationController.cs index d1a371fdb..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; @@ -101,6 +100,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 +116,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 +127,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 +164,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 +184,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.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 f71a28b79..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,12 +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 Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.HttpClients.Requests; @@ -60,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 d30bdd273..2a48b76c4 100644 --- a/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs +++ b/Crypter.Common.Client/Interfaces/Requests/IUserAuthenticationRequests.cs @@ -27,8 +27,8 @@ using System; using System.Threading.Tasks; using Crypter.Common.Contracts.Features.UserAuthentication; -using Crypter.Common.Contracts.Features.UserAuthentication.PasswordChange; using EasyMonads; +using OneOf; namespace Crypter.Common.Client.Interfaces.Requests; @@ -36,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..38587ed03 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, Func> multifactorVerificationDelegate); Task TestPasswordAsync(Password password); Task LogoutAsync(); 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/UserSessionService.cs b/Crypter.Common.Client/Services/UserSessionService.cs index 9a6269a1f..d42196f81 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, Func> multifactorVerificationDelegate) { return _userPasswordService .DeriveUserAuthenticationPasswordAsync(username, password, _userPasswordService.CurrentPasswordVersion) @@ -141,49 +142,76 @@ 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; - - 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); + Either> loginResult = await LoginRecursiveAsync(username, password, versionedPasswords, _trustDeviceRefreshTokenTypeMap[rememberUser], null, multifactorVerificationDelegate); + await loginResult.DoRightAsync(async x => + { + await x.Match( + _ => Unit.Default.AsTask(), + async loginResponse => + { + await StoreSessionInfoAsync(loginResponse, rememberUser); + bool showRecoveryKeyModal = await _crypterApiClient.UserConsent.GetUserConsentsAsync() + .MatchAsync( + none: () => false, + some: z => z.TryGetValue(UserConsentType.RecoveryKeyRisks, out DateTimeOffset? value) && !value.HasValue); + HandleUserLoggedInEvent(username, password, versionedPassword, rememberUser, showRecoveryKeyModal); + return Unit.Default; + }); + }); + + return loginResult.Bind(x => x.Match>(_ => LoginError.UnknownError, _ => Unit.Default)); }); } - private Task> LoginRecursiveAsync(Username username, Password password, List versionedPasswords, TokenType refreshTokenType) + /// + /// 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) { - return SendLoginRequestAsync(username, versionedPasswords, refreshTokenType) - .MatchAsync( - async error => + // 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 = string.IsNullOrEmpty(challengeHash) + ? null + : await multifactorVerificationDelegate(challengeHash); + + return await SendLoginRequestAsync(username, versionedPasswords, refreshTokenType, multiFactorVerification) + .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); - }); - } - - return error; - }, - response => response, - LoginError.UnknownError); + // 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); + }); + } + + // Base error case + return Either>.FromLeft(error); + }); } - + public async Task LogoutAsync() { await _crypterApiClient.UserAuthentication.LogoutAsync(); @@ -252,9 +280,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); + LoginRequest loginRequest = new LoginRequest(username, versionedPasswords, refreshTokenType, multiFactorVerification); return _crypterApiClient.UserAuthentication.LoginAsync(loginRequest); } @@ -287,7 +315,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.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.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.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/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/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/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..c5283e28e --- /dev/null +++ b/Crypter.Core/Features/UserAuthentication/Commands/SendMultiFactorVerificationCodeCommand.cs @@ -0,0 +1,85 @@ +/* + * 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? emailAddress = await _dataContext.Users + .Where(x => x.Id == request.UserId && !string.IsNullOrEmpty(x.EmailAddress)) + .Select(x => x.EmailAddress) + .FirstOrDefaultAsync(CancellationToken.None); + + if (emailAddress is not null && EmailAddress.TryFrom(emailAddress, out EmailAddress validEmailAddress)) + { + bool emailSuccess = await _emailService.SendMultiFactorChallengeEmailAsync(validEmailAddress, verificationCode, request.ChallengeExpirationMinutes); + if (emailSuccess) + { + _dataContext.UserMultiFactorChallenges.Add(challengeEntity); + await _dataContext.SaveChangesAsync(CancellationToken.None); + } + + return emailSuccess; + } + + return true; + } +} diff --git a/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs b/Crypter.Core/Features/UserAuthentication/Commands/UserLoginCommand.cs index 4d5dd88ad..d9d90e947 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 IHashIdService _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, IHashIdService 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,41 @@ 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 => + { + 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 _ => { - 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 +129,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 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 Guid ChallengeId { get; } = ChallengeId; + public string VerificationCode { get; } = VerificationCode; } private Either ValidateLoginRequest(LoginRequest request) @@ -133,11 +159,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 +187,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 +202,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 +222,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 is { RequireTwoFactorAuthentication: true, EmailAddress: not null }) + { + 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 +286,7 @@ private Either VerifyAndUpgradePassword(ValidLoginRequest vali private async Task> CreateLoginResponseAsync(UserEntity userEntity, TokenType refreshTokenType, string deviceDescription) { - userEntity.LastLogin = DateTime.UtcNow; + userEntity.LastLogin = _currentTime.UtcDateTime; RefreshTokenData refreshToken = _refreshTokenProviderMap[refreshTokenType].Invoke(userEntity.Id); UserTokenEntity tokenEntity = new UserTokenEntity( 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.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..856b3d30b 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..8d019b2db --- /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.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"); 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) 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/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..ee06e11dd 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; @@ -76,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 new file mode 100644 index 000000000..acc4d36a0 --- /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..7cce23927 --- /dev/null +++ b/Crypter.Web/Shared/Modal/TwoFactorChallengeModal.razor.cs @@ -0,0 +1,67 @@ +/* + * 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.Common.Client.Interfaces.Services; +using Crypter.Web.Shared.Modal.Template; +using EasyMonads; +using Microsoft.AspNetCore.Components; + +namespace Crypter.Web.Shared.Modal; + +public partial class TwoFactorChallengeModal : ComponentBase +{ + [Inject] private IUserSessionService UserSessionService { get; set; } + + [Parameter] public required EventCallback> ModalClosedCallback { get; set; } + + private string _code = string.Empty; + private bool _invalidCode; + + private ModalBehavior ModalBehaviorRef { get; set; } = null!; + + private async Task CloseAsync(Maybe value) + { + await ModalClosedCallback.InvokeAsync(value); + ModalBehaviorRef.Close(); + } + + private async Task SubmitCode() + { + await UserSessionService.LoginAsync(); + } + + private async Task OnSubmitClickedAsync() + { + _invalidCode = false; + } + + private async Task OnCancelClickedAsync() + { + await CloseAsync(Maybe.None); + } +} + 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..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; @@ -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,12 @@ await UserPasswordChangeService.ChangePasswordAsync(oldPassword, newPassword) () => HandlePasswordChangeError()); } + private async Task OnSaveMultiFactorAuthenticationClickedAsync() + { + await Task.Delay(1); // TODO implement a real save function + ResetMultiFactorAuthenticationErrors(); + } + private void HandleContactInfoUpdateError(UpdateContactInfoSettingsError error = UpdateContactInfoSettingsError.UnknownError) { _emailAddressPassword = string.Empty;