using Deal.Api.Extensions; using Deal.Api.Middleware; using Deal.Api.Models; using Deal.Api.Services; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; 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-эндпоинты аутентификации оператора /// public static class OperatorAuthEndpoints { private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; private const string OperatorAuthGroupPrefix = "/api/operator/auth"; private const string OperatorAuthOpenApiTag = "operator-auth"; /// /// Регистрирует группу /api/operator/auth /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); group.MapPost("/logout", LogoutAsync); group.MapGet("/me", MeAsync); return app; } private static async Task LoginAsync( LoginRequest body, OperatorAuthService operatorAuthService, AuditService auditService, IOptions cookieOptions, HttpContext context, CancellationToken ct, LoginAttemptGuard loginAttemptGuard) { string? attemptedLogin = NormalizeLogin(body.Login); 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) { 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); } 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); if (operatorIdentity is not null) { await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); } return Results.Ok(new { ok = true }); } 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(); }