using System.Security.Claims; using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Seed; using foodmarket.Application.Common.Tenancy; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using OpenIddict.Validation.AspNetCore; using Serilog; using static OpenIddict.Abstractions.OpenIddictConstants; Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateBootstrapLogger(); try { var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog((ctx, services, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services)); const string CorsPolicy = "food-market-cors"; builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p => { var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? ["http://localhost:5173"]; p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); })); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); // ClaimsTransformation для SuperAdmin override: добавляет роли Admin/ // Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)] // не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer. builder.Services.AddScoped(); builder.Services.AddDbContext(opts => { opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)); opts.UseOpenIddict(); }); builder.Services.AddIdentity(opts => { opts.Password.RequireDigit = true; opts.Password.RequiredLength = 8; opts.Password.RequireNonAlphanumeric = false; opts.Password.RequireUppercase = false; opts.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); builder.Services.Configure(opts => { opts.ClaimsIdentity.UserNameClaimType = Claims.Name; opts.ClaimsIdentity.UserIdClaimType = Claims.Subject; opts.ClaimsIdentity.RoleClaimType = Claims.Role; }); builder.Services.AddOpenIddict() .AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext()) .AddServer(opts => { opts.SetTokenEndpointUris("/connect/token"); opts.AllowPasswordFlow().AllowRefreshTokenFlow(); opts.AcceptAnonymousClients(); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); // Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml. // Survives API restarts so issued tokens remain valid across rebuilds. // Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates. var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml"); var rsa = System.Security.Cryptography.RSA.Create(2048); if (File.Exists(keyPath)) { rsa.FromXmlString(File.ReadAllText(keyPath)); } else { Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!); File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true)); } var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" }; opts.AddEncryptionKey(devKey); opts.AddSigningKey(devKey); if (builder.Environment.IsDevelopment()) { opts.DisableAccessTokenEncryption(); } opts.UseAspNetCore() .EnableTokenEndpointPassthrough() .DisableTransportSecurityRequirement(); opts.SetAccessTokenLifetime(TimeSpan.Parse( builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00")); opts.SetRefreshTokenLifetime(TimeSpan.Parse( builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00")); // Rolling refresh включён по умолчанию: каждый refresh гасит старый // токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт // 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё // принимается (для гонок/ретраев). Для розничной админки это дыра: // утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно — // ротация инвалидирует старый refresh немедленно. opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero); // Явный Issuer = публичный URL админки. Без него OpenIddict // вычисляет issuer из текущего HTTP-запроса, что ломается за // nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём // из конфигурации (OpenIddict:Issuer / docker compose env). if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer) { opts.SetIssuer(new Uri(issuer)); } }) .AddValidation(opts => { opts.UseLocalServer(); opts.UseAspNetCore(); }); // Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's // cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls. builder.Services.AddAuthentication(options => { options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; }); builder.Services.AddAuthorization(opts => { // Check the "role" claim explicitly — robust against role-claim-type mismatches between // OpenIddict validation identity and the default ClaimTypes.Role uri. opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); }); // Permission-based авторизация: [RequiresPermission("...")] → policy perm:* → // PermissionRequirement → проверка флага RolePermissions роли сотрудника. builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). builder.Services.AddAuthRateLimiting(); // Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены). // Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал. builder.Services.AddHealthChecks() .AddCheck( "database", tags: ["ready"]); // Email-отправка через MailKit. Singleton — внутри открывает scope для // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // на каждой отправке без рестарта приложения. builder.Services.AddSingleton(); builder.Services.AddDataProtection(); builder.Services.AddControllers(o => { // Глобальный action filter — пишет audit-log при успешных мутациях // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). o.Filters.AddService(); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge. // BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой // api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных // тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient). builder.Services.AddHttpClient(http => { var baseUrl = builder.Configuration["MoySklad:BaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) http.BaseAddress = new Uri(baseUrl); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, }); builder.Services.AddScoped(); builder.Services.AddSingleton(); // Inventory builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); // DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // Keep the file as reference for anyone starting without MoySklad access — // just re-register here to turn demo data back on. // builder.Services.AddHostedService(); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseCors(CorsPolicy); // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя // до проверки credential'ов в БД. app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*. app.UseMiddleware(); // Статика товарных изображений: физически /app/uploads (volume в compose), // публичный URL /uploads/... — раздаются public, без auth. var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads"); System.IO.Directory.CreateDirectory(uploadsDir); app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir), RequestPath = "/uploads", }); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.MapControllers(); // Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false // => ни один чек не запускается). Используется для рестарта зависшего контейнера. app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = _ => false, ResponseWriter = WriteHealthResponse, }); // Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово. app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = WriteHealthResponse, }); // Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда). app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/api/_debug/whoami", (HttpContext ctx) => { var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity; return Results.Ok(new { isAuthenticated = ctx.User.Identity?.IsAuthenticated, authType = ctx.User.Identity?.AuthenticationType, nameClaimType = identity?.NameClaimType, roleClaimType = identity?.RoleClaimType, isInRoleAdmin = ctx.User.IsInRole("Admin"), hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"), claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }), }); }).RequireAuthorization(); app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) => { var user = ctx.User; var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList(); var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value; var subRaw = user.FindFirst(Claims.Subject)?.Value; // Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер // находится: orphan AppUser без живой org или без активного Employee // получает /no-organization fallback вместо белого экрана на /dashboard. bool hasLiveOrg = false; bool hasActiveEmployee = false; if (Guid.TryParse(orgIdRaw, out var orgId)) { hasLiveOrg = await db.Organizations.IgnoreQueryFilters() .AnyAsync(o => o.Id == orgId && !o.IsArchived); if (Guid.TryParse(subRaw, out var sub)) { hasActiveEmployee = await db.Employees.IgnoreQueryFilters() .AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive); } } return Results.Ok(new { sub = subRaw, name = user.Identity?.Name, email = user.FindFirst(Claims.Email)?.Value, roles, orgId = orgIdRaw, hasLiveOrg, hasActiveEmployee, }); }).RequireAuthorization(); // Apply migrations on every startup (idempotent). Without this, fresh // stage/prod deploys land on an empty DB and OpenIddict seeders fail. using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); } finally { Log.CloseAndFlush(); } // JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию // неудобен для мониторинга; отдаём структурировано. static Task WriteHealthResponse(HttpContext context, Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report) { context.Response.ContentType = "application/json"; return context.Response.WriteAsJsonAsync(new { status = report.Status.ToString(), totalDurationMs = report.TotalDuration.TotalMilliseconds, checks = report.Entries.Select(e => new { name = e.Key, status = e.Value.Status.ToString(), description = e.Value.Description, }), }); }