using Deal.Api.Http; using Deal.Api.Middleware; using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Models; using Microsoft.Extensions.Options; using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; namespace Deal.Api.Endpoints; /// /// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). /// /// /// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), /// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). /// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые /// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. /// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). /// public static class OperatorAuthEndpoints { private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; private const string OperatorAuthGroupPrefix = "/api/operator/auth"; private const string OperatorAuthOpenApiTag = "operator-auth"; /// /// Регистрирует группу /api/operator/auth: login, logout, me. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/logout", LogoutAsync); group.MapGet("/me", MeAsync); return app; } // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). private static async Task LoginAsync( LoginRequest body, OperatorAuthService operatorAuthService, AuditService auditService, IOptions cookieOptions, HttpContext context, CancellationToken ct, LoginAttemptGuard loginAttemptGuard) { string? attemptedLogin = NormalizeLogin(body.Login); // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) { return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); } var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); if (result.Login is null || result.Token is null) { // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). if (attemptedLogin is not null) { await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); await auditService.AppendAsync(new AuditRecordDto( AuditEvents.OperatorLoginFailed, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: ClientIp(context), DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); } return EndpointResults.Unauthorized(InvalidCredentialsDetail); } // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await auditService.AppendAsync(new AuditRecordDto( AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, ActorId: result.OperatorId, TenantId: null, Ip: ClientIp(context), DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); return Results.Ok(new { ok = true, login = result.Login }); } // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). private static async Task LogoutAsync( OperatorAuthService operatorAuthService, IOptions cookieOptions, HttpContext context, CancellationToken ct) { var cookieName = cookieOptions.Value.Name; var rawToken = context.Request.Cookies[cookieName]; // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). CurrentOperator? operatorIdentity = context.GetCurrentOperator(); await operatorAuthService.LogoutAsync(rawToken, ct); context.Response.Cookies.Delete(cookieName); // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. if (operatorIdentity is not null) { await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); } return Results.Ok(new { ok = true }); } // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). private static IResult MeAsync(HttpContext context) { var operatorIdentity = context.GetCurrentOperator(); if (operatorIdentity is null) { return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); } return Results.Ok(new { login = operatorIdentity.Login, ok = true }); } // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. // context: Контекст запроса. // options: Настройки куки из конфигурации (секция OperatorCookies). // rawToken: Raw-токен операторской сессии. private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken) { // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). context.Response.Cookies.Append( options.Name, rawToken, new AspNetCoreCookieOptions { HttpOnly = true, SameSite = SameSiteMode.Lax, Path = "/", MaxAge = TimeSpan.FromHours(options.Hours), Secure = options.Secure, }); } // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. // login: Логин из тела запроса. // Возвращает: Нормализованный логин или null при пустом/пробельном входе. private static string? NormalizeLogin(string? login) { string? normalized = login?.Trim().ToLowerInvariant(); return string.IsNullOrEmpty(normalized) ? null : normalized; } // IP-адрес клиента для аудита (без порта; null, если недоступен). // context: Контекст запроса. // Возвращает: Строковое представление IP или null. private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); }