Архивация списков (#13)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Нумерованная серия партиций SharePoint-списка по типу заявок: куда писать сейчас
|
||||
/// и вся серия для админки/портала.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/request-types")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RequestTypeListsController(ListPartitionService service) : ControllerBase
|
||||
{
|
||||
/// <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: если партиция добрала порог элементов, запрос ставит в очередь
|
||||
/// фоновую задачу создания следующей (см. ListPartitionService.GetCurrentAsync).
|
||||
/// </summary>
|
||||
[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) =>
|
||||
(await service.GetCurrentAsync(requestTypeId, ct)).ToActionResult();
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Extensions.Hosting.AsyncInitialization;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
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.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Infrastructure;
|
||||
@@ -17,11 +21,25 @@ const string CorsPolicyName = "Frontend";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Локальные оверрайды, не попадающие в git (см. .gitignore) — на своей машине
|
||||
// можно, например, занизить ListPartitioning:ItemCountThreshold, чтобы не
|
||||
// накапливать тысячи тестовых заявок для проверки создания партиций.
|
||||
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
|
||||
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
"logs/log-.txt",
|
||||
rollingInterval: RollingInterval.Month,
|
||||
retainedFileCountLimit: 6));
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
|
||||
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
@@ -37,6 +55,16 @@ builder.Services.AddCors(options =>
|
||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||
builder.Services.AddApprovalRouteApplication();
|
||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||
|
||||
builder.Services.AddHangfire(configuration => configuration
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(postgres =>
|
||||
postgres.UseNpgsqlConnection(builder.Configuration.GetConnectionString("Postgres"))));
|
||||
|
||||
builder.Services.AddHangfireServer();
|
||||
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||
builder.Services.AddOpenApi(options =>
|
||||
@@ -92,6 +120,16 @@ app.UseCors(CorsPolicyName);
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
@@ -12,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,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Hangfire": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
@@ -19,11 +24,23 @@
|
||||
"https://localhost:4321"
|
||||
]
|
||||
},
|
||||
"ListPartitioning": {
|
||||
"ItemCountThreshold": 4900,
|
||||
"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",
|
||||
"CertificateSettingName": "AzurePfx",
|
||||
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
|
||||
"CertificatePassword": "Rk.xDjccnfyjdktybz1",
|
||||
"NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint"
|
||||
},
|
||||
"TnsuPortal": {
|
||||
"BaseUrl": "https://api.tnsu.kz",
|
||||
|
||||
Reference in New Issue
Block a user