SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-эндпоинты аутентификации оператора
|
||||
/// </summary>
|
||||
public static class OperatorAuthEndpoints
|
||||
{
|
||||
private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора";
|
||||
private const string OperatorAuthGroupPrefix = "/api/operator/auth";
|
||||
private const string OperatorAuthOpenApiTag = "operator-auth";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/auth
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
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<IResult> LoginAsync(
|
||||
LoginRequest body,
|
||||
OperatorAuthService operatorAuthService,
|
||||
AuditService auditService,
|
||||
IOptions<OperatorCookieOptions> 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<IResult> LogoutAsync(
|
||||
OperatorAuthService operatorAuthService,
|
||||
IOptions<OperatorCookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string cookieName = cookieOptions.Value.Name;
|
||||
string? 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();
|
||||
}
|
||||
Reference in New Issue
Block a user