Add password step and working account creation (#306)

This commit is contained in:
Leendert de Borst
2024-10-15 22:21:16 +02:00
parent 5d7433674c
commit 9dca684e4c
4 changed files with 329 additions and 8 deletions

View File

@@ -0,0 +1,209 @@
@using System.Timers
@using System.Net.Http.Json
@implements IDisposable
<div class="w-full max-w-md mx-auto">
@if (isLoading)
{
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-primary-500"></div>
</div>
}
<div class="@(isLoading ? "opacity-0" : "opacity-100 transition-opacity duration-300") w-full">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<img class="h-10 w-10 rounded-full" src="/img/avatar.webp" alt="AliasVault Assistant">
</div>
<div class="ml-3 bg-blue-100 dark:bg-blue-900 rounded-lg p-3">
<p class="text-sm text-gray-900 dark:text-white">
Great! Now, let's set up your master password for AliasVault.
</p>
<p class="text-sm text-gray-900 dark:text-white mt-3">
Please enter a strong master password for your account. Your username is: <strong>@Username</strong>
</p>
<p class="text-sm text-gray-900 dark:text-white mt-3 font-semibold">
Important: This master password will be used to encrypt your vault. It should be a long, complex string that you can remember. If you forget this password, your data will be permanently inaccessible.
</p>
<ul class="text-sm text-gray-900 dark:text-white mt-3 list-disc list-inside">
<li>Your master password never leaves your device</li>
<li>The server has no access to your unencrypted data</li>
<li>Even the server admin cannot restore your access if you forget this password</li>
</ul>
</div>
</div>
</div>
<div class="space-y-4">
<div>
<div class="">
<EditFormRow Id="password" Label="Master Password" @bind-Value="Password" Type="password" Placeholder="Enter your master password" OnFocus="@OnPasswordInputFocus"/>
</div>
<div class="mt-4">
<EditFormRow Id="confirmPassword" Label="Confirm Master Password" @bind-Value="ConfirmPassword" Type="password" Placeholder="Confirm your master password" OnFocus="@OnPasswordInputFocus" />
</div>
@if (isValidating)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">Validating password...</div>
}
else if (isValid)
{
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="mt-2 text-sm text-yellow-600 dark:text-yellow-400">@errorMessage</div>
}
else
{
<div class="mt-2 text-sm text-green-600 dark:text-green-400">Password is valid and strong!</div>
}
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="mt-2 text-sm text-red-600 dark:text-red-400">@errorMessage</div>
}
</div>
</div>
</div>
</div>
@code {
private string _password = string.Empty;
private string _confirmPassword = string.Empty;
private bool isValid = false;
private bool isValidating = false;
private string errorMessage = string.Empty;
private Timer? debounceTimer;
[Inject]
private HttpClient Http { get; set; } = null!;
[Parameter]
public string Username { get; set; } = string.Empty;
public string Password
{
get => _password;
set
{
if (_password != value)
{
_password = value;
ValidatePassword();
}
}
}
public string ConfirmPassword
{
get => _confirmPassword;
set
{
if (_confirmPassword != value)
{
_confirmPassword = value;
ValidatePassword();
}
}
}
[Parameter]
public EventCallback<string> OnPasswordChange { get; set; }
private bool isLoading = true;
private Timer? loadingTimer;
protected override void OnInitialized()
{
loadingTimer = new Timer(300);
loadingTimer.Elapsed += (sender, e) => FinishLoading();
loadingTimer.AutoReset = false;
loadingTimer.Start();
debounceTimer = new Timer(300);
debounceTimer.Elapsed += async (sender, e) => await ValidatePasswordDebounced();
debounceTimer.AutoReset = false;
}
private void FinishLoading()
{
isLoading = false;
InvokeAsync(StateHasChanged);
}
private void ValidatePassword()
{
isValidating = true;
isValid = false;
errorMessage = string.Empty;
StateHasChanged();
debounceTimer?.Stop();
debounceTimer?.Start();
}
private async Task ValidatePasswordDebounced()
{
await InvokeAsync(async () =>
{
if (string.IsNullOrWhiteSpace(Password) || string.IsNullOrWhiteSpace(ConfirmPassword))
{
isValidating = false;
isValid = false;
errorMessage = "Both password fields are required.";
await OnPasswordChange.InvokeAsync(string.Empty);
StateHasChanged();
return;
}
if (Password != ConfirmPassword)
{
isValidating = false;
isValid = false;
errorMessage = "Passwords do not match.";
await OnPasswordChange.InvokeAsync(string.Empty);
StateHasChanged();
return;
}
if (Password.Length < 10)
{
isValidating = false;
isValid = false;
errorMessage = "Master password must be at least 10 characters long.";
await OnPasswordChange.InvokeAsync(string.Empty);
StateHasChanged();
return;
}
// If password is valid
isValid = true;
errorMessage = string.Empty;
// Show warning for passwords between 10 and 13 characters
if (Password.Length < 14)
{
errorMessage = "Password is acceptable, but can be improved when longer.";
}
await OnPasswordChange.InvokeAsync(Password);
isValidating = false;
StateHasChanged();
});
}
private void OnPasswordInputFocus(FocusEventArgs args)
{
// Reset validation state when the input is focused
isValid = false;
isValidating = false;
errorMessage = string.Empty;
StateHasChanged();
}
public void Dispose()
{
loadingTimer?.Dispose();
debounceTimer?.Dispose();
}
}

View File

@@ -3,6 +3,12 @@
@inherits AliasVault.Client.Auth.Pages.Base.LoginBase
@layout Auth.Layout.EmptyLayout
@attribute [AllowAnonymous]
@inject IConfiguration Configuration
@using System.Text.Json
@using AliasVault.Shared.Models.WebApi.Auth
@using AliasVault.Client.Utilities
@using AliasVault.Cryptography.Client
@using SecureRemotePassword
<div class="flex min-h-screen bg-gray-100 dark:bg-gray-900">
<div class="w-full max-w-md mx-auto flex flex-col @(currentStep == SetupStep.Welcome ? "justify-center" : "pt-8")">
@@ -25,11 +31,11 @@
<UsernameStep
OnUsernameChange="@((string username) => { setupData.Username = username; StateHasChanged(); })" />
break;
/*case SetupStep.Password:
case SetupStep.Password:
<PasswordStep
Password="@setupData.Password"
Username="@setupData.Username"
OnPasswordChange="@((string pwd) => { setupData.Password = pwd; StateHasChanged(); })" />
break; */
break;
}
</div>
<div class="fixed bottom-0 left-0 right-0 p-8 bg-gray-100 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
@@ -43,7 +49,14 @@
}
</div>
<div class="lg:flex-shrink-0">
@if (currentStep != SetupStep.Password)
@if (currentStep == SetupStep.Password && !string.IsNullOrWhiteSpace(setupData.Password))
{
<button @onclick="CompleteSetup"
class="w-full lg:w-auto py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition duration-300 ease-in-out">
Create Account
</button>
}
else
{
<button @onclick="GoNext"
class="w-full lg:w-auto py-3 px-4 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition duration-300 ease-in-out @(isNextEnabled ? "" : "opacity-50 cursor-not-allowed")"
@@ -54,10 +67,18 @@
</div>
</div>
</div>
<button @onclick="CancelSetup" class="absolute top-4 right-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<FullScreenLoadingIndicator @ref="LoadingIndicator"/>
@code {
private FullScreenLoadingIndicator LoadingIndicator = new();
private SetupStep currentStep = SetupStep.Welcome;
private SetupData setupData = new();
private bool isNextEnabled => currentStep switch
@@ -101,10 +122,73 @@
};
}
private void CompleteSetup()
private async Task CompleteSetup()
{
LoadingIndicator.Show();
StateHasChanged();
try
{
var client = new SrpClient();
var salt = client.GenerateSalt();
string encryptionType = Defaults.EncryptionType;
string encryptionSettings = Defaults.EncryptionSettings;
if (Configuration["CryptographyOverrideType"] is not null && Configuration["CryptographyOverrideSettings"] is not null)
{
encryptionType = Configuration["CryptographyOverrideType"]!;
encryptionSettings = Configuration["CryptographyOverrideSettings"]!;
}
var passwordHash = await Encryption.DeriveKeyFromPasswordAsync(setupData.Password, salt, encryptionType, encryptionSettings);
var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty);
var srpSignup = Srp.PasswordChangeAsync(client, salt, setupData.Username, passwordHashString);
var registerRequest = new RegisterRequest(srpSignup.Username, srpSignup.Salt, srpSignup.Verifier, encryptionType, encryptionSettings);
var result = await Http.PostAsJsonAsync("api/v1/Auth/register", registerRequest);
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
foreach (var error in ApiResponseUtility.ParseErrorResponse(responseContent))
{
GlobalNotificationService.AddErrorMessage(error);
}
StateHasChanged();
return;
}
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenObject != null)
{
await AuthService.StoreEncryptionKeyAsync(passwordHash);
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.AddSuccessMessage("Account created successfully!");
NavigationManager.NavigateTo("/");
}
else
{
GlobalNotificationService.AddErrorMessage("An error occurred during registration.");
StateHasChanged();
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"An error occurred: {ex.Message}");
}
finally
{
LoadingIndicator.Hide();
StateHasChanged();
}
}
private void CancelSetup()
{
// TODO: Implement the logic to save user information and create the account
// For example: await UserService.CreateUser(setupData.UserInfo, setupData.Password);
NavigationManager.NavigateTo("/");
}

View File

@@ -6,7 +6,7 @@
}
else
{
<input type="text" id="@Id" autocomplete="off" @onfocus="OnFocusEvent" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged" placeholder="@Placeholder">
<input type="@Type" id="@Id" autocomplete="off" @onfocus="OnFocusEvent" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged" placeholder="@Placeholder">
}
</div>

View File

@@ -678,6 +678,14 @@ video {
top: 50%;
}
.right-4 {
right: 1rem;
}
.top-4 {
top: 1rem;
}
.z-10 {
z-index: 10;
}
@@ -1923,6 +1931,11 @@ video {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.text-yellow-600 {
--tw-text-opacity: 1;
color: rgb(202 138 4 / var(--tw-text-opacity));
}
.opacity-25 {
opacity: 0.25;
}
@@ -2184,6 +2197,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -2517,6 +2535,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:text-yellow-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(250 204 21 / 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));
@@ -2601,6 +2624,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));