разные правки по беку

This commit is contained in:
gorbenko.pavel
2026-08-17 15:33:54 +05:00
parent 2a7130c37f
commit b92897254d
23 changed files with 475 additions and 315 deletions
@@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;
using Hangfire.Dashboard;
using Microsoft.Net.Http.Headers;
namespace Tnsu.ApprovalRoute.Api.Auth;
/// <summary>
/// Простая Basic Auth для дашборда Hangfire — единственный логин/пароль из
/// конфигурации (Hangfire:Dashboard), а не полноценная схема аутентификации: сам
/// дашборд не участвует в остальной авторизации API (это отдельная страница,
/// открываемая прямо в браузере, не по JWT).
///
/// Пустые логин/пароль (не заданы в конфигурации) означают «дашборд закрыт для
/// всех» — не «открыт для всех»: пустая строка в конфиге не должна случайно снять
/// защиту.
/// </summary>
public sealed class HangfireDashboardBasicAuthFilter(string username, string password) : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) &&
TryGetBasicAuthCredentials(httpContext.Request.Headers.Authorization, out var user, out var pass) &&
FixedTimeEquals(user, username) && FixedTimeEquals(pass, password))
{
return true;
}
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = "Basic realm=\"Hangfire\"";
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return false;
}
private static bool TryGetBasicAuthCredentials(
string? authorizationHeader, out string username, out string password)
{
username = string.Empty;
password = string.Empty;
if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
return false;
try
{
var decoded = Encoding.UTF8.GetString(
Convert.FromBase64String(authorizationHeader["Basic ".Length..].Trim()));
var separatorIndex = decoded.IndexOf(':');
if (separatorIndex < 0) return false;
username = decoded[..separatorIndex];
password = decoded[(separatorIndex + 1)..];
return true;
}
catch (FormatException)
{
// Битый base64 в заголовке — не наша забота, просто не авторизуем.
return false;
}
}
// Сравнение за постоянное время: длина логина/пароля в Basic Auth и так видна
// по заголовку, но посимвольное сравнение через == утекало бы ЕЩЁ и через
// тайминг совпадения символов — дешёво закрыть и это.
private static bool FixedTimeEquals(string a, string b) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
}
@@ -1,35 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary>
/// Нумерованная серия SharePoint-списков по типу заявок: куда писать сейчас и вся
/// серия для админки.
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
/// и вся серия для админки/портала.
/// </summary>
[ApiController]
[Authorize]
[Route("api/request-types/{requestTypeId:int}/lists")]
[Route("api/request-types")]
[Produces("application/json")]
public sealed class RequestTypeListsController(RequestTypeListService service) : ControllerBase
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
{
/// <summary>Вся серия списков типа заявок (архив + текущий), от первого к последнему.</summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListDto>>), StatusCodes.Status200OK)]
/// <summary>
/// Серии партиций ВСЕХ типов заявок разом — для портала: чтобы искать по
/// заявкам сразу во всех партициях (архив + текущая) любого типа, не нужно
/// запрашивать серию по каждому типу отдельно.
/// </summary>
[HttpGet("lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeListSeriesDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListAll(CancellationToken ct) =>
(await service.ListAllAsync(ct)).ToActionResult();
/// <summary>Вся серия партиций одного типа заявок (архив + текущая), от первой к последней.</summary>
[HttpGet("{requestTypeId:int}/lists")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestListPartitionDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> List(int requestTypeId, CancellationToken ct) =>
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
/// <summary>
/// Список, в который нужно писать новые заявки этого типа.
/// Партиция, в которую нужно писать новые заявки этого типа.
///
/// Не чистый GET: если список добрал порог элементов, запрос ставит в очередь
/// фоновую задачу создания следующего (см. RequestTypeListService.GetCurrentAsync).
/// Не чистый GET: если партиция добрала порог элементов, запрос ставит в очередь
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
/// </summary>
[HttpGet("current")]
[ProducesResponseType(typeof(ApiResponse<CurrentRequestTypeListDto>), StatusCodes.Status200OK)]
[HttpGet("{requestTypeId:int}/lists/current")]
[ProducesResponseType(typeof(ApiResponse<CurrentListPartitionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(int requestTypeId, CancellationToken ct) =>
+33 -3
View File
@@ -8,11 +8,12 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using Serilog;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypeLists;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
@@ -20,11 +21,27 @@ const string CorsPolicyName = "Frontend";
var builder = WebApplication.CreateBuilder(args);
// Заменяет собой встроенных провайдеров логирования: все ILogger<T> в приложении
// (включая наши логи в ListPartitioning) идут через Serilog в оба синка ниже.
// ReadFrom.Configuration — чтобы Logging:LogLevel в appsettings продолжал работать
// как обычно, без второй, серилоговской системы настройки уровней рядом с ним.
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
// Один файл на календарный месяц (rollOnFileSizeLimit не включаем — ротация
// только по времени). retainedFileCountLimit: null — логи не подчищаются
// автоматически, ротацией места на диске занимается инфраструктура хоста.
.WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Month,
retainedFileCountLimit: null));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
@@ -40,7 +57,7 @@ builder.Services.AddCors(options =>
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure<ListProvisioningOptions>(builder.Configuration.GetSection("ListProvisioning"));
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
// Hangfire на том же Postgres, что и остальные данные. Свои таблицы он создаёт сам
// (схема hangfire) при старте — отдельной EF-миграции для них не нужно.
@@ -109,6 +126,19 @@ app.UseCors(CorsPolicyName);
app.UseAuthentication();
app.UseAuthorization();
// Свой Basic Auth, а не схемы из AuthSetup: дашборд Hangfire открывается прямо в
// браузере без похода за токеном Entra, ему нужна отдельная, простая проверка.
// Логин/пароль — в конфигурации (Hangfire:Dashboard), см. appsettings.json.
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization =
[
new HangfireDashboardBasicAuthFilter(
app.Configuration["Hangfire:Dashboard:Username"] ?? string.Empty,
app.Configuration["Hangfire:Dashboard:Password"] ?? string.Empty)
]
});
app.MapControllers();
await app.InitAndRunAsync();
@@ -14,5 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
</Project>
@@ -1,8 +1,10 @@
{
"Logging": {
"LogLevel": {
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
},
"ConnectionStrings": {
@@ -19,11 +21,17 @@
"https://localhost:4321"
]
},
"ListProvisioning": {
"ListPartitioning": {
"ItemCountThreshold": 4500,
"StaleLockAfter": "00:30:00",
"CheckThresholdOnRead": true
},
"Hangfire": {
"Dashboard": {
"Username": "admin",
"Password": "RzaT2krczbRpE36QAucdCXR"
}
},
"SharePoint": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",