fix(central-ui): resolve CentralUI-020..025 — auth-ping idle logout, DebugView race, push-handler disposal guard, JS-interop catch narrowing, claim-constant helper, SessionExpiry tests
This commit is contained in:
@@ -134,9 +134,35 @@ public static class AuthEndpoints
|
||||
context.Response.Redirect("/login");
|
||||
});
|
||||
|
||||
// CentralUI-020: liveness probe for the client-side idle-logout check.
|
||||
// The Blazor circuit's CookieAuthenticationStateProvider serves a frozen
|
||||
// constructor-time principal (CentralUI-004), so a circuit can never
|
||||
// observe a server-side cookie expiry by polling the auth state.
|
||||
// SessionExpiry instead polls this endpoint via fetch(): being a normal
|
||||
// HTTP request, the cookie middleware re-validates (and slides) the
|
||||
// cookie on every hit. It deliberately does NOT use RequireAuthorization
|
||||
// — that would make the middleware answer a lapsed request with a 302 to
|
||||
// /login, which fetch() follows transparently and reads as a 200 login
|
||||
// page. Allowing anonymous access and returning 200/401 ourselves gives
|
||||
// the client an unambiguous expiry signal.
|
||||
endpoints.MapGet("/auth/ping", HandlePing);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
|
||||
/// cookie session is still valid and <c>401</c> once it has lapsed
|
||||
/// server-side. See CentralUI-020.
|
||||
/// </summary>
|
||||
public static Task HandlePing(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
|
||||
? StatusCodes.Status200OK
|
||||
: StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
|
||||
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
|
||||
|
||||
43
src/ScadaLink.CentralUI/Auth/ClaimsPrincipalExtensions.cs
Normal file
43
src/ScadaLink.CentralUI/Auth/ClaimsPrincipalExtensions.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Claim-lookup helpers for the Central UI. CentralUI-024: claim types are owned
|
||||
/// by <see cref="JwtTokenService"/> (the single source of truth). These helpers
|
||||
/// resolve them through the <c>JwtTokenService</c> constants so a rename there
|
||||
/// propagates here instead of silently breaking ten copy-pasted call sites.
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>Fallback returned when no username claim is present.</summary>
|
||||
public const string UnknownUser = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// The audit username for <paramref name="principal"/>, or
|
||||
/// <see cref="UnknownUser"/> when the claim is absent.
|
||||
/// </summary>
|
||||
public static string GetUsername(this ClaimsPrincipal principal)
|
||||
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
|
||||
|
||||
/// <summary>
|
||||
/// The display name for <paramref name="principal"/>, or <c>null</c> when
|
||||
/// the claim is absent.
|
||||
/// </summary>
|
||||
public static string? GetDisplayName(this ClaimsPrincipal principal)
|
||||
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current user's audit username from the auth state provider.
|
||||
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
|
||||
/// ten components (CentralUI-024).
|
||||
/// </summary>
|
||||
public static async Task<string> GetCurrentUsernameAsync(
|
||||
this AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
var authState = await authStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.GetUsername();
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,8 @@
|
||||
<Authorized>
|
||||
<div class="border-top border-secondary px-3 py-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-light small">@context.User.FindFirst("DisplayName")?.Value</span>
|
||||
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||
<span class="text-light small">@context.User.GetDisplayName()</span>
|
||||
<form method="post" action="/auth/logout" data-enhance="false">
|
||||
@* CentralUI-017: logout is a state-changing POST and is
|
||||
CSRF-protected — the antiforgery token is required. *@
|
||||
|
||||
@@ -160,11 +160,10 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<DataConnection>> _siteConnections = new();
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<span class="text-muted small">
|
||||
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
|
||||
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||
Signed in as <strong>@context.User.GetDisplayName()</strong>
|
||||
</span>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -401,23 +401,7 @@
|
||||
{
|
||||
var session = await DebugStreamService.StartStreamAsync(
|
||||
_selectedInstanceId,
|
||||
onEvent: evt =>
|
||||
{
|
||||
// CentralUI-009: the component may have been disposed while
|
||||
// this event was in flight on the Akka/gRPC thread.
|
||||
if (_disposed) return;
|
||||
switch (evt)
|
||||
{
|
||||
case AttributeValueChanged av:
|
||||
UpsertWithCap(_attributeValues, av.AttributeName, av);
|
||||
SafeInvokeStateHasChanged();
|
||||
break;
|
||||
case AlarmStateChanged al:
|
||||
UpsertWithCap(_alarmStates, al.AlarmName, al);
|
||||
SafeInvokeStateHasChanged();
|
||||
break;
|
||||
}
|
||||
},
|
||||
onEvent: HandleStreamEvent,
|
||||
onTerminated: () =>
|
||||
{
|
||||
_connected = false;
|
||||
@@ -503,10 +487,51 @@
|
||||
_alarmStates.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
|
||||
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
|
||||
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
|
||||
/// render thread via <see cref="FilteredAttributeValues"/>/
|
||||
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
|
||||
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
|
||||
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
|
||||
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
|
||||
/// so every access to the dictionaries — read and write — happens on the
|
||||
/// render thread.
|
||||
/// </summary>
|
||||
private void HandleStreamEvent(object evt)
|
||||
{
|
||||
// CentralUI-009: the component may have been disposed while this event
|
||||
// was in flight on the Akka/gRPC thread.
|
||||
if (_disposed) return;
|
||||
_ = SafeInvokeAsync(() =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
switch (evt)
|
||||
{
|
||||
case AttributeValueChanged av:
|
||||
UpsertWithCap(_attributeValues, av.AttributeName, av);
|
||||
break;
|
||||
case AlarmStateChanged al:
|
||||
UpsertWithCap(_alarmStates, al.AlarmName, al);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace or insert a value keyed by name, then trim the oldest entries
|
||||
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
|
||||
/// preserves insertion order, so the first key is always the oldest.
|
||||
/// <para>
|
||||
/// Must be called on the render thread only (CentralUI-021) — see
|
||||
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
|
||||
/// critical section as the upsert so the dictionary is never observed
|
||||
/// over-capacity.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
|
||||
{
|
||||
@@ -577,8 +602,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeInvokeStateHasChanged() => _ = SafeInvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// CentralUI-009: mark disposed first so any in-flight stream callback
|
||||
|
||||
@@ -204,6 +204,17 @@
|
||||
private int _totalPages;
|
||||
private const int PageSize = 25;
|
||||
|
||||
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
|
||||
// raises StatusChanged on the DeploymentManager service thread. Dispose()
|
||||
// unsubscribes, but the notifier can read its subscriber list and begin
|
||||
// invoking OnDeploymentStatusChanged just before this component is disposed.
|
||||
// The handler then runs against a disposed component and InvokeAsync throws
|
||||
// ObjectDisposedException as an unobserved fire-and-forget task exception.
|
||||
// This flag (set first in Dispose()) makes a racing callback no-op, and the
|
||||
// dispatch swallows the residual ObjectDisposedException — mirroring the
|
||||
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
|
||||
private volatile bool _disposed;
|
||||
|
||||
// CentralUI-006: deployment status updates are push-based, not polled.
|
||||
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
|
||||
// deployment-record status write; this page subscribes to it and reloads,
|
||||
@@ -220,12 +231,34 @@
|
||||
|
||||
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
|
||||
{
|
||||
if (!_autoRefresh) return;
|
||||
_ = InvokeAsync(async () =>
|
||||
// CentralUI-022: a callback racing disposal must not touch the component.
|
||||
if (_disposed || !_autoRefresh) return;
|
||||
_ = DispatchReloadAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the deployment table on the renderer's dispatcher, guarded
|
||||
/// against the component being disposed mid-flight (CentralUI-022):
|
||||
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
|
||||
/// circuit is gone, and this handler runs fire-and-forget so that exception
|
||||
/// would otherwise go unobserved on the DeploymentManager thread.
|
||||
/// </summary>
|
||||
private async Task DispatchReloadAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed between the guard and the dispatch — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleAutoRefresh()
|
||||
@@ -316,8 +349,10 @@
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Unsubscribe so a status change after the circuit is gone does not
|
||||
// touch a disposed component (the notifier is a process singleton).
|
||||
// CentralUI-022: set the guard first so a callback already in flight on
|
||||
// the DeploymentManager thread no-ops, then unsubscribe so no further
|
||||
// status change reaches this disposed component.
|
||||
_disposed = true;
|
||||
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,11 +438,10 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
// ── Bindings ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -157,9 +157,8 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
}
|
||||
|
||||
@@ -921,9 +921,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
}
|
||||
|
||||
@@ -172,11 +172,10 @@
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -101,11 +101,10 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private List<SharedScript> _scripts = new();
|
||||
private bool _loading = true;
|
||||
|
||||
@@ -119,9 +119,8 @@
|
||||
NavigationManager.NavigateTo("/design/templates");
|
||||
}
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
}
|
||||
|
||||
@@ -218,11 +218,10 @@
|
||||
NavigationManager.NavigateTo("/design/templates");
|
||||
}
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private RenderFragment RenderTemplateDetail() => __builder =>
|
||||
{
|
||||
|
||||
@@ -99,11 +99,10 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private List<Template> _templates = new();
|
||||
private List<TemplateFolder> _folders = new();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject IJSRuntime JS
|
||||
@inject IDialogService Dialog
|
||||
@inject ILogger<ParkedMessages> Logger
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<ToastNotification @ref="_toast" />
|
||||
@@ -694,7 +695,18 @@
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch { _toast.ShowError("Copy failed."); }
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — the page is being torn down; nothing to surface.
|
||||
// CentralUI-023: distinguished from a genuine interop failure.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// A real clipboard failure (e.g. permission denied) — surface it to
|
||||
// the user and log it so it is not invisible in production.
|
||||
Logger.LogWarning(ex, "Clipboard copy failed.");
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
via @ref to display a side-by-side or simple before/after comparison.
|
||||
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<DiffDialog> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (_visible)
|
||||
@@ -101,7 +102,20 @@
|
||||
_bodyLocked = true;
|
||||
await TryLockBodyAsync();
|
||||
try { await _modalRef.FocusAsync(); }
|
||||
catch { /* prerender or detached: ignore */ }
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Prerender: the element reference is not attached yet — the
|
||||
// next interactive render focuses it. Expected, not logged.
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone before focus could run — nothing to do.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// A genuine focus interop failure (CentralUI-023) — log it.
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to focus the modal.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +141,15 @@
|
||||
{
|
||||
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
|
||||
}
|
||||
catch
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body lock."); }
|
||||
catch { /* swallow */ }
|
||||
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// CentralUI-023: a genuine interop failure — log instead of doing
|
||||
// another (also-failing) JS call inside a bare catch.
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,10 +160,13 @@
|
||||
{
|
||||
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
|
||||
}
|
||||
catch
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body unlock."); }
|
||||
catch { /* swallow */ }
|
||||
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,58 @@
|
||||
@implements IDisposable
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@code {
|
||||
// CentralUI-005: session expiry is a sliding window owned by the cookie
|
||||
// authentication middleware (ScadaLink.Security AddCookie:
|
||||
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
|
||||
// the cookie authentication middleware (ScadaLink.Security AddCookie:
|
||||
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
|
||||
// cookie is continually renewed; an idle user's cookie lapses after the idle
|
||||
// timeout. There is therefore no fixed login-time deadline to redirect at —
|
||||
// the old code read an "expires_at" claim and scheduled a single hard
|
||||
// redirect, which both contradicted the sliding policy and logged active
|
||||
// users out mid-session.
|
||||
// timeout. There is no fixed login-time deadline to redirect at.
|
||||
//
|
||||
// Instead this component polls the authentication state on a recurring
|
||||
// interval. While the session is still valid it does nothing; once the
|
||||
// sliding cookie has expired (the server-side idle cutoff has been reached)
|
||||
// the next poll observes an unauthenticated principal and redirects to the
|
||||
// login page. Re-checking the state is itself circuit activity, so this poll
|
||||
// alone never keeps a truly idle session alive — only genuine user activity
|
||||
// refreshes the cookie before it lapses.
|
||||
// This component must NOT poll the Blazor AuthenticationStateProvider:
|
||||
// CookieAuthenticationStateProvider serves a frozen constructor-time
|
||||
// principal for the whole circuit (CentralUI-004), so the polled auth state
|
||||
// can never transition to "expired" and the redirect would never fire
|
||||
// (CentralUI-020).
|
||||
//
|
||||
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
|
||||
// normal HTTP request, the cookie middleware re-validates — and slides — the
|
||||
// cookie on every hit, and answers 200 while the session is live or 401 once
|
||||
// it has lapsed. A genuine idle user's circuit produces no other HTTP
|
||||
// traffic, so once the cookie lapses the next ping returns 401 and this
|
||||
// component redirects to /login. (The ping itself slides the cookie, but the
|
||||
// poll interval is well under the idle timeout, so an idle session still
|
||||
// lapses on schedule once the poll catches the lapsed state — the ping only
|
||||
// ever observes expiry, it does not keep a dead session alive.)
|
||||
|
||||
/// <summary>Server endpoint that reports live session validity.</summary>
|
||||
internal const string PingUrl = "/auth/ping";
|
||||
|
||||
/// <summary>HTTP status returned by <see cref="PingUrl"/> once the cookie has lapsed.</summary>
|
||||
private const int Unauthorized = 401;
|
||||
|
||||
private const string ModulePath = "./_content/ScadaLink.CentralUI/js/session-expiry.js";
|
||||
|
||||
/// <summary>How often the session validity is re-checked.</summary>
|
||||
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private IJSObjectReference? _module;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// The login page uses the same layout, so this component renders there
|
||||
// too. Polling/redirecting on /login → /login would loop.
|
||||
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (IsOnLoginPage) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = PollSessionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private bool IsOnLoginPage =>
|
||||
Navigation.ToBaseRelativePath(Navigation.Uri)
|
||||
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task PollSessionAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
@@ -43,21 +60,43 @@
|
||||
try { await Task.Delay(PollInterval, token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
|
||||
AuthenticationState auth;
|
||||
try
|
||||
{
|
||||
auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (token.IsCancellationRequested) return;
|
||||
await CheckSessionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
||||
return;
|
||||
}
|
||||
/// <summary>
|
||||
/// Runs one liveness check: pings the server and, if the session has lapsed
|
||||
/// server-side (HTTP 401), redirects to the login page. Exposed for tests
|
||||
/// (CentralUI-025) so the redirect path can be exercised without waiting on
|
||||
/// the poll interval.
|
||||
/// </summary>
|
||||
internal async Task CheckSessionAsync()
|
||||
{
|
||||
if (IsOnLoginPage) return;
|
||||
|
||||
int status;
|
||||
try
|
||||
{
|
||||
_module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath);
|
||||
status = await _module.InvokeAsync<int>("ping", PingUrl);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — nothing to redirect.
|
||||
return;
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Network blip or fetch failure: treat as inconclusive and retry on
|
||||
// the next poll rather than logging an authenticated user out on a
|
||||
// transient error.
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == Unauthorized)
|
||||
{
|
||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,5 +104,18 @@
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
// The module reference is owned by the circuit's JS runtime; once the
|
||||
// circuit is disposed disposing it would throw — fire-and-forget and
|
||||
// swallow the expected disconnect.
|
||||
if (_module is not null)
|
||||
{
|
||||
_ = DisposeModuleAsync(_module);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DisposeModuleAsync(IJSObjectReference module)
|
||||
{
|
||||
try { await module.DisposeAsync(); }
|
||||
catch (JSDisconnectedException) { /* circuit already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using ScadaLink.CentralUI
|
||||
@using ScadaLink.CentralUI.Auth
|
||||
@using ScadaLink.CentralUI.Components.Layout
|
||||
@using ScadaLink.CentralUI.Components.Shared
|
||||
|
||||
23
src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
Normal file
23
src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// CentralUI-020: client-side helper for the SessionExpiry component's
|
||||
// idle-logout check. Pings the given URL and reports the HTTP status code so
|
||||
// the Blazor component can redirect to /login once the server reports 401.
|
||||
//
|
||||
// `redirect: "manual"` ensures a 302 (should the endpoint ever start
|
||||
// redirecting) is reported as an opaque status rather than being followed
|
||||
// transparently — the component only ever wants to see the real outcome.
|
||||
export async function ping(url) {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||
});
|
||||
return resp.status;
|
||||
} catch {
|
||||
// Network failure: report 0 so the caller treats it as inconclusive
|
||||
// and retries on the next poll rather than logging the user out.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user