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:
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user