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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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>();
|
||||
|
|
|
|||
|
|
@ -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 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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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