Add secure cross-platform auto-login with credential storage
Introduces ISecureCredentialService abstraction and platform-specific implementations for secure credential storage (WebAssembly, MAUI, server). Refactors LoggedInModel to support async auto-login, login, and logout using stored credentials. Updates DI and UI logic to enable seamless auto-login and logout across all platforms. Cleans up redundant navigation checks and improves maintainability.
This commit is contained in:
parent
4ef318973f
commit
0d9ced990a
|
|
@ -1,4 +1,4 @@
|
||||||
using AyCode.Core.Consts;
|
using AyCode.Core.Consts;
|
||||||
using AyCode.Core.Loggers;
|
using AyCode.Core.Loggers;
|
||||||
|
|
||||||
namespace FruitBank.Common;
|
namespace FruitBank.Common;
|
||||||
|
|
@ -7,13 +7,13 @@ public static class FruitBankConstClient
|
||||||
{
|
{
|
||||||
public static string DefaultLocale = "en-US";
|
public static string DefaultLocale = "en-US";
|
||||||
|
|
||||||
public static string BaseUrl = "https://localhost:59579"; //FrutiBank nop
|
public static string BaseUrl = "http://localhost:59579"; //FrutiBank nop
|
||||||
//public static string BaseUrl = "https://shop.fruitbank.hu"; //FrutiBank nop
|
//public static string BaseUrl = "https://shop.fruitbank.hu"; //FrutiBank nop
|
||||||
#if RELEASE
|
#if RELEASE
|
||||||
// public static string BaseUrl = "https://shop.fruitbank.hu"; //FrutiBank nop
|
// public static string BaseUrl = "https://shop.fruitbank.hu"; //FrutiBank nop
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
//public static string BaseUrl = "http://localhost:5000"; //FrutiBank nop
|
//public static string BaseUrl = "http://localhost:59579"; //FrutiBank nop
|
||||||
//public static string BaseUrl = "http://10.0.2.2:59579"; //FrutiBank (android) nop
|
//public static string BaseUrl = "http://10.0.2.2:59579"; //FrutiBank (android) nop
|
||||||
//public static string BaseUrl = "https://localhost:7144"; //HybridApp
|
//public static string BaseUrl = "https://localhost:7144"; //HybridApp
|
||||||
|
|
||||||
|
|
@ -111,3 +111,5 @@ public static string SystemEmailAddress = "test@touriam.com";
|
||||||
public static LogLevel DefaultLogLevelClient = LogLevel.Detail;
|
public static LogLevel DefaultLogLevelClient = LogLevel.Detail;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AyCode.Core;
|
using AyCode.Core;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
using Mango.Nop.Core.Dtos;
|
using Mango.Nop.Core.Dtos;
|
||||||
using Mango.Nop.Core.Models;
|
using Mango.Nop.Core.Models;
|
||||||
using Nop.Core.Domain.Customers;
|
using Nop.Core.Domain.Customers;
|
||||||
|
|
@ -7,6 +8,8 @@ namespace FruitBank.Common.Models;
|
||||||
|
|
||||||
public class LoggedInModel
|
public class LoggedInModel
|
||||||
{
|
{
|
||||||
|
private readonly ISecureCredentialService? _secureCredentialService;
|
||||||
|
|
||||||
public bool IsLoggedIn => CustomerDto != null;
|
public bool IsLoggedIn => CustomerDto != null;
|
||||||
public bool IsRevisor => IsLoggedIn && CustomerRoles.Any(x => x.SystemName.ToLowerInvariant() == "measuringrevisor");
|
public bool IsRevisor => IsLoggedIn && CustomerRoles.Any(x => x.SystemName.ToLowerInvariant() == "measuringrevisor");
|
||||||
public bool IsAdministrator => IsLoggedIn && CustomerRoles.Any(x => x.SystemName.ToLowerInvariant() == "administrators");
|
public bool IsAdministrator => IsLoggedIn && CustomerRoles.Any(x => x.SystemName.ToLowerInvariant() == "administrators");
|
||||||
|
|
@ -16,38 +19,123 @@ public class LoggedInModel
|
||||||
|
|
||||||
public CustomerDto? CustomerDto { get; private set; }
|
public CustomerDto? CustomerDto { get; private set; }
|
||||||
public List<CustomerRole> CustomerRoles { get; private set; } = [];
|
public List<CustomerRole> CustomerRoles { get; private set; } = [];
|
||||||
|
|
||||||
public List<CustomerDto> MeasuringUsers { get; set; } = [];
|
public List<CustomerDto> MeasuringUsers { get; set; } = [];
|
||||||
|
|
||||||
|
public Func<string, string, Task<MgLoginModelResponse?>>? LoginFunc { get; set; }
|
||||||
|
public Func<int, Task<List<CustomerRole>?>>? GetRolesFunc { get; set; }
|
||||||
|
|
||||||
public LoggedInModel()
|
public LoggedInModel()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoggedInModel(CustomerDto? customerDto)
|
public LoggedInModel(ISecureCredentialService secureCredentialService)
|
||||||
{
|
{
|
||||||
InitLoggedInCustomer(customerDto);
|
_secureCredentialService = secureCredentialService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoggedInModel(MgLoginModelResponse loginModelResponse) : this(loginModelResponse.CustomerDto)
|
/// <summary>
|
||||||
|
/// Tries to login - first checks if already logged in, then checks for stored credentials.
|
||||||
|
/// Call this on app startup. Only attempts auto-login once per session.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TryAutoLoginAsync()
|
||||||
{
|
{
|
||||||
|
if (IsLoggedIn) return IsLoggedIn;
|
||||||
|
|
||||||
|
var credentials = await GetStoredCredentialsAsync();
|
||||||
|
if (credentials == null) return IsLoggedIn;
|
||||||
|
|
||||||
|
await LoginAsync(credentials.Email, credentials.Password, true);
|
||||||
|
|
||||||
|
return IsLoggedIn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InitLoggedInCustomer(CustomerDto? customerDto)
|
/// <summary>
|
||||||
|
/// Performs manual login with the provided credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> LoginAsync(string email, string password, bool saveCredentials = true)
|
||||||
{
|
{
|
||||||
LogOut();
|
if (IsLoggedIn || LoginFunc == null) return IsLoggedIn;
|
||||||
|
|
||||||
|
var loginResponse = await LoginFunc(email, password);
|
||||||
|
|
||||||
|
if (loginResponse is { IsSuccesLogin: true })
|
||||||
|
{
|
||||||
|
await SetupLoggedInUser(loginResponse.CustomerDto!);
|
||||||
|
|
||||||
|
if (saveCredentials)
|
||||||
|
{
|
||||||
|
await SaveCredentialsAsync(email, password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs out the user and clears stored credentials.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LogOutAsync()
|
||||||
|
{
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
ClearCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCustomer(CustomerDto? customerDto)
|
||||||
|
{
|
||||||
|
ClearCustomer();
|
||||||
if (customerDto != null) CustomerDto = customerDto;
|
if (customerDto != null) CustomerDto = customerDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InitCustomerRoles(List<CustomerRole> customerRoles)
|
public void SetCustomerRoles(List<CustomerRole> customerRoles)
|
||||||
{
|
{
|
||||||
CustomerRoles.Clear();
|
CustomerRoles.Clear();
|
||||||
CustomerRoles.AddRange(customerRoles);
|
CustomerRoles.AddRange(customerRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LogOut()
|
public void ClearCustomer()
|
||||||
{
|
{
|
||||||
CustomerDto = null;
|
CustomerDto = null;
|
||||||
CustomerRoles.Clear();
|
CustomerRoles.Clear();
|
||||||
//MeasuringUsers.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void LogOut() => ClearCustomer();
|
||||||
|
|
||||||
|
#region Credential Management
|
||||||
|
|
||||||
|
public async Task<StoredCredentials?> GetStoredCredentialsAsync()
|
||||||
|
{
|
||||||
|
if (_secureCredentialService == null) return null;
|
||||||
|
return await _secureCredentialService.GetCredentialsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveCredentialsAsync(string email, string password)
|
||||||
|
{
|
||||||
|
if (_secureCredentialService == null) return;
|
||||||
|
await _secureCredentialService.SaveCredentialsAsync(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearCredentialsAsync()
|
||||||
|
{
|
||||||
|
if (_secureCredentialService == null) return;
|
||||||
|
await _secureCredentialService.ClearCredentialsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Private Methods
|
||||||
|
|
||||||
|
private async Task SetupLoggedInUser(CustomerDto customerDto)
|
||||||
|
{
|
||||||
|
SetCustomer(customerDto);
|
||||||
|
|
||||||
|
if (GetRolesFunc != null)
|
||||||
|
{
|
||||||
|
var customerRoles = await GetRolesFunc(customerDto.Id);
|
||||||
|
if (customerRoles != null)
|
||||||
|
{
|
||||||
|
SetCustomerRoles(customerRoles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace FruitBank.Common.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for securely storing and retrieving user credentials.
|
||||||
|
/// Platform-specific implementations handle the actual secure storage.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISecureCredentialService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the user credentials securely with a 2-day expiration from now.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveCredentialsAsync(string email, string password);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the stored credentials if they exist and haven't expired.
|
||||||
|
/// Returns null if no credentials are stored or if they have expired.
|
||||||
|
/// </summary>
|
||||||
|
Task<StoredCredentials?> GetCredentialsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all stored credentials (used on logout).
|
||||||
|
/// </summary>
|
||||||
|
Task ClearCredentialsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents stored user credentials.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record StoredCredentials(string Email, string Password);
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@using FruitBank.Common.Models
|
@using FruitBank.Common.Models
|
||||||
|
@using FruitBankHybrid.Shared.Pages
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -29,7 +30,10 @@
|
||||||
ShowCloseButton="true">
|
ShowCloseButton="true">
|
||||||
</DxToastProvider>
|
</DxToastProvider>
|
||||||
<CascadingValue Value="RefreshMainLayoutEventCallback">
|
<CascadingValue Value="RefreshMainLayoutEventCallback">
|
||||||
|
@if (LoggedInModel.IsLoggedIn || IsOnLoginPage)
|
||||||
|
{
|
||||||
@Body
|
@Body
|
||||||
|
}
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ using FruitBankHybrid.Shared.Pages;
|
||||||
using FruitBankHybrid.Shared.Services.Loggers;
|
using FruitBankHybrid.Shared.Services.Loggers;
|
||||||
using FruitBankHybrid.Shared.Services.SignalRs;
|
using FruitBankHybrid.Shared.Services.SignalRs;
|
||||||
using Mango.Nop.Core.Loggers;
|
using Mango.Nop.Core.Loggers;
|
||||||
using MessagePack.Resolvers;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace FruitBankHybrid.Shared.Layout;
|
namespace FruitBankHybrid.Shared.Layout;
|
||||||
|
|
@ -29,6 +28,8 @@ public partial class MainLayout : LayoutComponentBase
|
||||||
private NavMenu _navMenu = null!;
|
private NavMenu _navMenu = null!;
|
||||||
private LoggerClient _logger = null!;
|
private LoggerClient _logger = null!;
|
||||||
|
|
||||||
|
private bool IsOnLoginPage => NavManager.Uri.Equals(NavManager.ToAbsoluteUri("/Login").ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Toast fields
|
// Toast fields
|
||||||
private DxToast orderNotificationToast;
|
private DxToast orderNotificationToast;
|
||||||
private string toastTitle = "Értesítő!";
|
private string toastTitle = "Értesítő!";
|
||||||
|
|
@ -39,28 +40,31 @@ public partial class MainLayout : LayoutComponentBase
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_logger = new LoggerClient<MainLayout>(LogWriters.ToArray());
|
_logger = new LoggerClient<MainLayout>(LogWriters.ToArray());
|
||||||
_logger.Info("OnInitializedAsync");
|
_logger.Info("OnInitialized");
|
||||||
|
|
||||||
|
// Setup login delegates
|
||||||
|
LoggedInModel.LoginFunc = FruitBankSignalRClient.LoginMeasuringUser;
|
||||||
|
LoggedInModel.GetRolesFunc = FruitBankSignalRClient.GetCustomerRolesByCustomerId;
|
||||||
|
|
||||||
FruitBankSignalRClient.OnMessageReceived += SignalRClientOnMessageReceived;
|
FruitBankSignalRClient.OnMessageReceived += SignalRClientOnMessageReceived;
|
||||||
|
}
|
||||||
|
|
||||||
var loginUri = NavManager.ToAbsoluteUri("/Login").ToString();
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
if (!LoggedInModel.IsLoggedIn && NavManager.Uri != loginUri)
|
|
||||||
{
|
{
|
||||||
NavManager.NavigateTo("/Login", forceLoad: true);
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
await LoggedInModel.TryAutoLoginAsync();
|
||||||
|
|
||||||
|
if (!LoggedInModel.IsLoggedIn && !IsOnLoginPage)
|
||||||
|
{
|
||||||
|
NavManager.NavigateTo("/Login");
|
||||||
|
}
|
||||||
|
else if (LoggedInModel.IsLoggedIn)
|
||||||
|
{
|
||||||
|
StateHasChanged(); // Refresh UI after successful auto-login
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//protected override void OnAfterRender(bool firstRender)
|
|
||||||
//{
|
|
||||||
// if (!firstRender) return;
|
|
||||||
|
|
||||||
// var loginUri = NavManager.ToAbsoluteUri("/Login").ToString();
|
|
||||||
// if (!LoggedInModel.IsLoggedIn && NavManager.Uri != loginUri)
|
|
||||||
// {
|
|
||||||
// NavManager.NavigateTo("/Login", forceLoad: true);
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
private async Task SignalRClientOnMessageReceived(int messageTag, string? jsonMessage)
|
private async Task SignalRClientOnMessageReceived(int messageTag, string? jsonMessage)
|
||||||
{
|
{
|
||||||
if (messageTag != SignalRTags.NotificationReceived || !LoggedInModel.IsLoggedIn) return;
|
if (messageTag != SignalRTags.NotificationReceived || !LoggedInModel.IsLoggedIn) return;
|
||||||
|
|
@ -97,9 +101,9 @@ public partial class MainLayout : LayoutComponentBase
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLogoutClick()
|
private async void OnLogoutClick()
|
||||||
{
|
{
|
||||||
LoggedInModel.LogOut();
|
await LoggedInModel.LogOutAsync();
|
||||||
RefreshMainLayout();
|
RefreshMainLayout();
|
||||||
NavManager.NavigateTo("/Login");
|
NavManager.NavigateTo("/Login");
|
||||||
}
|
}
|
||||||
|
|
@ -109,4 +113,9 @@ public partial class MainLayout : LayoutComponentBase
|
||||||
_navMenu.RefreshNavMenu();
|
_navMenu.RefreshNavMenu();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
FruitBankSignalRClient.OnMessageReceived -= SignalRClientOnMessageReceived;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,20 +10,4 @@ public partial class Home : ComponentBase
|
||||||
|
|
||||||
private string Factor => FormFactor.GetFormFactor();
|
private string Factor => FormFactor.GetFormFactor();
|
||||||
private string Platform => FormFactor.GetPlatform();
|
private string Platform => FormFactor.GetPlatform();
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
if (!LoggedInModel.IsLoggedIn)
|
|
||||||
{
|
|
||||||
NavManager.NavigateTo("/Login", forceLoad: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//protected override void OnAfterRender(bool firstRender)
|
|
||||||
//{
|
|
||||||
// if (firstRender && !LoggedInModel.IsLoggedIn)
|
|
||||||
// {
|
|
||||||
// NavManager.NavigateTo("/Login", forceLoad: true);
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|
@ -20,11 +20,10 @@ public partial class Login : ComponentBase
|
||||||
[Inject] public required NavigationManager NavManager{ get; set; }
|
[Inject] public required NavigationManager NavManager{ get; set; }
|
||||||
|
|
||||||
private ILogger _logger = null!;
|
private ILogger _logger = null!;
|
||||||
//private List<CustomerDto> Users { get; set; }
|
|
||||||
private CustomerDto? SelectedUser { get; set; }
|
private CustomerDto? SelectedUser { get; set; }
|
||||||
private string PasswordValue { get; set; } = string.Empty;
|
private string PasswordValue { get; set; } = string.Empty;
|
||||||
|
|
||||||
private MgLoginModelResponse? LoginModelResponse { get; set; }
|
private MgLoginModelResponse? LoginModelResponse { get; set; }
|
||||||
|
private string _rolesText = string.Empty;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
public EventCallback UpdateStyle { get; set; }
|
public EventCallback UpdateStyle { get; set; }
|
||||||
|
|
@ -35,6 +34,18 @@ public partial class Login : ComponentBase
|
||||||
_logger.Info("OnInitializedAsync");
|
_logger.Info("OnInitializedAsync");
|
||||||
|
|
||||||
if (!LoggedInModel.IsLoggedIn)
|
if (!LoggedInModel.IsLoggedIn)
|
||||||
|
{
|
||||||
|
await LoadMeasuringUsersAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnInitializedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMeasuringUsersAsync()
|
||||||
{
|
{
|
||||||
using (await ObjectLock.GetSemaphore<CustomerDto>().UseWaitAsync())
|
using (await ObjectLock.GetSemaphore<CustomerDto>().UseWaitAsync())
|
||||||
{
|
{
|
||||||
|
|
@ -45,46 +56,42 @@ public partial class Login : ComponentBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else _rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
|
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string _rolesText = string.Empty;
|
|
||||||
private async Task OnLoginClick()
|
private async Task OnLoginClick()
|
||||||
{
|
{
|
||||||
if (LoggedInModel.IsLoggedIn) return;
|
if (LoggedInModel.IsLoggedIn) return;
|
||||||
|
|
||||||
_rolesText = string.Empty;
|
_rolesText = string.Empty;
|
||||||
|
|
||||||
|
if (!ValidateLoginInput()) return;
|
||||||
|
|
||||||
|
// Use the simplified LoginAsync from LoggedInModel
|
||||||
|
if(await LoggedInModel.LoginAsync(SelectedUser!.Email, PasswordValue))
|
||||||
|
{
|
||||||
|
_rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateStyle.InvokeAsync();
|
||||||
|
|
||||||
|
if (LoggedInModel.IsLoggedIn)
|
||||||
|
{
|
||||||
|
NavManager.NavigateTo("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateLoginInput()
|
||||||
|
{
|
||||||
if (SelectedUser == null || PasswordValue.IsNullOrWhiteSpace())
|
if (SelectedUser == null || PasswordValue.IsNullOrWhiteSpace())
|
||||||
{
|
{
|
||||||
LoginModelResponse = new MgLoginModelResponse
|
LoginModelResponse = new MgLoginModelResponse
|
||||||
{
|
{
|
||||||
ErrorMessage = "Válasszon felhsználót és adja meg a jelszavát!"
|
ErrorMessage = "Válasszon felhsználót és adja meg a jelszavát!"
|
||||||
};
|
};
|
||||||
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
LoginModelResponse = await FruitBankSignalRClient.LoginMeasuringUser(SelectedUser.Email, PasswordValue);
|
|
||||||
|
|
||||||
if (LoginModelResponse is { IsSuccesLogin: true })
|
|
||||||
{
|
|
||||||
LoggedInModel.InitLoggedInCustomer(LoginModelResponse.CustomerDto);
|
|
||||||
|
|
||||||
var customerRoles = await FruitBankSignalRClient.GetCustomerRolesByCustomerId(LoginModelResponse.CustomerDto!.Id);
|
|
||||||
if (customerRoles != null)
|
|
||||||
{
|
|
||||||
LoggedInModel.InitCustomerRoles(customerRoles);
|
|
||||||
|
|
||||||
_rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateStyle.InvokeAsync();
|
|
||||||
if (LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/");
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task OnPasswordKeyDown(KeyboardEventArgs e)
|
protected async Task OnPasswordKeyDown(KeyboardEventArgs e)
|
||||||
|
|
@ -94,7 +101,6 @@ public partial class Login : ComponentBase
|
||||||
|
|
||||||
private string GetImageFileName(CustomerDto employee)
|
private string GetImageFileName(CustomerDto employee)
|
||||||
{
|
{
|
||||||
//return StaticAssetUtils.GetEmployeeImagePath(employee.Id);
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,8 +39,6 @@ namespace FruitBankHybrid.Shared.Pages
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login");
|
|
||||||
|
|
||||||
LoadingPanelVisible = true;
|
LoadingPanelVisible = true;
|
||||||
|
|
||||||
_logger = new LoggerClient<MeasuringIn>(LogWriters.ToArray());
|
_logger = new LoggerClient<MeasuringIn>(LogWriters.ToArray());
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ namespace FruitBankHybrid.Shared.Pages
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login");
|
|
||||||
|
|
||||||
LoadingPanelVisible = true;
|
LoadingPanelVisible = true;
|
||||||
_logger = new LoggerClient<MeasuringOut>(LogWriters.ToArray());
|
_logger = new LoggerClient<MeasuringOut>(LogWriters.ToArray());
|
||||||
_logger.Info("OnInitializedAsync");
|
_logger.Info("OnInitializedAsync");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
// This file is kept for backward compatibility.
|
||||||
|
// The interface has been moved to FruitBank.Common.Services.ISecureCredentialService
|
||||||
|
global using FruitBank.Common.Services;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AyCode.Core.Loggers;
|
using AyCode.Core.Loggers;
|
||||||
using FruitBank.Common.Loggers;
|
using FruitBank.Common.Loggers;
|
||||||
using FruitBank.Common.Models;
|
using FruitBank.Common.Models;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
using FruitBankHybrid.Shared.Databases;
|
using FruitBankHybrid.Shared.Databases;
|
||||||
using FruitBankHybrid.Shared.Services;
|
using FruitBankHybrid.Shared.Services;
|
||||||
using FruitBankHybrid.Shared.Services.SignalRs;
|
using FruitBankHybrid.Shared.Services.SignalRs;
|
||||||
|
|
@ -15,12 +16,14 @@ builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpres
|
||||||
|
|
||||||
// Add device-specific services used by the FruitBankHybrid.Shared project
|
// Add device-specific services used by the FruitBankHybrid.Shared project
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
|
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
|
||||||
|
|
||||||
//#if DEBUG
|
//#if DEBUG
|
||||||
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
||||||
//#endif
|
//#endif
|
||||||
|
|
||||||
builder.Services.AddSingleton<LoggedInModel>();
|
builder.Services.AddSingleton<LoggedInModel>(sp =>
|
||||||
|
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
|
||||||
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
||||||
builder.Services.AddSingleton<DatabaseClient>();
|
builder.Services.AddSingleton<DatabaseClient>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace FruitBankHybrid.Web.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebAssembly implementation of ISecureCredentialService using obfuscated localStorage.
|
||||||
|
/// Note: WebAssembly has limited cryptography support, so we use Base64 + XOR obfuscation.
|
||||||
|
/// This prevents casual inspection but is not cryptographically secure.
|
||||||
|
/// For true security, consider server-side token storage or authentication cookies.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WebSecureCredentialService : ISecureCredentialService
|
||||||
|
{
|
||||||
|
private const string CredentialsKey = "FruitBank_UserCredentials";
|
||||||
|
private static readonly TimeSpan ExpirationDuration = TimeSpan.FromDays(2);
|
||||||
|
private static readonly byte[] ObfuscationKey = "FruitBank_Secure_v1_2025"u8.ToArray();
|
||||||
|
|
||||||
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
|
||||||
|
public WebSecureCredentialService(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
_jsRuntime = jsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveCredentialsAsync(string email, string password)
|
||||||
|
{
|
||||||
|
var data = new SecureCredentialData
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
Password = password,
|
||||||
|
ExpiresAtUtc = DateTime.UtcNow.Add(ExpirationDuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(data);
|
||||||
|
var obfuscated = Obfuscate(json);
|
||||||
|
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", CredentialsKey, obfuscated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoredCredentials?> GetCredentialsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var obfuscated = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", CredentialsKey);
|
||||||
|
if (string.IsNullOrEmpty(obfuscated))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var json = Deobfuscate(obfuscated);
|
||||||
|
if (string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = JsonSerializer.Deserialize<SecureCredentialData>(json);
|
||||||
|
if (data == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (DateTime.UtcNow > data.ExpiresAtUtc)
|
||||||
|
{
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoredCredentials(data.Email, data.Password);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If any error occurs (corrupted data, etc.), clear and return null
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearCredentialsAsync()
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", CredentialsKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Obfuscate(string plainText)
|
||||||
|
{
|
||||||
|
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||||
|
var obfuscatedBytes = new byte[plainBytes.Length];
|
||||||
|
|
||||||
|
for (int i = 0; i < plainBytes.Length; i++)
|
||||||
|
{
|
||||||
|
obfuscatedBytes[i] = (byte)(plainBytes[i] ^ ObfuscationKey[i % ObfuscationKey.Length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToBase64String(obfuscatedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Deobfuscate(string obfuscated)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var obfuscatedBytes = Convert.FromBase64String(obfuscated);
|
||||||
|
var plainBytes = new byte[obfuscatedBytes.Length];
|
||||||
|
|
||||||
|
for (int i = 0; i < obfuscatedBytes.Length; i++)
|
||||||
|
{
|
||||||
|
plainBytes[i] = (byte)(obfuscatedBytes[i] ^ ObfuscationKey[i % ObfuscationKey.Length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plainBytes);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SecureCredentialData
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AyCode.Core.Loggers;
|
using AyCode.Core.Loggers;
|
||||||
using FruitBank.Common;
|
using FruitBank.Common;
|
||||||
using FruitBank.Common.Models;
|
using FruitBank.Common.Models;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
using FruitBank.Common.Server.Services.Loggers;
|
using FruitBank.Common.Server.Services.Loggers;
|
||||||
using FruitBank.Common.Server.Services.SignalRs;
|
using FruitBank.Common.Server.Services.SignalRs;
|
||||||
using FruitBankHybrid.Shared.Databases;
|
using FruitBankHybrid.Shared.Databases;
|
||||||
|
|
@ -17,9 +18,11 @@ builder.Services.AddMvc();
|
||||||
|
|
||||||
builder.Services.AddSignalR(options => options.MaximumReceiveMessageSize = 256 * 1024);
|
builder.Services.AddSignalR(options => options.MaximumReceiveMessageSize = 256 * 1024);
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
|
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
|
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
|
||||||
builder.Services.AddSingleton<LoggedInModel>();
|
builder.Services.AddSingleton<LoggedInModel>(sp =>
|
||||||
|
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
|
||||||
|
|
||||||
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
||||||
builder.Services.AddSingleton<DatabaseClient>();
|
builder.Services.AddSingleton<DatabaseClient>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using FruitBank.Common.Services;
|
||||||
|
|
||||||
|
namespace FruitBankHybrid.Web.Services;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side implementation of ISecureCredentialService.
|
||||||
|
/// This is a no-op implementation used during prerendering - actual credential storage
|
||||||
|
/// is handled by the client-side WebSecureCredentialService after WebAssembly loads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerSecureCredentialService : ISecureCredentialService
|
||||||
|
{
|
||||||
|
public Task SaveCredentialsAsync(string email, string password)
|
||||||
|
{
|
||||||
|
// No-op on server side - credentials are stored client-side
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<StoredCredentials?> GetCredentialsAsync()
|
||||||
|
{
|
||||||
|
// Always return null on server side - auto-login happens client-side after WASM loads
|
||||||
|
return Task.FromResult<StoredCredentials?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ClearCredentialsAsync()
|
||||||
|
{
|
||||||
|
// No-op on server side
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AyCode.Core.Loggers;
|
using AyCode.Core.Loggers;
|
||||||
using FruitBank.Common.Loggers;
|
using FruitBank.Common.Loggers;
|
||||||
using FruitBank.Common.Models;
|
using FruitBank.Common.Models;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
using FruitBankHybrid.Services;
|
using FruitBankHybrid.Services;
|
||||||
using FruitBankHybrid.Services.Loggers;
|
using FruitBankHybrid.Services.Loggers;
|
||||||
using FruitBankHybrid.Shared.Databases;
|
using FruitBankHybrid.Shared.Databases;
|
||||||
|
|
@ -28,12 +29,14 @@ namespace FruitBankHybrid
|
||||||
|
|
||||||
// Add device-specific services used by the FruitBankHybrid.Shared project
|
// Add device-specific services used by the FruitBankHybrid.Shared project
|
||||||
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
builder.Services.AddSingleton<IFormFactor, FormFactor>();
|
||||||
|
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
builder.Services.AddSingleton<LoggedInModel>();
|
builder.Services.AddSingleton<LoggedInModel>(sp =>
|
||||||
|
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
|
||||||
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
builder.Services.AddSingleton<FruitBankSignalRClient>();
|
||||||
builder.Services.AddSingleton<DatabaseClient>();
|
builder.Services.AddSingleton<DatabaseClient>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using FruitBank.Common.Services;
|
||||||
|
|
||||||
|
namespace FruitBankHybrid.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MAUI implementation of ISecureCredentialService using SecureStorage.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MauiSecureCredentialService : ISecureCredentialService
|
||||||
|
{
|
||||||
|
private const string CredentialsKey = "FruitBank_UserCredentials";
|
||||||
|
private static readonly TimeSpan ExpirationDuration = TimeSpan.FromDays(2);
|
||||||
|
|
||||||
|
public async Task SaveCredentialsAsync(string email, string password)
|
||||||
|
{
|
||||||
|
var data = new SecureCredentialData
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
Password = password,
|
||||||
|
ExpiresAtUtc = DateTime.UtcNow.Add(ExpirationDuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(data);
|
||||||
|
await SecureStorage.Default.SetAsync(CredentialsKey, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoredCredentials?> GetCredentialsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await SecureStorage.Default.GetAsync(CredentialsKey);
|
||||||
|
if (string.IsNullOrEmpty(json))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var data = JsonSerializer.Deserialize<SecureCredentialData>(json);
|
||||||
|
if (data == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (DateTime.UtcNow > data.ExpiresAtUtc)
|
||||||
|
{
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoredCredentials(data.Email, data.Password);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If any error occurs (corrupted data, etc.), clear and return null
|
||||||
|
await ClearCredentialsAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ClearCredentialsAsync()
|
||||||
|
{
|
||||||
|
SecureStorage.Default.Remove(CredentialsKey);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SecureCredentialData
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue