fix(central-ui): resolve CentralUI-005 — sliding cookie session expiry (Security AddCookie + AuthEndpoints + SessionExpiry)
This commit is contained in:
@@ -44,15 +44,17 @@ public static class AuthEndpoints
|
||||
// Map LDAP groups to roles
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
|
||||
// Build claims from LDAP auth + role mapping
|
||||
// Build claims from LDAP auth + role mapping.
|
||||
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
|
||||
// — session expiry is owned by the cookie middleware's sliding window
|
||||
// (ScadaLink.Security AddCookie: ExpireTimeSpan = idle timeout,
|
||||
// SlidingExpiration = true). A frozen absolute claim would contradict
|
||||
// the documented sliding-refresh policy.
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, authResult.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
|
||||
new("expires_at", expiresAt.ToUnixTimeSeconds().ToString()),
|
||||
};
|
||||
|
||||
foreach (var role in roleMappingResult.Roles)
|
||||
@@ -74,11 +76,7 @@ public static class AuthEndpoints
|
||||
await context.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
principal,
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = expiresAt
|
||||
});
|
||||
BuildSignInProperties());
|
||||
|
||||
context.Response.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
@@ -138,4 +136,22 @@ public static class AuthEndpoints
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
|
||||
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
|
||||
/// Session expiry is owned by the cookie authentication middleware's sliding
|
||||
/// window (configured in <c>ScadaLink.Security</c>'s <c>AddCookie</c>:
|
||||
/// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
|
||||
/// Setting a fixed <c>ExpiresUtc</c> here would re-impose a hard absolute cap
|
||||
/// that overrides the sliding window and contradicts the documented
|
||||
/// "sliding refresh, 30-minute idle timeout" policy. <see cref="AuthenticationProperties.IsPersistent"/>
|
||||
/// is true so the cookie survives a browser restart within the idle window;
|
||||
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
|
||||
/// so the middleware is free to slide the expiry on activity.
|
||||
/// </summary>
|
||||
public static AuthenticationProperties BuildSignInProperties() => new()
|
||||
{
|
||||
IsPersistent = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,37 +3,62 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
// CentralUI-005: 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
/// <summary>How often the session validity is re-checked.</summary>
|
||||
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// The login page uses the same layout, so this component renders there
|
||||
// too. Redirecting /login → /login would loop ("too many redirects").
|
||||
// too. Polling/redirecting on /login → /login would loop.
|
||||
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
var auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (auth.User.Identity?.IsAuthenticated != true) return;
|
||||
|
||||
var claim = auth.User.FindFirst("expires_at")?.Value;
|
||||
if (!long.TryParse(claim, out var unix)) return;
|
||||
|
||||
var remaining = DateTimeOffset.FromUnixTimeSeconds(unix) - DateTimeOffset.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = ScheduleRedirectAsync(remaining + TimeSpan.FromSeconds(2), _cts.Token);
|
||||
_ = PollSessionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task ScheduleRedirectAsync(TimeSpan delay, CancellationToken token)
|
||||
private async Task PollSessionAsync(CancellationToken token)
|
||||
{
|
||||
try { await Task.Delay(delay, token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(PollInterval, token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
|
||||
AuthenticationState auth;
|
||||
try
|
||||
{
|
||||
auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
@@ -25,6 +26,28 @@ public static class ServiceCollectionExtensions
|
||||
options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
|
||||
});
|
||||
|
||||
// CentralUI-005: configure the cookie session as a sliding window so the
|
||||
// code matches the documented policy ("sliding refresh, 30-minute idle
|
||||
// timeout"). ASP.NET cookie auth exposes a single ExpireTimeSpan plus a
|
||||
// SlidingExpiration flag — it cannot natively model a 15-minute sliding
|
||||
// token AND a separate 30-minute absolute idle cap. The faithful
|
||||
// interpretation: the cookie window IS the idle timeout
|
||||
// (SecurityOptions.IdleTimeoutMinutes, default 30) and SlidingExpiration
|
||||
// renews it on activity (the middleware re-issues the cookie once past
|
||||
// the halfway mark of the window). An active user is therefore kept
|
||||
// signed in; an idle user is signed out after the idle timeout. The
|
||||
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
|
||||
// itself (see JwtTokenService) — a separate layer from the cookie
|
||||
// session window. Bound here via PostConfigure so SecurityOptions
|
||||
// (configured by the Host after AddSecurity) is honoured.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<SecurityOptions>>((cookieOptions, securityOptions) =>
|
||||
{
|
||||
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
});
|
||||
|
||||
services.AddScadaLinkAuthorization();
|
||||
|
||||
return services;
|
||||
|
||||
Reference in New Issue
Block a user