feat(auth): synchronize SSR cookie authentication with WASM client and support returnUrl navigation

This commit is contained in:
2026-05-27 11:19:48 +02:00
parent ee87014fee
commit ae25d14ee7
4 changed files with 85 additions and 2 deletions
@@ -0,0 +1,41 @@
@using Microsoft.AspNetCore.Components.Authorization
@inject PersistentComponentState ApplicationState
@inject AuthenticationStateProvider AuthenticationStateProvider
@implements IDisposable
@code {
private PersistingComponentStateSubscription _subscription;
protected override void OnInitialized()
{
_subscription = ApplicationState.RegisterOnPersisting(PersistAuthenticationStateAsync);
}
private async Task PersistAuthenticationStateAsync()
{
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var principal = authenticationState.User;
if (principal.Identity?.IsAuthenticated == true)
{
var email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? principal.Identity.Name;
var tenantId = principal.FindFirst("TenantId")?.Value ?? "global";
var roles = string.Join(",", principal.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value));
if (email != null)
{
ApplicationState.PersistAsJson("UserInfo", new UserInfo
{
Email = email,
TenantId = tenantId,
Roles = roles
});
}
}
}
public void Dispose()
{
_subscription.Dispose();
}
}
@@ -102,13 +102,21 @@
<input type="hidden" name="email" value="@_loginModel.Email" /> <input type="hidden" name="email" value="@_loginModel.Email" />
<input type="hidden" name="password" value="@_loginModel.Password" /> <input type="hidden" name="password" value="@_loginModel.Password" />
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" /> <input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
</form> </form>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
[Parameter] [Parameter]
[SupplyParameterFromQuery(Name = "error")] [SupplyParameterFromQuery(Name = "error")]
public string? ErrorCode { get; set; } public string? ErrorCode { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
private LoginModel _loginModel = new(); private LoginModel _loginModel = new();
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
@@ -116,7 +124,7 @@
private bool _allowRegistration; private bool _allowRegistration;
private bool _allowPasswordReset; private bool _allowPasswordReset;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true; _allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true; _allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
@@ -134,6 +142,15 @@
_ => "Wystąpił nieoczekiwany błąd podczas logowania." _ => "Wystąpił nieoczekiwany błąd podczas logowania."
}; };
} }
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
NavigationManager.NavigateTo(string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl);
}
}
} }
private async Task HandleLogin() private async Task HandleLogin()
+2
View File
@@ -1,3 +1,5 @@
<AuthenticationStatePersister />
<ErrorBoundary @ref="_errorBoundary"> <ErrorBoundary @ref="_errorBoundary">
<ChildContent> <ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly"> <Router AppAssembly="@typeof(Routes).Assembly">
@@ -4,20 +4,24 @@ using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Constants; using NexusReader.Application.Constants;
using Microsoft.AspNetCore.Components;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public class NexusAuthenticationStateProvider : AuthenticationStateProvider public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private readonly PersistentComponentState _persistentState;
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes. // SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server) // In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
// or encrypted storage/JWT claims validation to prevent client-side role tampering. // or encrypted storage/JWT claims validation to prevent client-side role tampering.
private const string TokenKey = StorageKeys.AuthToken; private const string TokenKey = StorageKeys.AuthToken;
public NexusAuthenticationStateProvider(INativeStorageService storageService) public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState)
{ {
_storageService = storageService; _storageService = storageService;
_persistentState = persistentState;
} }
public void ClearCache() public void ClearCache()
@@ -34,6 +38,18 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
if (_cachedState != null) return _cachedState; if (_cachedState != null) return _cachedState;
// 0. Hydrate state from SSR if available in PersistentComponentState
if (_persistentState.TryTakeFromJson<UserInfo>("UserInfo", out var userInfo) && userInfo != null)
{
// Save to local storage for subsequent client-only transitions/refreshes
await _storageService.SaveSecureString(StorageKeys.UserEmail, userInfo.Email);
await _storageService.SaveSecureString(StorageKeys.UserTenant, userInfo.TenantId);
await _storageService.SaveSecureString(StorageKeys.UserRoles, userInfo.Roles);
_cachedState = CreateState(userInfo.Email, userInfo.TenantId, "FederatedHydration", userInfo.Roles);
return _cachedState;
}
var tokenResult = await _storageService.GetSecureString(TokenKey); var tokenResult = await _storageService.GetSecureString(TokenKey);
var token = tokenResult.IsSuccess ? tokenResult.Value : null; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
@@ -116,3 +132,10 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
} }
} }
public class UserInfo
{
public string Email { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string Roles { get; set; } = string.Empty;
}