Удаление целых //-блоков со ссылками (Task/Ruling/этап/ТЗ/§/ дизайн-док/api-map/python/прототип) вместо построчного вырезания — без обрывков фраз; снят боилерплейт <param>/<returns>; то же в .proto.
158 lines
7.1 KiB
C#
158 lines
7.1 KiB
C#
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)
|
|
{
|
|
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();
|
|
}
|