Пункт 2 + 3 пакета SMTP-настроек.
Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
одного письма; EmailNotConfiguredException — для контроллеров чтобы
ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
· регистрируется Singleton, на каждой отправке открывает scope для
свежего AppDbContext (конфиг перечитывается без рестарта);
· читает PlatformSettings из БД, расшифровывает пароль через
IDataProtector("foodmarket.smtp");
· поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
оба false → открытое соединение (для dev/MailHog);
· бросает EmailNotConfiguredException если host или from-email пусты,
или если расшифровка пароля падает (ключ DataProtection ротировался).
API:
- PlatformSettingsController:
GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
(только has-password флаг + updatedAt).
PUT — принимает Reason (≥10) + все поля + опциональный
NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
Пароль шифруется через DataProtection при записи. Audit-log.
POST /test-send — реальная отправка через текущие настройки;
ловит EmailNotConfiguredException → 400, остальные → 500
с message (SuperAdmin-only, diagnostic-info нужна).
DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).
Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
был через OpenIddict, но Infrastructure нужен явный reference).
266 lines
12 KiB
C#
266 lines
12 KiB
C#
using System.Security.Claims;
|
||
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<string[]>()
|
||
?? ["http://localhost:5173"];
|
||
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||
}));
|
||
|
||
builder.Services.AddHttpContextAccessor();
|
||
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
||
|
||
builder.Services.AddDbContext<AppDbContext>(opts =>
|
||
{
|
||
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
|
||
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
|
||
opts.UseOpenIddict();
|
||
});
|
||
|
||
builder.Services.AddIdentity<User, Role>(opts =>
|
||
{
|
||
opts.Password.RequireDigit = true;
|
||
opts.Password.RequiredLength = 8;
|
||
opts.Password.RequireNonAlphanumeric = false;
|
||
opts.Password.RequireUppercase = false;
|
||
opts.User.RequireUniqueEmail = true;
|
||
})
|
||
.AddEntityFrameworkStores<AppDbContext>()
|
||
.AddDefaultTokenProviders();
|
||
|
||
builder.Services.Configure<IdentityOptions>(opts =>
|
||
{
|
||
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
|
||
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
|
||
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
|
||
});
|
||
|
||
builder.Services.AddOpenIddict()
|
||
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
|
||
.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"));
|
||
// Явный 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"))));
|
||
});
|
||
|
||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||
|
||
// Email-отправка через MailKit. Singleton — внутри открывает scope для
|
||
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
|
||
// на каждой отправке без рестарта приложения.
|
||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||
foodmarket.Infrastructure.Email.MailKitEmailSender>();
|
||
builder.Services.AddDataProtection();
|
||
builder.Services.AddControllers(o =>
|
||
{
|
||
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
|
||
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||
});
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen();
|
||
|
||
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
||
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
|
||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||
{
|
||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||
});
|
||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||
|
||
// Inventory
|
||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||
|
||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||
builder.Services.AddHostedService<DevDataSeeder>();
|
||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||
// 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<DemoCatalogSeeder>();
|
||
|
||
var app = builder.Build();
|
||
|
||
app.UseSerilogRequestLogging();
|
||
app.UseCors(CorsPolicy);
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
|
||
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
|
||
|
||
// Статика товарных изображений: физически /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();
|
||
|
||
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<AppDbContext>();
|
||
db.Database.Migrate();
|
||
}
|
||
|
||
app.Run();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||
}
|
||
finally
|
||
{
|
||
Log.CloseAndFlush();
|
||
}
|