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