using Dapper; using GB5Shared.Config; using GB5Shared.Connection; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ResponseStandard; using Microsoft.AspNetCore.Http; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Npgsql; using GB5Shared.Telemetry.Database; using System; using System.Data; using System.Diagnostics; using System.Threading.Tasks; using static GB5Shared.GB5Constant.Constant; namespace GB5Shared.Middleware { /// /// Session heartbeat middleware — enforces sliding session expiry on every /// authenticated API call by running a single atomic UPDATE against MSESSIONSTORE /// in the GB5 system database. /// /// How it works: /// The UPDATE WHERE clause includes the expiry window check. /// • rowsAffected = 1 → session is active; window slides forward. /// • rowsAffected = 0 → one of two reasons: /// a) Session row still exists but the idle window has elapsed → "expired" /// b) Session row is gone (force-logout / logout from another device) → "terminated" /// A follow-up SELECT distinguishes the two so the client receives a /// specific, actionable reason rather than a generic 401 message. /// /// 401 response format: /// Always returns a fully-structured ResponseStandardDTO<object> matching all /// other GB5 API error responses — never a raw JSON object. /// /// Configuration: /// Gb5SystemDTO:SessionExpiryMinutes (default 15) in appsettings.json / env vars. /// /// Skipped paths (never session-checked): /// /Authentication/* — login/logout endpoints must not be blocked during re-login /// /dapr/* — Dapr pub-sub and sidecar routes /// /swagger* — Swagger UI /// /healthz, /health — health probes /// / — root /// /// Placement in pipeline: after routing/CORS, before FastEndpoints. /// Registration: app.UseMiddleware<SessionHeartbeatMiddleware>() /// /// Guarantees: /// • One DB round-trip on every active request (check + slide are atomic in a /// single UPDATE). A second SELECT is issued only when the UPDATE returns 0. /// • Never throws — DB errors are caught and logged as Warning so that a /// transient DB hiccup never blocks legitimate business requests. /// • Works across ALL SL projects because it lives in GB5Shared. /// public sealed class SessionHeartbeatMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IMemoryCache _heartbeatCache; // ── SQL — inline because GB5Shared does not reference FrameworkDAL. // Slide UPDATE: atomically checks expiry window AND advances LASTLOGINUSEDTIME. // Params: @Now (UTC DateTime), @ServerconfigId (int), // @LoginEventLogId (int), @ExpiryMinutes (int) private const string SlideSqlServer = @" UPDATE MSESSIONSTORE SET LASTLOGINUSEDTIME = @Now WHERE SERVERCONFIGID = @ServerconfigId AND LOGINEVENTLOGID = @LoginEventLogId AND LASTLOGINUSEDTIME >= DATEADD(MINUTE, -@ExpiryMinutes, @Now);"; private const string SlidePostgresql = @" UPDATE MSESSIONSTORE SET LASTLOGINUSEDTIME = @Now WHERE SERVERCONFIGID = @ServerconfigId AND LOGINEVENTLOGID = @LoginEventLogId AND LASTLOGINUSEDTIME >= @Now - (@ExpiryMinutes * INTERVAL '1 minute');"; // Row-existence check — issued only when the UPDATE returns 0. // If the row is present the session has simply expired (idle timeout). // If the row is absent the session was explicitly terminated. // Params: @ServerconfigId (int), @LoginEventLogId (int) private const string CheckSessionExistsSql = @" SELECT COUNT(*) FROM MSESSIONSTORE WHERE SERVERCONFIGID = @ServerconfigId AND LOGINEVENTLOGID = @LoginEventLogId;"; /// /// Singleton IMemoryCache used for heartbeat debouncing. /// Prevents a DB write on every authenticated request by skipping the UPDATE /// when the session was validated within the last (expiryMinutes / 4) minutes. /// Security guarantee: sessions still expire within one full expiry window because /// the cache entry TTL is at most 25% of the expiry window — a terminated session /// can pass at most one quarter-window before the next DB check catches it. /// public SessionHeartbeatMiddleware( RequestDelegate next, ILogger logger, IMemoryCache memoryCache) { _next = next; _logger = logger; _heartbeatCache = memoryCache; } /// /// Scoped services are injected per-request via InvokeAsync parameters — /// correct ASP.NET Core middleware pattern for accessing scoped DI. /// public async Task InvokeAsync( HttpContext context, IApplicationConnection appConnection, IOptionsSnapshot systemDto) { // ── Step 0: Master switch — N in gb5shared.json bypasses all session checks ── // Set "SessionHeartbeatEnabled": "N" during debugging to disable expiry enforcement. // Default is "Y". Any value other than "N" (case-insensitive) is treated as enabled. if (!string.Equals(GB5SharedSettings.Instance.SessionHeartbeatEnabled, "Y", StringComparison.OrdinalIgnoreCase)) { await _next(context); return; } // ── Step 1: Parse LoginDTO from request header ─────────────────── LoginDTO? login = null; try { if (context.Request.Headers.TryGetValue("Login", out var loginHeader) && !string.IsNullOrWhiteSpace(loginHeader)) { login = JsonConvert.DeserializeObject(loginHeader!); } } catch { // Malformed Login header — treat as unauthenticated and continue } // ── Step 2: Guard — skip when no tracked session or internal path ── // Authentication paths are explicitly excluded: a client that sends an // expired Login header while attempting to re-authenticate must reach // the login endpoint rather than being short-circuited here. bool shouldCheck = login is not null && login.LoginEventLogId != 0 && login.ServerConfigId != 0 && !IsInternalPath(context.Request.Path); if (shouldCheck) { // ── Debounce: skip DB write if we slid this session recently ────────────── // Cache key is scoped to the session row — not tenant or user. // TTL = expiryMinutes / 4 so a force-terminated session is detected // within one quarter-window at worst. string heartbeatKey = $"hb:{login!.ServerConfigId}:{login.LoginEventLogId}"; if (_heartbeatCache.TryGetValue(heartbeatKey, out _)) { // Debounced: session was validated and slid within the last window. // Skip the DB UPDATE and allow the request through immediately. await _next(context); return; } // ───────────────────────────────────────────────────────────────────────── bool sessionValid = false; string expireReason = "expired"; int expiryMinutes = 15; try { // Priority: module appsettings override (> 0) → gb5shared.json default expiryMinutes = systemDto.Value.SessionExpiryMinutes > 0 ? systemDto.Value.SessionExpiryMinutes : GB5SharedSettings.Instance.SessionExpiryMinutes; (sessionValid, expireReason) = await CheckAndSlideSessionAsync( appConnection, systemDto.Value.DataBaseType, login.ServerConfigId, login.LoginEventLogId, expiryMinutes).ConfigureAwait(false); // Mark as recently validated — suppress DB writes for the next quarter-window if (sessionValid) { _heartbeatCache.Set( heartbeatKey, 1, TimeSpan.FromMinutes(Math.Max(1, expiryMinutes / 4.0))); } } catch (Exception ex) { // DB error — log and allow through. Availability takes priority over // strict session enforcement for a transient infrastructure failure. _logger.LogWarning(ex, "SessionHeartbeat DB error — allowing request through | " + "LoginEventLogId={LoginEventLogId} ServerconfigId={ServerconfigId}", login.LoginEventLogId, login.ServerConfigId); sessionValid = true; } if (!sessionValid) { _logger.LogInformation( "SessionHeartbeat: session rejected | " + "LoginEventLogId={LoginEventLogId} ServerconfigId={ServerconfigId} Reason={Reason}", login!.LoginEventLogId, login.ServerConfigId, expireReason); // Specific, user-readable reason so the user understands exactly // what happened and what action to take. string message = expireReason == "terminated" ? "Your session was ended — you signed in from another device or " + "an administrator logged you out. Please sign in again." : $"Your session was inactive for more than {expiryMinutes} minute(s) " + "and has expired. Please sign in again."; await WriteUnauthorizedResponseAsync(context, message); return; // short-circuit — do not call _next } } // ── Step 3: Session is valid (or not applicable) — continue pipeline ── await _next(context); } // ── Helpers ────────────────────────────────────────────────────────── /// /// Writes a fully-structured GB5 ResponseStandardDTO<object> 401 response. /// Matches the exact same response envelope used by all other GB5 API error paths. /// private static async Task WriteUnauthorizedResponseAsync(HttpContext context, string message) { var response = new ResponseStandardDTO { Status = FrameworkEnumDTO.ResponseStatus.Unauthorized, Body = message, ErrorBody = message }; context.Response.StatusCode = 401; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonConvert.SerializeObject(response)); } /// /// Atomically slides the session window forward. /// Returns (true, "") when the session is active. /// Returns (false, "expired") when the row exists but the idle window has elapsed. /// Returns (false, "terminated") when the row no longer exists (explicit logout/force-logout). /// private static async Task<(bool IsValid, string Reason)> CheckAndSlideSessionAsync( IApplicationConnection appConnection, int dbType, int serverConfigId, int loginEventLogId, int expiryMinutes) { string connStr = await appConnection.Gb5SystemConnectionString().ConfigureAwait(false); string slideSql = dbType == DBTYPE.POSTGRESQL ? SlidePostgresql : SlideSqlServer; using IDbConnection conn = dbType == DBTYPE.POSTGRESQL ? new NpgsqlConnection(connStr) : (IDbConnection)new SqlConnection(connStr); var slideParams = new { Now = DateTime.UtcNow, ServerconfigId = serverConfigId, LoginEventLogId = loginEventLogId, ExpiryMinutes = expiryMinutes }; int rowsAffected; using (var dbActivity = DatabaseActivityHelper.StartDbActivity(slideSql, parameters: slideParams)) { try { rowsAffected = await conn.ExecuteAsync(slideSql, slideParams).ConfigureAwait(false); dbActivity?.SetStatus(ActivityStatusCode.Ok); } catch (Exception dbEx) { DatabaseActivityHelper.RecordDbError(dbActivity, dbEx); throw; } } if (rowsAffected > 0) return (true, string.Empty); // UPDATE returned 0 rows. Determine whether the session simply timed out // (row still present, window elapsed) or was explicitly deleted (row gone). var existsParams = new { ServerconfigId = serverConfigId, LoginEventLogId = loginEventLogId }; int rowCount; using (var dbActivity = DatabaseActivityHelper.StartDbActivity(CheckSessionExistsSql, parameters: existsParams)) { try { rowCount = await conn.ExecuteScalarAsync(CheckSessionExistsSql, existsParams).ConfigureAwait(false); dbActivity?.SetStatus(ActivityStatusCode.Ok); } catch (Exception dbEx) { DatabaseActivityHelper.RecordDbError(dbActivity, dbEx); throw; } } // rowCount > 0 → row exists, session idle window has elapsed → expired // rowCount = 0 → row was deleted (logout / force-logout) → terminated return (false, rowCount > 0 ? "expired" : "terminated"); } /// /// Returns true for paths that bypass session checking. /// Authentication endpoints are included so that a re-login attempt that still /// carries an old (expired) Login header is never blocked before it can reach /// the authentication endpoint and establish a fresh session. /// private static bool IsInternalPath(PathString path) { var p = path.Value ?? string.Empty; return p.StartsWith("/dapr/", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/Authentication/", StringComparison.OrdinalIgnoreCase) || p.Equals("/healthz", StringComparison.OrdinalIgnoreCase) || p.Equals("/health", StringComparison.OrdinalIgnoreCase) || p.Equals("/", StringComparison.OrdinalIgnoreCase); } } }