Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная
|
||||
/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки,
|
||||
/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не
|
||||
/// логируется (аудит-IP живёт в AuditLog, операторский контур).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех
|
||||
/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса.
|
||||
/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог
|
||||
/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только
|
||||
/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни
|
||||
/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется.
|
||||
/// </remarks>
|
||||
public sealed class HttpAccessLogMiddleware
|
||||
{
|
||||
// Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor.
|
||||
private const string GrpcContentType = "application/grpc";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<HttpAccessLogMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт middleware access-лога HTTP-запросов.
|
||||
/// </summary>
|
||||
/// <param name="next">Следующий обработчик конвейера.</param>
|
||||
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
|
||||
public HttpAccessLogMiddleware(RequestDelegate next, ILogger<HttpAccessLogMiddleware> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
long startedAt = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
LogCall(context, startedAt, null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем —
|
||||
// обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod).
|
||||
LogCall(context, startedAt, exception);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет одну строку access-лога запроса (успех либо исключение).
|
||||
// context: Контекст запроса (метод/путь/статус ответа).
|
||||
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
||||
// exception: Необработанное исключение (null — запрос завершился штатно).
|
||||
private void LogCall(HttpContext context, long startedAt, Exception? exception)
|
||||
{
|
||||
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||
string method = context.Request.Method;
|
||||
string path = context.Request.Path.Value ?? "/";
|
||||
if (exception is null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
context.Response.StatusCode,
|
||||
elapsedMs);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
|
||||
method,
|
||||
path,
|
||||
elapsedMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через
|
||||
/// OperatorAuthService и наполняет <c>HttpContext.Items["CurrentOperator"]</c> (Ruling 1 этапа 7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Зеркало <see cref="SessionMiddleware"/> для операторов: отдельная кука и отдельный ключ Items —
|
||||
/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware).
|
||||
/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту.
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют
|
||||
/// <c>GetCurrentOperator()</c> и выставляют 401. OperatorAuthService — scoped, поэтому на запрос
|
||||
/// создаётся собственный scope через RequestServices (как в SessionMiddleware).
|
||||
/// </remarks>
|
||||
public sealed class OperatorSessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
|
||||
|
||||
public OperatorSessionMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptionsMonitor<OperatorCookieOptions> cookieOptions)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var operatorAuthService = scope.ServiceProvider.GetRequiredService<OperatorAuthService>();
|
||||
var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (identity is not null)
|
||||
{
|
||||
context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
|
||||
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
|
||||
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
|
||||
/// в явный allowlist <see cref="SecurityOptions.AllowedOrigins"/>; несовпадение — HTTP 403
|
||||
/// <c>{"detail":"…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10):
|
||||
/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому
|
||||
/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер,
|
||||
/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD —
|
||||
/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) —
|
||||
/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за
|
||||
/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9).
|
||||
/// <para>
|
||||
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
|
||||
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OriginGuardMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
|
||||
/// </summary>
|
||||
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
|
||||
|
||||
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
|
||||
private const string ApiPathPrefix = "/api";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly HashSet<string> _allowedOrigins;
|
||||
|
||||
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
ArgumentNullException.ThrowIfNull(securityOptions);
|
||||
_next = next;
|
||||
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
|
||||
_allowedOrigins = new HashSet<string>(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
HttpRequest request = context.Request;
|
||||
if (request.Path.StartsWithSegments(ApiPathPrefix)
|
||||
&& !HttpMethods.IsGet(request.Method)
|
||||
&& !HttpMethods.IsHead(request.Method)
|
||||
&& !HttpMethods.IsOptions(request.Method)
|
||||
&& request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
|
||||
{
|
||||
string origin = originValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
// Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host).
|
||||
// origin: Значение заголовка Origin запроса.
|
||||
// request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён).
|
||||
// Возвращает: true — Origin доверенный, запрос можно пропускать.
|
||||
private bool IsTrustedOrigin(string origin, HttpRequest request)
|
||||
{
|
||||
if (_allowedOrigins.Contains(origin))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер).
|
||||
HostString host = request.Host;
|
||||
if (host.HasValue)
|
||||
{
|
||||
string selfOrigin = request.Scheme + "://" + host.Value;
|
||||
if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище
|
||||
/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и
|
||||
/// /api/operator/auth/login), "api" — на тенанта из <see cref="CurrentUser"/> либо IP анонима
|
||||
/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter).
|
||||
/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики
|
||||
/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом <c>{"detail": "…"}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется только при <see cref="RateLimitOptions.Enabled"/> (в dev/тестах middleware и политики
|
||||
/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на
|
||||
/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток
|
||||
/// telegram-service резался бы общим окном на IP.
|
||||
/// <para>
|
||||
/// Счётчики окон живут в Postgres (<see cref="IRateLimitCounterStore"/> через scoped
|
||||
/// <see cref="StoreBackedFixedWindowRateLimiter"/>) — лимиты общие для всех инстансов core. Пороги/окна
|
||||
/// не менялись: фиксированные 1 минута, PermitLimit из <see cref="RateLimitOptions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute).
|
||||
/// </summary>
|
||||
public const string AuthPolicy = "auth";
|
||||
|
||||
/// <summary>
|
||||
/// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute).
|
||||
/// </summary>
|
||||
public const string ApiPolicy = "api";
|
||||
|
||||
/// <summary>
|
||||
/// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail.
|
||||
/// </summary>
|
||||
public const string RejectedDetail = "Слишком много запросов. Повторите позже";
|
||||
|
||||
// Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard.
|
||||
private const string HttpKeyPrefix = "http:";
|
||||
|
||||
// Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики
|
||||
// (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет).
|
||||
private const string GlobalScope = "global";
|
||||
|
||||
// Префикс партиции API-политики для анонима (IP не разрешён в сессию).
|
||||
private const string IpKeyPrefix = "ip:";
|
||||
|
||||
// Префикс партиции API-политики для аутентифицированного пользователя (по тенанту).
|
||||
private const string TenantKeyPrefix = "tenant:";
|
||||
|
||||
// Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы).
|
||||
private const string UnknownClientKey = "unknown";
|
||||
|
||||
// Длина окна политик — 1 минута (имена настроек — «PerMinute»).
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов хоста.</param>
|
||||
/// <param name="options">Настройки rate limiting (секция RateLimit).</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int apiPerMinute = options.ApiPerMinute;
|
||||
services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = OnRejectedAsync;
|
||||
limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute));
|
||||
limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute));
|
||||
// Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без
|
||||
// RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC).
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
context => ApiPartition(context, GlobalScope, apiPerMinute));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Партиция политики "auth": фиксированное окно в минуту на IP клиента.
|
||||
// context: Контекст запроса (адрес клиента).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом IP.
|
||||
private static RateLimitPartition<string> AuthPartition(HttpContext context, int permitsPerMinute)
|
||||
=> StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute);
|
||||
|
||||
// Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима.
|
||||
// context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter).
|
||||
// scope: Пространство ключей (имя политики или "global" для глобального лимитера).
|
||||
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
|
||||
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
|
||||
private static RateLimitPartition<string> ApiPartition(HttpContext context, string scope, int permitsPerMinute)
|
||||
{
|
||||
string partitionKey = context.GetCurrentUser() is { } user
|
||||
? TenantKeyPrefix + user.TenantId.ToString("N")
|
||||
: ClientKey(context);
|
||||
return StorePartition(context, scope, partitionKey, permitsPerMinute);
|
||||
}
|
||||
|
||||
// Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво.
|
||||
// context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера).
|
||||
// scope: Пространство ключей (политика/global).
|
||||
// partitionKey: Ключ партиции (IP/тенант).
|
||||
// permitsPerMinute: Разрешено запросов в минуту.
|
||||
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
|
||||
private static RateLimitPartition<string> StorePartition(
|
||||
HttpContext context, string scope, string partitionKey, int permitsPerMinute)
|
||||
{
|
||||
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
|
||||
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
|
||||
context.RequestServices.GetRequiredService<IServiceScopeFactory>(),
|
||||
key,
|
||||
permitsPerMinute,
|
||||
Window,
|
||||
() => DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
// Ключ клиента: строка IP (или unknown, если адрес недоступен).
|
||||
// context: Контекст запроса.
|
||||
// Возвращает: Ключ партиции по IP.
|
||||
private static string ClientKey(HttpContext context)
|
||||
{
|
||||
string? ip = context.Connection.RemoteIpAddress?.ToString();
|
||||
return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip;
|
||||
}
|
||||
|
||||
// Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5).
|
||||
// context: Контекст отклонённого запроса.
|
||||
// cancellationToken: Токен отмены ответа.
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Deal.Api.Configuration;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||||
|
||||
namespace Deal.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет
|
||||
/// <c>HttpContext.Items["CurrentUser"]</c> + tenant-контекст запроса (ITenantContext).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют
|
||||
/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без
|
||||
/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса.
|
||||
/// <para>
|
||||
/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices.
|
||||
/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через
|
||||
/// IOptionsMonitor, чтобы подхватывать изменения конфигурации.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<CookieOptions> _cookieOptions;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public SessionMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptionsMonitor<CookieOptions> cookieOptions,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_next = next;
|
||||
_cookieOptions = cookieOptions;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст.
|
||||
/// </summary>
|
||||
/// <param name="context">Контекст запроса.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||
{
|
||||
// AuthService scoped: создаём scope на запрос через RequestServices.
|
||||
await using var scope = context.RequestServices.CreateAsyncScope();
|
||||
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
|
||||
var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted);
|
||||
if (user is not null)
|
||||
{
|
||||
context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status));
|
||||
// Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService).
|
||||
_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")));
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Контекст AsyncLocal не должен переживать запрос.
|
||||
_tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user