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:
Loretta 2025-12-09 16:46:47 +01:00
parent 4ef318973f
commit 0d9ced990a
16 changed files with 440 additions and 90 deletions

View File

@ -1,4 +1,4 @@
using AyCode.Core.Consts;
using AyCode.Core.Consts;
using AyCode.Core.Loggers;
namespace FruitBank.Common;
@ -7,13 +7,13 @@ public static class FruitBankConstClient
{
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
#if RELEASE
// public static string BaseUrl = "https://shop.fruitbank.hu"; //FrutiBank nop
#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 = "https://localhost:7144"; //HybridApp
@ -111,3 +111,5 @@ public static string SystemEmailAddress = "test@touriam.com";
public static LogLevel DefaultLogLevelClient = LogLevel.Detail;
#endif
}

View File

@ -1,4 +1,5 @@
using AyCode.Core;
using FruitBank.Common.Services;
using Mango.Nop.Core.Dtos;
using Mango.Nop.Core.Models;
using Nop.Core.Domain.Customers;
@ -7,6 +8,8 @@ namespace FruitBank.Common.Models;
public class LoggedInModel
{
private readonly ISecureCredentialService? _secureCredentialService;
public bool IsLoggedIn => CustomerDto != null;
public bool IsRevisor => IsLoggedIn && CustomerRoles.Any(x => x.SystemName.ToLowerInvariant() == "measuringrevisor");
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 List<CustomerRole> CustomerRoles { get; private 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(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;
}
public void InitCustomerRoles(List<CustomerRole> customerRoles)
public void SetCustomerRoles(List<CustomerRole> customerRoles)
{
CustomerRoles.Clear();
CustomerRoles.AddRange(customerRoles);
}
public void LogOut()
public void ClearCustomer()
{
CustomerDto = null;
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
}

View File

@ -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);

View File

@ -1,4 +1,5 @@
@using FruitBank.Common.Models
@using FruitBankHybrid.Shared.Pages
@inherits LayoutComponentBase
<div class="page">
@ -29,7 +30,10 @@
ShowCloseButton="true">
</DxToastProvider>
<CascadingValue Value="RefreshMainLayoutEventCallback">
@Body
@if (LoggedInModel.IsLoggedIn || IsOnLoginPage)
{
@Body
}
</CascadingValue>
</article>
</main>

View File

@ -12,7 +12,6 @@ using FruitBankHybrid.Shared.Pages;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs;
using Mango.Nop.Core.Loggers;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Components;
namespace FruitBankHybrid.Shared.Layout;
@ -29,6 +28,8 @@ public partial class MainLayout : LayoutComponentBase
private NavMenu _navMenu = null!;
private LoggerClient _logger = null!;
private bool IsOnLoginPage => NavManager.Uri.Equals(NavManager.ToAbsoluteUri("/Login").ToString(), StringComparison.OrdinalIgnoreCase);
// Toast fields
private DxToast orderNotificationToast;
private string toastTitle = "Értesítő!";
@ -39,27 +40,30 @@ public partial class MainLayout : LayoutComponentBase
protected override void OnInitialized()
{
_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;
var loginUri = NavManager.ToAbsoluteUri("/Login").ToString();
if (!LoggedInModel.IsLoggedIn && NavManager.Uri != loginUri)
{
NavManager.NavigateTo("/Login", forceLoad: true);
}
}
//protected override void OnAfterRender(bool firstRender)
//{
// if (!firstRender) return;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// var loginUri = NavManager.ToAbsoluteUri("/Login").ToString();
// if (!LoggedInModel.IsLoggedIn && NavManager.Uri != loginUri)
// {
// NavManager.NavigateTo("/Login", forceLoad: true);
// }
//}
await LoggedInModel.TryAutoLoginAsync();
if (!LoggedInModel.IsLoggedIn && !IsOnLoginPage)
{
NavManager.NavigateTo("/Login");
}
else if (LoggedInModel.IsLoggedIn)
{
StateHasChanged(); // Refresh UI after successful auto-login
}
}
private async Task SignalRClientOnMessageReceived(int messageTag, string? jsonMessage)
{
@ -97,9 +101,9 @@ public partial class MainLayout : LayoutComponentBase
});
}
private void OnLogoutClick()
private async void OnLogoutClick()
{
LoggedInModel.LogOut();
await LoggedInModel.LogOutAsync();
RefreshMainLayout();
NavManager.NavigateTo("/Login");
}
@ -109,4 +113,9 @@ public partial class MainLayout : LayoutComponentBase
_navMenu.RefreshNavMenu();
StateHasChanged();
}
public void Dispose()
{
FruitBankSignalRClient.OnMessageReceived -= SignalRClientOnMessageReceived;
}
}

View File

@ -10,20 +10,4 @@ public partial class Home : ComponentBase
private string Factor => FormFactor.GetFormFactor();
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);
// }
//}
}

View File

@ -20,11 +20,10 @@ public partial class Login : ComponentBase
[Inject] public required NavigationManager NavManager{ get; set; }
private ILogger _logger = null!;
//private List<CustomerDto> Users { get; set; }
private CustomerDto? SelectedUser { get; set; }
private string PasswordValue { get; set; } = string.Empty;
private MgLoginModelResponse? LoginModelResponse { get; set; }
private string _rolesText = string.Empty;
[CascadingParameter]
public EventCallback UpdateStyle { get; set; }
@ -36,55 +35,63 @@ public partial class Login : ComponentBase
if (!LoggedInModel.IsLoggedIn)
{
using (await ObjectLock.GetSemaphore<CustomerDto>().UseWaitAsync())
{
if (LoggedInModel.MeasuringUsers.Count == 0)
{
LoggedInModel.MeasuringUsers = await FruitBankSignalRClient.GetMeasuringUsers() ?? [];
SelectedUser = LoggedInModel.MeasuringUsers.FirstOrDefault();
}
}
await LoadMeasuringUsersAsync();
}
else
{
_rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
}
else _rolesText = string.Join("; ", LoggedInModel.CustomerRoles.Select(x => x.Name));
await base.OnInitializedAsync();
}
private string _rolesText = string.Empty;
private async Task LoadMeasuringUsersAsync()
{
using (await ObjectLock.GetSemaphore<CustomerDto>().UseWaitAsync())
{
if (LoggedInModel.MeasuringUsers.Count == 0)
{
LoggedInModel.MeasuringUsers = await FruitBankSignalRClient.GetMeasuringUsers() ?? [];
SelectedUser = LoggedInModel.MeasuringUsers.FirstOrDefault();
}
}
}
private async Task OnLoginClick()
{
if (LoggedInModel.IsLoggedIn) return;
_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())
{
LoginModelResponse = new MgLoginModelResponse
{
ErrorMessage = "Válasszon felhsználót és adja meg a jelszavát!"
};
return;
return false;
}
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();
return true;
}
protected async Task OnPasswordKeyDown(KeyboardEventArgs e)
@ -94,7 +101,6 @@ public partial class Login : ComponentBase
private string GetImageFileName(CustomerDto employee)
{
//return StaticAssetUtils.GetEmployeeImagePath(employee.Id);
return string.Empty;
}
}

View File

@ -39,8 +39,6 @@ namespace FruitBankHybrid.Shared.Pages
protected override async Task OnInitializedAsync()
{
if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login");
LoadingPanelVisible = true;
_logger = new LoggerClient<MeasuringIn>(LogWriters.ToArray());

View File

@ -41,8 +41,6 @@ namespace FruitBankHybrid.Shared.Pages
protected override async Task OnInitializedAsync()
{
if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login");
LoadingPanelVisible = true;
_logger = new LoggerClient<MeasuringOut>(LogWriters.ToArray());
_logger.Info("OnInitializedAsync");

View File

@ -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;

View File

@ -1,6 +1,7 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Loggers;
using FruitBank.Common.Models;
using FruitBank.Common.Services;
using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services;
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
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
//#if DEBUG
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
//#endif
builder.Services.AddSingleton<LoggedInModel>();
builder.Services.AddSingleton<LoggedInModel>(sp =>
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>();

View File

@ -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; }
}
}

View File

@ -1,6 +1,7 @@
using AyCode.Core.Loggers;
using FruitBank.Common;
using FruitBank.Common.Models;
using FruitBank.Common.Services;
using FruitBank.Common.Server.Services.Loggers;
using FruitBank.Common.Server.Services.SignalRs;
using FruitBankHybrid.Shared.Databases;
@ -17,9 +18,11 @@ builder.Services.AddMvc();
builder.Services.AddSignalR(options => options.MaximumReceiveMessageSize = 256 * 1024);
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
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<DatabaseClient>();

View File

@ -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;
}
}

View File

@ -1,6 +1,7 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Loggers;
using FruitBank.Common.Models;
using FruitBank.Common.Services;
using FruitBankHybrid.Services;
using FruitBankHybrid.Services.Loggers;
using FruitBankHybrid.Shared.Databases;
@ -28,12 +29,14 @@ namespace FruitBankHybrid
// Add device-specific services used by the FruitBankHybrid.Shared project
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
#if DEBUG
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
#endif
builder.Services.AddSingleton<LoggedInModel>();
builder.Services.AddSingleton<LoggedInModel>(sp =>
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>();

View File

@ -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; }
}
}