From 0d9ced990a4cbfdb2de92f3a29eac10cc5999df2 Mon Sep 17 00:00:00 2001 From: Loretta Date: Tue, 9 Dec 2025 16:46:47 +0100 Subject: [PATCH] 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. --- FruitBank.Common/FruitBankConstClient.cs | 8 +- FruitBank.Common/Models/LoggedInModel.cs | 106 +++++++++++++-- .../Services/ISecureCredentialService.cs | 29 +++++ .../Layout/MainLayout.razor | 6 +- .../Layout/MainLayout.razor.cs | 47 ++++--- FruitBankHybrid.Shared/Pages/Home.razor.cs | 16 --- FruitBankHybrid.Shared/Pages/Login.razor.cs | 76 ++++++----- .../Pages/MeasuringIn.razor.cs | 2 - .../Pages/MeasuringOut.razor.cs | 2 - .../Services/ISecureCredentialService.cs | 3 + FruitBankHybrid.Web.Client/Program.cs | 5 +- .../Services/WebSecureCredentialService.cs | 122 ++++++++++++++++++ FruitBankHybrid.Web/Program.cs | 5 +- .../Services/ServerSecureCredentialService.cs | 30 +++++ FruitBankHybrid/MauiProgram.cs | 5 +- .../Services/MauiSecureCredentialService.cs | 68 ++++++++++ 16 files changed, 440 insertions(+), 90 deletions(-) create mode 100644 FruitBank.Common/Services/ISecureCredentialService.cs create mode 100644 FruitBankHybrid.Shared/Services/ISecureCredentialService.cs create mode 100644 FruitBankHybrid.Web.Client/Services/WebSecureCredentialService.cs create mode 100644 FruitBankHybrid.Web/Services/ServerSecureCredentialService.cs create mode 100644 FruitBankHybrid/Services/MauiSecureCredentialService.cs diff --git a/FruitBank.Common/FruitBankConstClient.cs b/FruitBank.Common/FruitBankConstClient.cs index 7d7a0da..c444b5c 100644 --- a/FruitBank.Common/FruitBankConstClient.cs +++ b/FruitBank.Common/FruitBankConstClient.cs @@ -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 } + + diff --git a/FruitBank.Common/Models/LoggedInModel.cs b/FruitBank.Common/Models/LoggedInModel.cs index 3ed0f8b..1610e70 100644 --- a/FruitBank.Common/Models/LoggedInModel.cs +++ b/FruitBank.Common/Models/LoggedInModel.cs @@ -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 CustomerRoles { get; private set; } = []; - public List MeasuringUsers { get; set; } = []; + + public Func>? LoginFunc { get; set; } + public Func?>>? GetRolesFunc { get; set; } + public LoggedInModel() { } - public LoggedInModel(CustomerDto? customerDto) + public LoggedInModel(ISecureCredentialService secureCredentialService) { - InitLoggedInCustomer(customerDto); + _secureCredentialService = secureCredentialService; } - public LoggedInModel(MgLoginModelResponse loginModelResponse) : this(loginModelResponse.CustomerDto) + /// + /// 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. + /// + public async Task 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) + /// + /// Performs manual login with the provided credentials. + /// + public async Task 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; + } + + /// + /// Logs out the user and clears stored credentials. + /// + public async Task LogOutAsync() + { + await ClearCredentialsAsync(); + ClearCustomer(); + } + + public void SetCustomer(CustomerDto? customerDto) + { + ClearCustomer(); if (customerDto != null) CustomerDto = customerDto; } - public void InitCustomerRoles(List customerRoles) + public void SetCustomerRoles(List 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 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 } \ No newline at end of file diff --git a/FruitBank.Common/Services/ISecureCredentialService.cs b/FruitBank.Common/Services/ISecureCredentialService.cs new file mode 100644 index 0000000..832a6d4 --- /dev/null +++ b/FruitBank.Common/Services/ISecureCredentialService.cs @@ -0,0 +1,29 @@ +namespace FruitBank.Common.Services; + +/// +/// Service for securely storing and retrieving user credentials. +/// Platform-specific implementations handle the actual secure storage. +/// +public interface ISecureCredentialService +{ + /// + /// Saves the user credentials securely with a 2-day expiration from now. + /// + Task SaveCredentialsAsync(string email, string password); + + /// + /// Retrieves the stored credentials if they exist and haven't expired. + /// Returns null if no credentials are stored or if they have expired. + /// + Task GetCredentialsAsync(); + + /// + /// Clears all stored credentials (used on logout). + /// + Task ClearCredentialsAsync(); +} + +/// +/// Represents stored user credentials. +/// +public sealed record StoredCredentials(string Email, string Password); diff --git a/FruitBankHybrid.Shared/Layout/MainLayout.razor b/FruitBankHybrid.Shared/Layout/MainLayout.razor index e98088a..e59d447 100644 --- a/FruitBankHybrid.Shared/Layout/MainLayout.razor +++ b/FruitBankHybrid.Shared/Layout/MainLayout.razor @@ -1,4 +1,5 @@ @using FruitBank.Common.Models +@using FruitBankHybrid.Shared.Pages @inherits LayoutComponentBase
@@ -29,7 +30,10 @@ ShowCloseButton="true"> - @Body + @if (LoggedInModel.IsLoggedIn || IsOnLoginPage) + { + @Body + } diff --git a/FruitBankHybrid.Shared/Layout/MainLayout.razor.cs b/FruitBankHybrid.Shared/Layout/MainLayout.razor.cs index 9722b1e..48ddd68 100644 --- a/FruitBankHybrid.Shared/Layout/MainLayout.razor.cs +++ b/FruitBankHybrid.Shared/Layout/MainLayout.razor.cs @@ -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(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; + } } \ No newline at end of file diff --git a/FruitBankHybrid.Shared/Pages/Home.razor.cs b/FruitBankHybrid.Shared/Pages/Home.razor.cs index d81c4fc..122f8c0 100644 --- a/FruitBankHybrid.Shared/Pages/Home.razor.cs +++ b/FruitBankHybrid.Shared/Pages/Home.razor.cs @@ -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); - // } - //} } \ No newline at end of file diff --git a/FruitBankHybrid.Shared/Pages/Login.razor.cs b/FruitBankHybrid.Shared/Pages/Login.razor.cs index d225de6..216cdba 100644 --- a/FruitBankHybrid.Shared/Pages/Login.razor.cs +++ b/FruitBankHybrid.Shared/Pages/Login.razor.cs @@ -20,11 +20,10 @@ public partial class Login : ComponentBase [Inject] public required NavigationManager NavManager{ get; set; } private ILogger _logger = null!; - //private List 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().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().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; } } \ No newline at end of file diff --git a/FruitBankHybrid.Shared/Pages/MeasuringIn.razor.cs b/FruitBankHybrid.Shared/Pages/MeasuringIn.razor.cs index d917e45..a774466 100644 --- a/FruitBankHybrid.Shared/Pages/MeasuringIn.razor.cs +++ b/FruitBankHybrid.Shared/Pages/MeasuringIn.razor.cs @@ -39,8 +39,6 @@ namespace FruitBankHybrid.Shared.Pages protected override async Task OnInitializedAsync() { - if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login"); - LoadingPanelVisible = true; _logger = new LoggerClient(LogWriters.ToArray()); diff --git a/FruitBankHybrid.Shared/Pages/MeasuringOut.razor.cs b/FruitBankHybrid.Shared/Pages/MeasuringOut.razor.cs index 97a276c..e6827a9 100644 --- a/FruitBankHybrid.Shared/Pages/MeasuringOut.razor.cs +++ b/FruitBankHybrid.Shared/Pages/MeasuringOut.razor.cs @@ -41,8 +41,6 @@ namespace FruitBankHybrid.Shared.Pages protected override async Task OnInitializedAsync() { - if (!LoggedInModel.IsLoggedIn) NavManager.NavigateTo("/Login"); - LoadingPanelVisible = true; _logger = new LoggerClient(LogWriters.ToArray()); _logger.Info("OnInitializedAsync"); diff --git a/FruitBankHybrid.Shared/Services/ISecureCredentialService.cs b/FruitBankHybrid.Shared/Services/ISecureCredentialService.cs new file mode 100644 index 0000000..27f0218 --- /dev/null +++ b/FruitBankHybrid.Shared/Services/ISecureCredentialService.cs @@ -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; diff --git a/FruitBankHybrid.Web.Client/Program.cs b/FruitBankHybrid.Web.Client/Program.cs index 44f010f..f524ea8 100644 --- a/FruitBankHybrid.Web.Client/Program.cs +++ b/FruitBankHybrid.Web.Client/Program.cs @@ -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(); +builder.Services.AddSingleton(); //#if DEBUG builder.Services.AddSingleton(); //#endif -builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new LoggedInModel(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/FruitBankHybrid.Web.Client/Services/WebSecureCredentialService.cs b/FruitBankHybrid.Web.Client/Services/WebSecureCredentialService.cs new file mode 100644 index 0000000..d3e29f8 --- /dev/null +++ b/FruitBankHybrid.Web.Client/Services/WebSecureCredentialService.cs @@ -0,0 +1,122 @@ +using System.Text; +using System.Text.Json; +using FruitBank.Common.Services; +using Microsoft.JSInterop; + +namespace FruitBankHybrid.Web.Client.Services; + +/// +/// 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. +/// +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 GetCredentialsAsync() + { + try + { + var obfuscated = await _jsRuntime.InvokeAsync("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(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; } + } +} diff --git a/FruitBankHybrid.Web/Program.cs b/FruitBankHybrid.Web/Program.cs index 8634960..18f69d8 100644 --- a/FruitBankHybrid.Web/Program.cs +++ b/FruitBankHybrid.Web/Program.cs @@ -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(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new LoggedInModel(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/FruitBankHybrid.Web/Services/ServerSecureCredentialService.cs b/FruitBankHybrid.Web/Services/ServerSecureCredentialService.cs new file mode 100644 index 0000000..6cd45c9 --- /dev/null +++ b/FruitBankHybrid.Web/Services/ServerSecureCredentialService.cs @@ -0,0 +1,30 @@ +using FruitBank.Common.Services; + +namespace FruitBankHybrid.Web.Services; + + +/// +/// 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. +/// +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 GetCredentialsAsync() + { + // Always return null on server side - auto-login happens client-side after WASM loads + return Task.FromResult(null); + } + + public Task ClearCredentialsAsync() + { + // No-op on server side + return Task.CompletedTask; + } +} diff --git a/FruitBankHybrid/MauiProgram.cs b/FruitBankHybrid/MauiProgram.cs index fbbc93f..7925edf 100644 --- a/FruitBankHybrid/MauiProgram.cs +++ b/FruitBankHybrid/MauiProgram.cs @@ -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(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddSingleton(); #endif - builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + new LoggedInModel(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/FruitBankHybrid/Services/MauiSecureCredentialService.cs b/FruitBankHybrid/Services/MauiSecureCredentialService.cs new file mode 100644 index 0000000..fddbf30 --- /dev/null +++ b/FruitBankHybrid/Services/MauiSecureCredentialService.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using FruitBank.Common.Services; + +namespace FruitBankHybrid.Services; + +/// +/// MAUI implementation of ISecureCredentialService using SecureStorage. +/// +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 GetCredentialsAsync() + { + try + { + var json = await SecureStorage.Default.GetAsync(CredentialsKey); + if (string.IsNullOrEmpty(json)) + return null; + + var data = JsonSerializer.Deserialize(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; } + } +}