diff --git a/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor b/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor index 60cd065ee..a47b33138 100644 --- a/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor +++ b/src/AliasVault.Admin/Auth/Pages/LoginWithRecoveryCode.razor @@ -9,8 +9,8 @@

- You have requested to log in with a recovery code. This login will not be remembered until you provide - an authenticator app code at log in or disable 2FA and log in again. + You have requested to log in with a recovery code. A recovery code is a one-time code that can be used to log in to your account. + Note that if you don't manually disable 2FA after login, you will be asked for an authenticator code again at the next login.

diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor index 0ecb4d062..17441c747 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor @@ -12,9 +12,9 @@ Configure authenticator app -@if (recoveryCodes is not null) +@if (RecoveryCodes is not null) { - + } else { @@ -34,8 +34,8 @@ else

  • -

    Scan the QR Code or enter this key @sharedKey into your two factor authenticator app. Spaces and casing do not matter.

    -
    +

    Scan the QR Code or enter this key @SharedKey into your two factor authenticator app. Spaces and casing do not matter.

    +
  • @@ -67,9 +67,9 @@ else @code { private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; - private string? sharedKey; - private string? authenticatorUri; - private IEnumerable? recoveryCodes; + private string? SharedKey { get; set; } + private string? AuthenticatorUri { get; set; } + private IEnumerable? RecoveryCodes { get; set; } [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); @@ -77,9 +77,7 @@ else protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - await LoadSharedKeyAndQrCodeUriAsync(UserService.User()); - await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri"); } @@ -88,10 +86,10 @@ else // Strip spaces and hyphens var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); - var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync( + var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync( UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); - if (!is2faTokenValid) + if (!is2FaTokenValid) { GlobalNotificationService.AddErrorMessage("Error: Verification code is invalid."); return; @@ -105,7 +103,7 @@ else if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0) { - recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); + RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); } else { @@ -124,10 +122,10 @@ else unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); } - sharedKey = FormatKey(unformattedKey!); + SharedKey = FormatKey(unformattedKey!); var username = await UserManager.GetUserNameAsync(user); - authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!); + AuthenticatorUri = GenerateQrCodeUri(username!, unformattedKey!); } private string FormatKey(string unformattedKey) diff --git a/src/AliasVault.Admin/Main/Pages/Users/View.razor b/src/AliasVault.Admin/Main/Pages/Users/View.razor index 53de71aa6..0574496ec 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View.razor @@ -49,6 +49,9 @@ else + } }

  • @@ -188,6 +191,27 @@ else StateHasChanged(); } + /// + /// This method enables two-factor authentication for the user based on existing keys. If no keys are present + /// then 2FA will not work. The user will need to manually set up a new authenticator device. + /// + private async Task EnableTwoFactor() + { + User = await DbContext.AliasVaultUsers.FindAsync(Id); + + if (User != null) + { + User.TwoFactorEnabled = true; + await DbContext.SaveChangesAsync(); + await LoadEntryAsync(); + } + } + + /// + /// This method disables two-factor authentication for the user. This will NOT remove the authenticator keys. + /// This means the admin can re-enable 2FA for the user without the user having to set up a new authenticator + /// keys. + /// private async Task DisableTwoFactor() { User = await DbContext.AliasVaultUsers.FindAsync(Id); @@ -200,13 +224,22 @@ else } } - private async Task EnableTwoFactor() + /// + /// This method resets the two-factor authentication for the user which will remove all authenticator keys. The + /// next time the user enables two-factor authentication new keys will be generated. When keys are removed it + /// also means 2FA cannot be re-enabled until the user manually sets up a new authenticator device. + /// + private async Task ResetTwoFactor() { User = await DbContext.AliasVaultUsers.FindAsync(Id); if (User != null) { - User.TwoFactorEnabled = true; + // Remove all authenticator keys and recovery codes. + await DbContext.UserTokens + .Where(x => x.UserId == User.Id && (x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes")) + .ForEachAsync(x => DbContext.UserTokens.Remove(x)); + await DbContext.SaveChangesAsync(); await LoadEntryAsync(); } diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css index 56b9cd1c9..dc3848890 100644 --- a/src/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css @@ -1085,6 +1085,11 @@ video { border-top-width: 1px; } +.border-amber-200 { + --tw-border-opacity: 1; + border-color: rgb(253 230 138 / var(--tw-border-opacity)); +} + .border-gray-200 { --tw-border-opacity: 1; border-color: rgb(229 231 235 / var(--tw-border-opacity)); @@ -1110,24 +1115,19 @@ video { border-color: rgb(239 68 68 / var(--tw-border-opacity)); } -.border-yellow-500 { - --tw-border-opacity: 1; - border-color: rgb(234 179 8 / var(--tw-border-opacity)); -} - -.border-blue-300 { - --tw-border-opacity: 1; - border-color: rgb(147 197 253 / var(--tw-border-opacity)); -} - .border-sky-200 { --tw-border-opacity: 1; border-color: rgb(186 230 253 / var(--tw-border-opacity)); } -.border-amber-200 { +.border-yellow-500 { --tw-border-opacity: 1; - border-color: rgb(253 230 138 / var(--tw-border-opacity)); + border-color: rgb(234 179 8 / var(--tw-border-opacity)); +} + +.bg-amber-50 { + --tw-bg-opacity: 1; + background-color: rgb(255 251 235 / var(--tw-bg-opacity)); } .bg-blue-500 { @@ -1235,6 +1235,16 @@ video { background-color: rgb(220 38 38 / var(--tw-bg-opacity)); } +.bg-red-700 { + --tw-bg-opacity: 1; + background-color: rgb(185 28 28 / var(--tw-bg-opacity)); +} + +.bg-sky-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 249 255 / var(--tw-bg-opacity)); +} + .bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); @@ -1250,26 +1260,6 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } -.bg-red-700 { - --tw-bg-opacity: 1; - background-color: rgb(185 28 28 / var(--tw-bg-opacity)); -} - -.bg-blue-50 { - --tw-bg-opacity: 1; - background-color: rgb(239 246 255 / var(--tw-bg-opacity)); -} - -.bg-sky-50 { - --tw-bg-opacity: 1; - background-color: rgb(240 249 255 / var(--tw-bg-opacity)); -} - -.bg-amber-50 { - --tw-bg-opacity: 1; - background-color: rgb(255 251 235 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1453,6 +1443,11 @@ video { line-height: 2.25rem; } +.text-amber-700 { + --tw-text-opacity: 1; + color: rgb(180 83 9 / var(--tw-text-opacity)); +} + .text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); @@ -1518,6 +1513,11 @@ video { color: rgb(153 27 27 / var(--tw-text-opacity)); } +.text-sky-700 { + --tw-text-opacity: 1; + color: rgb(3 105 161 / var(--tw-text-opacity)); +} + .text-white { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -1533,21 +1533,6 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } -.text-blue-800 { - --tw-text-opacity: 1; - color: rgb(30 64 175 / var(--tw-text-opacity)); -} - -.text-sky-700 { - --tw-text-opacity: 1; - color: rgb(3 105 161 / var(--tw-text-opacity)); -} - -.text-amber-700 { - --tw-text-opacity: 1; - color: rgb(180 83 9 / var(--tw-text-opacity)); -} - .underline { text-decoration-line: underline; } @@ -1755,6 +1740,11 @@ video { border-color: rgb(75 85 99 / var(--tw-divide-opacity)); } +.dark\:border-amber-800:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(146 64 14 / var(--tw-border-opacity)); +} + .dark\:border-gray-500:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(107 114 128 / var(--tw-border-opacity)); @@ -1770,21 +1760,11 @@ video { border-color: rgb(55 65 81 / var(--tw-border-opacity)); } -.dark\:border-blue-800:is(.dark *) { - --tw-border-opacity: 1; - border-color: rgb(30 64 175 / var(--tw-border-opacity)); -} - .dark\:border-sky-800:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(7 89 133 / var(--tw-border-opacity)); } -.dark\:border-amber-800:is(.dark *) { - --tw-border-opacity: 1; - border-color: rgb(146 64 14 / var(--tw-border-opacity)); -} - .dark\:bg-gray-600:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity)); @@ -1820,30 +1800,35 @@ video { background-color: rgb(239 68 68 / var(--tw-bg-opacity)); } -.dark\:bg-red-900:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(127 29 29 / var(--tw-bg-opacity)); -} - -.dark\:bg-yellow-900:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(113 63 18 / var(--tw-bg-opacity)); -} - .dark\:bg-red-600:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(220 38 38 / var(--tw-bg-opacity)); } +.dark\:bg-red-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(127 29 29 / var(--tw-bg-opacity)); +} + .dark\:bg-slate-800:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(30 41 59 / var(--tw-bg-opacity)); } +.dark\:bg-yellow-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(113 63 18 / var(--tw-bg-opacity)); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } +.dark\:text-amber-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(252 211 77 / var(--tw-text-opacity)); +} + .dark\:text-blue-400:is(.dark *) { --tw-text-opacity: 1; color: rgb(96 165 250 / var(--tw-text-opacity)); @@ -1904,6 +1889,11 @@ video { color: rgb(248 113 113 / var(--tw-text-opacity)); } +.dark\:text-sky-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(125 211 252 / var(--tw-text-opacity)); +} + .dark\:text-white:is(.dark *) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -1919,16 +1909,6 @@ video { color: rgb(254 240 138 / var(--tw-text-opacity)); } -.dark\:text-sky-300:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(125 211 252 / var(--tw-text-opacity)); -} - -.dark\:text-amber-300:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(252 211 77 / var(--tw-text-opacity)); -} - .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index c4d2ac37c..2c7e7846f 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -50,6 +50,11 @@ public class AuthController(IDbContextFactory dbContextFac /// private static readonly string[] Invalid2FaCode = ["Invalid authenticator code."]; + /// + /// Error message for invalid 2-factor authentication recovery code. + /// + private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."]; + /// /// Login endpoint used to process login attempt using credentials. /// @@ -168,6 +173,58 @@ public class AuthController(IDbContextFactory dbContextFac } } + /// + /// Validate login including two factor authentication recovery code check. + /// + /// ValidateLoginRequestRecoveryCode model. + /// Task. + [HttpPost("validate-recovery-code")] + public async Task ValidateRecoveryCode([FromBody] ValidateLoginRequestRecoveryCode model) + { + var user = await userManager.FindByNameAsync(model.Username); + if (user == null) + { + return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)); + } + + if (!cache.TryGetValue(model.Username, out var serverSecretEphemeral) || serverSecretEphemeral is not string) + { + return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)); + } + + try + { + var serverSession = Cryptography.Srp.DeriveSessionServer( + serverSecretEphemeral.ToString() ?? string.Empty, + model.ClientPublicEphemeral, + user.Salt, + model.Username, + user.Verifier, + model.ClientSessionProof); + + // If above does not throw an exception., then the client's proof is valid. Next check 2-factor code. + + // Sanitize recovery code. + var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty).ToUpper(); + + // Attempt to redeem the recovery code + var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode); + + if (!result.Succeeded) + { + return BadRequest(ServerValidationErrorResponse.Create(InvalidRecoveryCode, 400)); + } + + // Generate and return the JWT token. + var tokenModel = await GenerateNewTokensForUser(user); + return Ok(new ValidateLoginResponse(false, serverSession.Proof, tokenModel)); + } + catch + { + return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)); + } + } + /// /// Refresh endpoint used to refresh an expired access token using a valid refresh token. /// @@ -258,7 +315,7 @@ public class AuthController(IDbContextFactory dbContextFac } var encodedKey = urlEncoder.Encode(authenticatorKey!); - var qrCodeUrl = $"otpauth://totp/{urlEncoder.Encode("AliasVault WASM")}:{urlEncoder.Encode(user.UserName!)}?secret={encodedKey}&issuer={urlEncoder.Encode("AliasVault WASM")}"; + var qrCodeUrl = $"otpauth://totp/{urlEncoder.Encode("AliasVault")}:{urlEncoder.Encode(user.UserName!)}?secret={encodedKey}&issuer={urlEncoder.Encode("AliasVault")}"; return Ok(new { Secret = authenticatorKey, QrCodeUrl = qrCodeUrl }); } @@ -278,12 +335,12 @@ public class AuthController(IDbContextFactory dbContextFac await using var context = await dbContextFactory.CreateDbContextAsync(); - // Disable 2FA and remove any existing authenticator key(s). + // Disable 2FA and remove any existing authenticator key(s) and recovery codes. await userManager.SetTwoFactorEnabledAsync(user, false); context.UserTokens.RemoveRange( context.UserTokens.Where( x => x.UserId == user.Id && - x.Name == "AuthenticatorKey").ToList()); + (x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes")).ToList()); await context.SaveChangesAsync(); return Ok(); @@ -308,12 +365,14 @@ public class AuthController(IDbContextFactory dbContextFac if (isValid) { await userManager.SetTwoFactorEnabledAsync(user, true); - return Ok(); - } - else - { - return BadRequest("Invalid code."); + + // Generate new recovery codes. + var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + + return Ok(new { RecoveryCodes = recoveryCodes }); } + + return BadRequest("Invalid code."); } /// diff --git a/src/AliasVault.Api/Controllers/FaviconController.cs b/src/AliasVault.Api/Controllers/FaviconController.cs index 82a126f15..96b138b2f 100644 --- a/src/AliasVault.Api/Controllers/FaviconController.cs +++ b/src/AliasVault.Api/Controllers/FaviconController.cs @@ -8,7 +8,7 @@ namespace AliasVault.Api.Controllers; using AliasServerDb; -using AliasVault.Shared.Models; +using AliasVault.Shared.Models.WebApi.Favicon; using Asp.Versioning; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; diff --git a/src/AliasVault.Client/Auth/Pages/Login.razor b/src/AliasVault.Client/Auth/Pages/Login.razor index 16f29d08f..cc64dc72f 100644 --- a/src/AliasVault.Client/Auth/Pages/Login.razor +++ b/src/AliasVault.Client/Auth/Pages/Login.razor @@ -3,9 +3,8 @@ @layout Auth.Layout.MainLayout @attribute [AllowAnonymous] @using System.Text.Json -@using AliasVault.Shared.Models -@using AliasVault.Client.Auth.Components @using AliasVault.Shared.Models.WebApi.Auth +@using AliasVault.Client.Auth.Components @using Cryptography @using SecureRemotePassword @@ -40,7 +39,36 @@

    Don't have access to your authenticator device? You can - log in with a recovery code. + . +

    +} +else if (ShowLoginWithRecoveryCodeStep) +{ +

    + Recovery code verification +

    + + + +

    + You have requested to log in with a recovery code. A recovery code is a one-time code that can be used to log in to your account. + Note that if you don't manually disable 2FA after login, you will be asked for an authenticator code again at the next login. +

    +
    + + + +
    + + + +
    + +
    +
    +

    + Regained access to your authenticator device? You can + instead.

    } else @@ -85,9 +113,11 @@ else @code { private readonly LoginModel LoginModel = new(); private readonly LoginModel2Fa LoginModel2Fa = new(); + private readonly LoginModelRecoveryCode LoginModelRecoveryCode = new(); private FullScreenLoadingIndicator LoadingIndicator = new(); private ServerValidationErrors ServerValidationErrors = new(); - private bool ShowTwoFactorAuthStep = false; + private bool ShowTwoFactorAuthStep; + private bool ShowLoginWithRecoveryCodeStep; private SrpEphemeral ClientEphemeral = new(); private SrpSession ClientSession = new(); @@ -104,6 +134,20 @@ else } } + private void LoginWithAuthenticator() + { + ShowLoginWithRecoveryCodeStep = false; + ShowTwoFactorAuthStep = true; + StateHasChanged(); + } + + private void LoginWithRecoveryCode() + { + ShowLoginWithRecoveryCodeStep = true; + ShowTwoFactorAuthStep = false; + StateHasChanged(); + } + private async Task HandleLogin() { LoadingIndicator.Show(); @@ -136,6 +180,61 @@ else } } + private async Task HandleRecoveryCode() + { + LoadingIndicator.Show(); + ServerValidationErrors.Clear(); + + try + { + // Sanitize username + var username = LoginModel.Username.ToLowerInvariant().Trim(); + + // Validate 2-factor auth code auth and login + var result = await Http.PostAsJsonAsync("api/v1/Auth/validate-recovery-code", new ValidateLoginRequestRecoveryCode(username, ClientEphemeral.Public, ClientSession.Proof, LoginModelRecoveryCode.RecoveryCode)); + var responseContent = await result.Content.ReadAsStringAsync(); + + if (!result.IsSuccessStatusCode) + { + foreach (var error in ParseResponse(responseContent)) + { + ServerValidationErrors.AddError(error); + return; + } + } + + var validateLoginResponse = JsonSerializer.Deserialize(responseContent); + if (validateLoginResponse == null) + { + ServerValidationErrors.AddError("An error occurred while processing the login request."); + return; + } + + var errors = await ProcessLoginVerify(validateLoginResponse); + foreach (var error in errors) + { + ServerValidationErrors.AddError(error); + } + } +#if DEBUG + catch (Exception ex) + { + // If in debug mode show the actual exception. + ServerValidationErrors.AddError(ex.ToString()); + } +#else + catch + { + // If in release mode show a generic error. + ServerValidationErrors.AddError("An error occurred while processing the login request. Try again (later)."); + } +#endif + finally + { + LoadingIndicator.Hide(); + } + } + private async Task Handle2Fa() { LoadingIndicator.Show(); diff --git a/src/AliasVault.Client/Auth/Pages/Register.razor b/src/AliasVault.Client/Auth/Pages/Register.razor index f1f454254..aad447913 100644 --- a/src/AliasVault.Client/Auth/Pages/Register.razor +++ b/src/AliasVault.Client/Auth/Pages/Register.razor @@ -6,7 +6,7 @@ @inject NavigationManager NavigationManager @inject AuthService AuthService @using System.Text.Json -@using AliasVault.Shared.Models +@using AliasVault.Shared.Models.WebApi.Auth @using AliasVault.Client.Auth.Components @using AliasVault.Client.Auth.Pages.Base @using Cryptography diff --git a/src/AliasVault.Client/Main/Pages/Settings/General.razor b/src/AliasVault.Client/Main/Pages/Settings/General.razor index 6d729cd30..c359c0279 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/General.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/General.razor @@ -11,7 +11,7 @@ -
    +

    Email Settings

    diff --git a/src/AliasVault.Client/Main/Pages/Settings/Security/Components/ShowRecoveryCodes.razor b/src/AliasVault.Client/Main/Pages/Settings/Security/Components/ShowRecoveryCodes.razor new file mode 100644 index 000000000..230a91bca --- /dev/null +++ b/src/AliasVault.Client/Main/Pages/Settings/Security/Components/ShowRecoveryCodes.razor @@ -0,0 +1,33 @@ +
    +

    Recovery codes

    +
    + The recovery codes below are used to access your account in case you lose access to your authenticator device. + Make a photo or write them down and store them in a secure location. Do not share them with anyone. +
    + + +
    + @foreach (var recoveryCode in RecoveryCodes) + { +
    + @recoveryCode +
    + } +
    +
    + +@code { + /// + /// The recovery codes to show. + /// + [Parameter] + public string[] RecoveryCodes { get; set; } = []; +} diff --git a/src/AliasVault.Client/Main/Pages/Settings/Security/Disable2Fa.razor b/src/AliasVault.Client/Main/Pages/Settings/Security/Disable2Fa.razor index 7bf0788f1..c206aa5fc 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/Security/Disable2Fa.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/Security/Disable2Fa.razor @@ -24,7 +24,7 @@ else
    Two factor authentication is currently enabled. Disable it in order to be able to access your vault with your password only.
    } diff --git a/src/AliasVault.Client/Main/Pages/Settings/Security/Enable2Fa.razor b/src/AliasVault.Client/Main/Pages/Settings/Security/Enable2Fa.razor index 7c8ea2bf5..064f579c8 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/Security/Enable2Fa.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/Security/Enable2Fa.razor @@ -1,23 +1,28 @@ @page "/settings/security/enable-2fa" +@using AliasVault.Client.Main.Pages.Settings.Security.Components @inherits MainBase @inject HttpClient Http Enable two-factor authentication +
    +
    + +

    Enable two-factor authentication

    +

    Enable two-factor authentication to increase the security of your vaults.

    +
    +
    + @if (IsLoading) { } +else if (RecoveryCodes is not null) +{ + +} else { -
    -
    - -

    Enable two-factor authentication

    -

    Enable two-factor authentication to increase the security of your vaults.

    -
    -
    -
    @@ -49,6 +54,7 @@ else private string Secret { get; set; } = string.Empty; private VerificationModel VerifyModel = new(); private bool IsLoading { get; set; } = true; + private List? RecoveryCodes { get; set; } /// protected override async Task OnInitializedAsync() @@ -76,7 +82,11 @@ else if (result != null) { QrCodeUrl = result.QrCodeUrl; - Secret = result.Secret; + + // Make secret more readable by adding spaces every 4 characters + Secret = string.Join(" ", Enumerable.Range(0, result.Secret.Length / 4) + .Select(i => result.Secret.Substring(i * 4, 4))).ToLower(); + IsLoading = false; StateHasChanged(); JsInteropService.GenerateQrCode("authenticator-uri"); @@ -93,15 +103,23 @@ else var response = await Http.PostAsJsonAsync("api/v1/Auth/verify-2fa", VerifyModel.Code); if (response.IsSuccessStatusCode) { - GlobalNotificationService.AddSuccessMessage("Two-factor authentication is now successfully enabled. On your " + - "next login you will need to enter your 2FA code."); - NavigationManager.NavigateTo("/settings/security"); - } - else - { - GlobalNotificationService.AddErrorMessage("Failed to enable two-factor authentication.", true); - StateHasChanged(); + var result = await response.Content.ReadFromJsonAsync(); + + if (result != null) + { + GlobalNotificationService.AddSuccessMessage("Two-factor authentication is now successfully enabled. On your " + + "next login you will need to enter your 2FA code.", true); + + // Show recovery codes. + RecoveryCodes = result.RecoveryCodes; + IsLoading = false; + StateHasChanged(); + return; + } } + + GlobalNotificationService.AddErrorMessage("Failed to enable two-factor authentication.", true); + StateHasChanged(); } private class TotpSetupResult @@ -110,6 +128,11 @@ else public string QrCodeUrl { get; set; } = string.Empty; } + private class TotpVerifyResult + { + public List RecoveryCodes { get; set; } = new(); + } + private class VerificationModel { public string Code { get; set; } = string.Empty; diff --git a/src/AliasVault.Client/Services/Auth/AuthService.cs b/src/AliasVault.Client/Services/Auth/AuthService.cs index 0b99b1207..4b8e82738 100644 --- a/src/AliasVault.Client/Services/Auth/AuthService.cs +++ b/src/AliasVault.Client/Services/Auth/AuthService.cs @@ -9,7 +9,7 @@ namespace AliasVault.Client.Services.Auth; using System.Net.Http.Json; using System.Text.Json; -using AliasVault.Shared.Models; +using AliasVault.Shared.Models.WebApi.Auth; using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs index ba4710228..11a1f3d53 100644 --- a/src/AliasVault.Client/Services/CredentialService.cs +++ b/src/AliasVault.Client/Services/CredentialService.cs @@ -17,7 +17,7 @@ using AliasClientDb; using AliasGenerators.Identity.Implementations; using AliasGenerators.Identity.Models; using AliasGenerators.Password.Implementations; -using AliasVault.Shared.Models; +using AliasVault.Shared.Models.WebApi.Favicon; using Microsoft.EntityFrameworkCore; using Identity = AliasGenerators.Identity.Models.Identity; diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index 889e4a575..baa99ff52 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -1206,6 +1206,10 @@ video { border-left-width: 0px; } +.border-l-4 { + border-left-width: 4px; +} + .border-t { border-top-width: 1px; } @@ -1245,6 +1249,11 @@ video { border-color: rgb(248 185 99 / var(--tw-border-opacity)); } +.border-primary-500 { + --tw-border-opacity: 1; + border-color: rgb(244 149 65 / var(--tw-border-opacity)); +} + .border-primary-600 { --tw-border-opacity: 1; border-color: rgb(214 131 56 / var(--tw-border-opacity)); @@ -1330,11 +1339,21 @@ video { background-color: rgb(253 222 133 / var(--tw-bg-opacity)); } +.bg-primary-200 { + --tw-bg-opacity: 1; + background-color: rgb(251 203 116 / var(--tw-bg-opacity)); +} + .bg-primary-300 { --tw-bg-opacity: 1; background-color: rgb(248 185 99 / var(--tw-bg-opacity)); } +.bg-primary-500 { + --tw-bg-opacity: 1; + background-color: rgb(244 149 65 / var(--tw-bg-opacity)); +} + .bg-primary-600 { --tw-bg-opacity: 1; background-color: rgb(214 131 56 / var(--tw-bg-opacity)); @@ -1370,11 +1389,6 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.bg-primary-500 { - --tw-bg-opacity: 1; - background-color: rgb(244 149 65 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1872,6 +1886,11 @@ video { background-color: rgb(246 167 82 / var(--tw-bg-opacity)); } +.hover\:bg-primary-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(214 131 56 / var(--tw-bg-opacity)); +} + .hover\:bg-primary-700:hover { --tw-bg-opacity: 1; background-color: rgb(184 112 47 / var(--tw-bg-opacity)); @@ -1897,11 +1916,6 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } -.hover\:bg-primary-600:hover { - --tw-bg-opacity: 1; - background-color: rgb(214 131 56 / var(--tw-bg-opacity)); -} - .hover\:from-primary-600:hover { --tw-gradient-from: #d68338 var(--tw-gradient-from-position); --tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position); diff --git a/src/AliasVault.Shared/Models/RegisterModel.cs b/src/AliasVault.Shared/Models/RegisterModel.cs deleted file mode 100644 index f6eb8160a..000000000 --- a/src/AliasVault.Shared/Models/RegisterModel.cs +++ /dev/null @@ -1,43 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.Shared.Models; - -using System.ComponentModel.DataAnnotations; -using AliasVault.Shared.Models.Validation; - -/// -/// Register model. -/// -public class RegisterModel -{ - /// - /// Gets or sets the username. - /// - [Required] - public string Username { get; set; } = null!; - - /// - /// Gets or sets the password. - /// - [Required] - [MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")] - public string Password { get; set; } = null!; - - /// - /// Gets or sets the password confirmation. - /// - [Required] - [Compare("Password", ErrorMessage = "Passwords do not match.")] - public string PasswordConfirm { get; set; } = null!; - - /// - /// Gets or sets a value indicating whether the terms and conditions are accepted or not. - /// - [MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")] - public bool AcceptTerms { get; set; } = false; -} diff --git a/src/AliasVault.Shared/Models/LoginModel.cs b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModel.cs similarity index 94% rename from src/AliasVault.Shared/Models/LoginModel.cs rename to src/AliasVault.Shared/Models/WebApi/Auth/LoginModel.cs index 5be4e71da..efd7e3fb2 100644 --- a/src/AliasVault.Shared/Models/LoginModel.cs +++ b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Shared.Models; +namespace AliasVault.Shared.Models.WebApi.Auth; using System.ComponentModel.DataAnnotations; diff --git a/src/AliasVault.Shared/Models/LoginModel2Fa.cs b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModel2Fa.cs similarity index 86% rename from src/AliasVault.Shared/Models/LoginModel2Fa.cs rename to src/AliasVault.Shared/Models/WebApi/Auth/LoginModel2Fa.cs index 54d00ec37..512c0f408 100644 --- a/src/AliasVault.Shared/Models/LoginModel2Fa.cs +++ b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModel2Fa.cs @@ -5,12 +5,12 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Shared.Models; +namespace AliasVault.Shared.Models.WebApi.Auth; using System.ComponentModel.DataAnnotations; /// -/// Login model for two factor authentication step. +/// Login model for two factor authentication step using an authenticator code. /// public class LoginModel2Fa { diff --git a/src/AliasVault.Shared/Models/WebApi/Auth/LoginModelRecoveryCode.cs b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModelRecoveryCode.cs new file mode 100644 index 000000000..ab8bfbb93 --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/Auth/LoginModelRecoveryCode.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +using System.ComponentModel.DataAnnotations; + +/// +/// Login model for two factor authentication step using a recovery code. +/// +public class LoginModelRecoveryCode +{ + /// + /// Gets or sets the recovery code. + /// + [Required] + public string RecoveryCode { get; set; } = null!; +} diff --git a/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModel.cs b/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModel.cs index bbb1e2780..2ff6b5fdc 100644 --- a/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModel.cs +++ b/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModel.cs @@ -7,37 +7,37 @@ namespace AliasVault.Shared.Models.WebApi.Auth; +using System.ComponentModel.DataAnnotations; +using AliasVault.Shared.Models.Validation; + /// -/// This class represents the model for registering a new user -/// using SRP (Secure Remote Password) protocol. +/// Register model. /// public class RegisterModel { /// - /// Initializes a new instance of the class. + /// Gets or sets the username. /// - /// Email. - /// Salt. - /// Verifier. - public RegisterModel(string email, string salt, string verifier) - { - Email = email; - Salt = salt; - Verifier = verifier; - } + [Required] + public string Username { get; set; } = null!; /// - /// Gets or sets the email. + /// Gets or sets the password. /// - public string Email { get; set; } + [Required] + [MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")] + public string Password { get; set; } = null!; /// - /// Gets or sets the salt. + /// Gets or sets the password confirmation. /// - public string Salt { get; set; } + [Required] + [Compare("Password", ErrorMessage = "Passwords do not match.")] + public string PasswordConfirm { get; set; } = null!; /// - /// Gets or sets the verifier. + /// Gets or sets a value indicating whether the terms and conditions are accepted or not. /// - public string Verifier { get; set; } + [MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")] + public bool AcceptTerms { get; set; } = false; } diff --git a/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModelOld.cs b/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModelOld.cs new file mode 100644 index 000000000..170e0f448 --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/Auth/RegisterModelOld.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth; + +/// +/// This class represents the model for registering a new user +/// using SRP (Secure Remote Password) protocol. +/// +public class RegisterModelOld +{ + /// + /// Initializes a new instance of the class. + /// + /// Email. + /// Salt. + /// Verifier. + public RegisterModelOld(string email, string salt, string verifier) + { + Email = email; + Salt = salt; + Verifier = verifier; + } + + /// + /// Gets or sets the email. + /// + public string Email { get; set; } + + /// + /// Gets or sets the salt. + /// + public string Salt { get; set; } + + /// + /// Gets or sets the verifier. + /// + public string Verifier { get; set; } +} diff --git a/src/AliasVault.Shared/Models/TokenModel.cs b/src/AliasVault.Shared/Models/WebApi/Auth/TokenModel.cs similarity index 94% rename from src/AliasVault.Shared/Models/TokenModel.cs rename to src/AliasVault.Shared/Models/WebApi/Auth/TokenModel.cs index 6777dc850..2946c69c2 100644 --- a/src/AliasVault.Shared/Models/TokenModel.cs +++ b/src/AliasVault.Shared/Models/WebApi/Auth/TokenModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Shared.Models; +namespace AliasVault.Shared.Models.WebApi.Auth; using System.Text.Json.Serialization; diff --git a/src/AliasVault.Shared/Models/UnlockModel.cs b/src/AliasVault.Shared/Models/WebApi/Auth/UnlockModel.cs similarity index 92% rename from src/AliasVault.Shared/Models/UnlockModel.cs rename to src/AliasVault.Shared/Models/WebApi/Auth/UnlockModel.cs index 80fa63a8a..4496d999f 100644 --- a/src/AliasVault.Shared/Models/UnlockModel.cs +++ b/src/AliasVault.Shared/Models/WebApi/Auth/UnlockModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Shared.Models; +namespace AliasVault.Shared.Models.WebApi.Auth; using System.ComponentModel.DataAnnotations; diff --git a/src/AliasVault.Shared/Models/WebApi/Auth/ValidateLoginRequestRecoveryCode.cs b/src/AliasVault.Shared/Models/WebApi/Auth/ValidateLoginRequestRecoveryCode.cs new file mode 100644 index 000000000..0c95b26ef --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/Auth/ValidateLoginRequestRecoveryCode.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.Auth +{ + /// + /// Represents a request to validate a login with added 2-factor authentication code. + /// + public class ValidateLoginRequestRecoveryCode : ValidateLoginRequest + { + /// + /// Initializes a new instance of the class. + /// + /// Username. + /// Client public ephemeral. + /// Client session proof. + /// 2-factor recovery code. + public ValidateLoginRequestRecoveryCode(string username, string clientPublicEphemeral, string clientSessionProof, string recoveryCode) + : base(username, clientPublicEphemeral, clientSessionProof) + { + RecoveryCode = recoveryCode; + } + + /// + /// Gets the 2-factor authentication recovery code. + /// + public string RecoveryCode { get; } + } +} diff --git a/src/AliasVault.Shared/Models/FaviconExtractModel.cs b/src/AliasVault.Shared/Models/WebApi/Favicon/FaviconExtractModel.cs similarity index 92% rename from src/AliasVault.Shared/Models/FaviconExtractModel.cs rename to src/AliasVault.Shared/Models/WebApi/Favicon/FaviconExtractModel.cs index f1fe2d097..d872fe1aa 100644 --- a/src/AliasVault.Shared/Models/FaviconExtractModel.cs +++ b/src/AliasVault.Shared/Models/WebApi/Favicon/FaviconExtractModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Shared.Models; +namespace AliasVault.Shared.Models.WebApi.Favicon; /// /// FaviconExtractModel model. diff --git a/src/Utilities/CsvImportExport/CredentialCsvService.cs b/src/Utilities/CsvImportExport/CredentialCsvService.cs index 7a25561ed..1f5728873 100644 --- a/src/Utilities/CsvImportExport/CredentialCsvService.cs +++ b/src/Utilities/CsvImportExport/CredentialCsvService.cs @@ -19,7 +19,11 @@ public static class CredentialCsvService { private const string CsvVersionIdentifier = "1.0.0"; - /// The file path of the CSV file. + /// + /// Export list of credentials to CSV file. + /// + /// List of credentials to export. + /// CSV file as byte array. public static byte[] ExportCredentialsToCsv(List credentials) { var records = new List(); @@ -30,7 +34,7 @@ public static class CredentialCsvService { Version = CsvVersionIdentifier, Id = credential.Id, - Username = credential.Username, + Username = credential.Username ?? string.Empty, Notes = credential.Notes ?? string.Empty, CreatedAt = credential.CreatedAt, UpdatedAt = credential.UpdatedAt,