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