8dfbf7c18a
The OIDC OnTokenValidated handler stored the raw principal (all id_token + userinfo claims) in the auth cookie. Encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it across …CookiesC1/C2. The chunked cookie survives the prerender GET but is dropped on the Blazor interactive WebSocket upgrade, so the circuit comes up anonymous and the page sticks on "Chargement…". SaveTokens=false alone didn't shrink it enough — the claims themselves bloat it. Replace the principal with a slim 4-claim identity (preferred_username, name, app_role, auth_provider), identical to the local-login path, so the cookie stays single + unchunked and the circuit authenticates. Also fixes a latent bug: the OIDC principal never carried app_role or auth_provider, so Entra admins got no admin nav and logout skipped the OIDC sign-out branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
449 lines
24 KiB
C#
449 lines
24 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
using Microsoft.AspNetCore.Antiforgery;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using Serilog;
|
|
using SharepointToolbox.Web.Core.Config;
|
|
using SharepointToolbox.Web.Core.Models;
|
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
|
using SharepointToolbox.Web.Infrastructure.OAuth;
|
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
|
using SharepointToolbox.Web.Services;
|
|
using SharepointToolbox.Web.Services.Audit;
|
|
using SharepointToolbox.Web.Services.Auth;
|
|
using SharepointToolbox.Web.Services.Export;
|
|
using SharepointToolbox.Web.Services.OAuth;
|
|
using SharepointToolbox.Web.Services.Reports;
|
|
using SharepointToolbox.Web.Services.Session;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// ── Serilog ───────────────────────────────────────────────────────────────────
|
|
var dataFolder = builder.Configuration["DataFolder"] ?? "/data";
|
|
Directory.CreateDirectory(dataFolder);
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
.ReadFrom.Configuration(builder.Configuration)
|
|
.WriteTo.Console()
|
|
.WriteTo.File(
|
|
Path.Combine(dataFolder, "logs", "app-.log"),
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 30)
|
|
.CreateLogger();
|
|
|
|
builder.Host.UseSerilog();
|
|
|
|
// ── Blazor / Razor Components ─────────────────────────────────────────────────
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
// ── Forwarded headers ─────────────────────────────────────────────────────────
|
|
// Behind a TLS-terminating reverse proxy the app receives plain HTTP; without this
|
|
// it would see scheme=http and build an http:// OIDC redirect_uri (which Entra
|
|
// rejects for non-localhost hosts) and set the auth cookie non-Secure. Honour
|
|
// X-Forwarded-Proto/For so the app sees the real https scheme + client IP. Proxy IP
|
|
// is unknown inside the container network, so don't restrict to known proxies.
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
options.KnownIPNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
});
|
|
|
|
// ── Data Protection ───────────────────────────────────────────────────────────
|
|
// Keys MUST persist across container recreates: they encrypt the auth cookie AND
|
|
// the app-only certs on disk (/data/appcerts). Default storage is the container's
|
|
// ephemeral home dir, so a redeploy would log everyone out and make stored certs
|
|
// undecryptable. Pin keys + app name to the data volume.
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataFolder, "dpkeys")))
|
|
.SetApplicationName("SharepointToolbox.Web");
|
|
|
|
// Localization string source — Scoped: one per circuit, with its own explicit culture.
|
|
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
|
|
|
|
// ── Authentication ────────────────────────────────────────────────────────────
|
|
if (builder.Environment.IsDevelopment())
|
|
{
|
|
// Dev: cookie-only, no OIDC. /account/login auto-signs in a hardcoded Admin.
|
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/account/login";
|
|
options.LogoutPath = "/account/logout";
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
options.SlidingExpiration = true;
|
|
});
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
// Challenge the cookie scheme (→ redirect to /account/login, the combined
|
|
// local + Microsoft page). OIDC is triggered explicitly from the "Sign in
|
|
// with Microsoft" button (/account/login/entra), never as the implicit
|
|
// challenge — otherwise logged-out hits on protected pages force OIDC and
|
|
// 404 when it is unconfigured/unreachable.
|
|
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/account/login";
|
|
options.LogoutPath = "/account/logout";
|
|
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
|
|
options.SessionStore = null;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
options.SlidingExpiration = true;
|
|
})
|
|
.AddOpenIdConnect(options =>
|
|
{
|
|
var oidc = builder.Configuration.GetSection("Oidc");
|
|
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0";
|
|
options.ClientId = oidc["ClientId"];
|
|
options.ClientSecret = oidc["ClientSecret"];
|
|
options.ResponseType = OpenIdConnectResponseType.Code;
|
|
// Do NOT persist the OIDC access/id/refresh tokens in the auth cookie. They are
|
|
// never read (SharePoint/Graph auth runs through the separate connect flow +
|
|
// app-only cert paths), and storing them bloats the cookie past ~4 KB so it gets
|
|
// chunked. The chunked cookie survives the prerender GET but is dropped on the
|
|
// WebSocket upgrade that establishes the interactive circuit → the circuit comes
|
|
// up anonymous and the app sticks on "Chargement…". Keeping the cookie small fixes it.
|
|
options.SaveTokens = false;
|
|
options.Scope.Add("openid");
|
|
options.Scope.Add("profile");
|
|
options.Scope.Add("email");
|
|
options.GetClaimsFromUserInfoEndpoint = true;
|
|
options.MapInboundClaims = false;
|
|
options.TokenValidationParameters.NameClaimType = "preferred_username";
|
|
|
|
options.Events.OnTokenValidated = async ctx =>
|
|
{
|
|
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
|
var user = await userService.ProvisionAsync(ctx.Principal!);
|
|
|
|
// The whole principal is serialized into the auth cookie. The raw OIDC principal carries
|
|
// dozens of id_token + userinfo claims (oid, tid, given/family_name, a long picture URL …);
|
|
// encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it into …CookiesC1/C2.
|
|
// The chunked cookie survives the prerender GET but is dropped on the Blazor WebSocket upgrade
|
|
// → the interactive circuit comes up anonymous → page sticks on "Chargement…". Replace it with
|
|
// a slim principal holding only the claims the app reads — identical to the local-login path —
|
|
// so the cookie stays small (single, unchunked) and the circuit authenticates. This also adds
|
|
// the app_role claim (role-based authz) and auth_provider (logout's OIDC sign-out branch),
|
|
// which the fat OIDC principal never had.
|
|
var identity = new ClaimsIdentity(
|
|
new Claim[]
|
|
{
|
|
new("preferred_username", user.Email),
|
|
new("name", user.DisplayName),
|
|
new("app_role", user.Role.ToString()),
|
|
new("auth_provider", nameof(AuthProvider.Entra)),
|
|
},
|
|
ctx.Principal!.Identity!.AuthenticationType,
|
|
"preferred_username",
|
|
"app_role");
|
|
ctx.Principal = new ClaimsPrincipal(identity);
|
|
};
|
|
});
|
|
}
|
|
|
|
builder.Services.AddAuthorization();
|
|
|
|
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
|
|
builder.Services.AddMemoryCache();
|
|
builder.Services.AddHttpClient("oauth");
|
|
|
|
// ── ClientConnect options ─────────────────────────────────────────────────────
|
|
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
|
|
|
|
// ── App config ────────────────────────────────────────────────────────────────
|
|
var certsFolder = Path.Combine(dataFolder, "appcerts");
|
|
builder.Services.Configure<AppConfiguration>(opt =>
|
|
{
|
|
opt.DataFolder = dataFolder;
|
|
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
|
|
opt.CertsFolder = certsFolder;
|
|
Directory.CreateDirectory(opt.ExportsFolder);
|
|
Directory.CreateDirectory(opt.CertsFolder);
|
|
});
|
|
|
|
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
|
|
builder.Services.AddSingleton(new ProfileRepository(Path.Combine(dataFolder, "profiles.json")));
|
|
builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "settings.json")));
|
|
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
|
|
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
|
|
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
|
|
builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json")));
|
|
builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json")));
|
|
|
|
// ── App-only (unattended) auth for scheduled reports ──────────────────────────
|
|
builder.Services.AddSingleton<IAppOnlyCertStore>(sp =>
|
|
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
|
|
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
|
|
|
|
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
|
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
|
|
builder.Services.AddSingleton<IUserService, UserService>();
|
|
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
|
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
|
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
|
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
|
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
|
|
builder.Services.AddScoped<GraphClientFactory>();
|
|
|
|
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
|
|
builder.Services.AddScoped<IUserSessionService, UserSessionService>();
|
|
builder.Services.AddScoped<IUserContextAccessor, UserContextAccessor>();
|
|
builder.Services.AddScoped<ISessionCredentialStore, SessionCredentialStore>();
|
|
|
|
// ── Audit (Scoped — reads user context from circuit) ─────────────────────────
|
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
|
|
|
// ── Business services (Scoped — each user circuit gets its own instances) ─────
|
|
builder.Services.AddScoped<ISessionManager, SessionManager>();
|
|
builder.Services.AddScoped<IPermissionsService, PermissionsService>();
|
|
builder.Services.AddScoped<IStorageService, StorageService>();
|
|
builder.Services.AddScoped<ISearchService, SearchService>();
|
|
builder.Services.AddScoped<IDuplicatesService, DuplicatesService>();
|
|
builder.Services.AddScoped<IFileTransferService, FileTransferService>();
|
|
builder.Services.AddScoped<IBulkMemberService, BulkMemberService>();
|
|
builder.Services.AddScoped<IBulkSiteService, BulkSiteService>();
|
|
builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>();
|
|
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
|
|
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
|
builder.Services.AddScoped<ISiteDiscoveryService, SiteDiscoveryService>();
|
|
builder.Services.AddScoped<ILibraryDiscoveryService, LibraryDiscoveryService>();
|
|
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
|
|
builder.Services.AddScoped<ITemplateService, TemplateService>();
|
|
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
|
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
|
|
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
|
|
builder.Services.AddScoped<IElevationCoordinator, ElevationCoordinator>();
|
|
|
|
// ── Export services (Scoped) ──────────────────────────────────────────────────
|
|
builder.Services.AddScoped<CsvExportService>();
|
|
builder.Services.AddScoped<HtmlExportService>();
|
|
builder.Services.AddScoped<StorageCsvExportService>();
|
|
builder.Services.AddScoped<StorageHtmlExportService>();
|
|
builder.Services.AddScoped<SearchCsvExportService>();
|
|
builder.Services.AddScoped<SearchHtmlExportService>();
|
|
builder.Services.AddScoped<DuplicatesCsvExportService>();
|
|
builder.Services.AddScoped<DuplicatesHtmlExportService>();
|
|
builder.Services.AddScoped<UserAccessCsvExportService>();
|
|
builder.Services.AddScoped<UserAccessHtmlExportService>();
|
|
builder.Services.AddScoped<VersionCleanupHtmlExportService>();
|
|
builder.Services.AddScoped<BulkResultCsvExportService>();
|
|
builder.Services.AddScoped<WebExportService>();
|
|
|
|
// ── Scheduled reports (background generation) ─────────────────────────────────
|
|
builder.Services.AddSingleton<ScheduledRunCoordinator>();
|
|
builder.Services.AddScoped<IReportMailService, ReportMailService>();
|
|
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
|
|
builder.Services.AddHostedService<ScheduledReportHostedService>();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
|
|
app.UseForwardedHeaders();
|
|
|
|
// ── First-run bootstrap ───────────────────────────────────────────────────────
|
|
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
|
|
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
|
|
// still sign in via the local email/password form. Only fires while the user store is
|
|
// empty; set Bootstrap__AdminEmail and Bootstrap__AdminPassword to enable.
|
|
{
|
|
var bootEmail = app.Configuration["Bootstrap:AdminEmail"];
|
|
var bootPass = app.Configuration["Bootstrap:AdminPassword"];
|
|
if (!string.IsNullOrWhiteSpace(bootEmail) && !string.IsNullOrWhiteSpace(bootPass))
|
|
{
|
|
var users = app.Services.GetRequiredService<IUserService>();
|
|
// Seed if this email has no account yet — covers both an empty store and a store
|
|
// that already holds an Entra-provisioned user from a failed sign-in attempt.
|
|
if (await users.GetByEmailAsync(bootEmail) is null)
|
|
{
|
|
await users.CreateLocalUserAsync(bootEmail, "Administrator", UserRole.Admin, bootPass);
|
|
Log.Information("Bootstrap: created local admin {Email}.", bootEmail);
|
|
}
|
|
else
|
|
{
|
|
Log.Information("Bootstrap: local admin {Email} already present; skipping seed.", bootEmail);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
app.UseHsts();
|
|
}
|
|
|
|
// Re-execute unmatched (404) requests into the branded not-found page
|
|
app.UseStatusCodePagesWithReExecute("/not-found");
|
|
|
|
app.MapStaticAssets();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
|
var isDev = app.Environment.IsDevelopment();
|
|
|
|
// Combined login page. Dev: local form + "Quick sign in as Dev Admin" (no OIDC scheme registered).
|
|
// Prod: local form + "Sign in with Microsoft".
|
|
app.MapGet("/account/login", (HttpContext ctx, IAntiforgery antiforgery, string? returnUrl, bool? error) =>
|
|
{
|
|
var html = LoginPageRenderer.Build(
|
|
ctx, antiforgery, returnUrl, error == true,
|
|
showEntra: !isDev,
|
|
showDevButton: isDev);
|
|
return Results.Content(html, "text/html");
|
|
});
|
|
|
|
if (isDev)
|
|
{
|
|
// Dev shortcut: provision + sign in the hardcoded Dev Admin (first run = Admin).
|
|
app.MapGet("/account/login/dev", async (HttpContext ctx, string? returnUrl, IUserService userService) =>
|
|
{
|
|
const string devEmail = "dev@local.test";
|
|
const string devName = "Dev Admin";
|
|
|
|
var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
|
|
new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) },
|
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
|
var user = await userService.ProvisionAsync(provisionPrincipal);
|
|
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
|
new Claim[] {
|
|
new("preferred_username", devEmail),
|
|
new("name", devName),
|
|
new("app_role", user.Role.ToString()),
|
|
},
|
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
|
|
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
|
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
// Microsoft / Entra OIDC challenge (the "Sign in with Microsoft" button).
|
|
app.MapGet("/account/login/entra", async (HttpContext ctx, string? returnUrl) =>
|
|
{
|
|
var props = new AuthenticationProperties
|
|
{
|
|
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
|
|
};
|
|
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
|
});
|
|
}
|
|
|
|
// Local password sign-in — available in every environment.
|
|
app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antiforgery, IUserService userService) =>
|
|
{
|
|
try { await antiforgery.ValidateRequestAsync(ctx); }
|
|
catch (AntiforgeryValidationException) { return Results.BadRequest(); }
|
|
|
|
var form = await ctx.Request.ReadFormAsync();
|
|
var email = form["email"].ToString();
|
|
var password = form["password"].ToString();
|
|
var returnUrl = form["returnUrl"].ToString();
|
|
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
|
|
|
|
var user = await userService.ValidateLocalCredentialsAsync(email, password);
|
|
if (user is null)
|
|
return Results.Redirect($"/account/login?error=true&returnUrl={Uri.EscapeDataString(safeReturn)}");
|
|
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
|
new Claim[]
|
|
{
|
|
new("preferred_username", user.Email),
|
|
new("name", user.DisplayName),
|
|
new("app_role", user.Role.ToString()),
|
|
new("auth_provider", nameof(AuthProvider.Local)),
|
|
},
|
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
|
|
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
|
return Results.Redirect(safeReturn);
|
|
});
|
|
|
|
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
|
{
|
|
// Local/dev accounts only hold the cookie; Entra accounts also have an OIDC session to end.
|
|
var isLocal = ctx.User.HasClaim("auth_provider", nameof(AuthProvider.Local));
|
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
if (!isDev && !isLocal && ctx.User.Identity?.IsAuthenticated == true)
|
|
await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
|
|
new AuthenticationProperties { RedirectUri = "/" });
|
|
else
|
|
ctx.Response.Redirect("/");
|
|
});
|
|
|
|
// ── OAuth2 connect endpoints ──────────────────────────────────────────────────
|
|
app.MapOAuthEndpoints();
|
|
|
|
// ── File download endpoint ────────────────────────────────────────────────────
|
|
app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppConfiguration> opts, HttpContext ctx) =>
|
|
{
|
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
|
var path = Path.Combine(opts.Value.ExportsFolder, Path.GetFileName(fileName));
|
|
if (!File.Exists(path)) return Results.NotFound();
|
|
var bytes = await File.ReadAllBytesAsync(path);
|
|
var ct = fileName.EndsWith(".csv") ? "text/csv" : "text/html";
|
|
return Results.File(bytes, ct, fileName);
|
|
});
|
|
|
|
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
|
|
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
|
|
{
|
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
|
var report = await index.GetAsync(id);
|
|
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
|
|
return Results.NotFound();
|
|
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
|
|
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
|
|
if (!File.Exists(path)) return Results.NotFound();
|
|
var bytes = await File.ReadAllBytesAsync(path);
|
|
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
|
|
return Results.File(bytes, mime, report.FileName);
|
|
});
|
|
|
|
// ── Audit CSV download ────────────────────────────────────────────────────────
|
|
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
|
|
{
|
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
|
// Role check via the app-role claim set during OIDC provisioning
|
|
var rolesClaim = ctx.User.FindFirst("app_role")?.Value;
|
|
if (rolesClaim != nameof(UserRole.Admin)) return Results.Forbid();
|
|
var entries = await auditRepo.LoadAllAsync();
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
|
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
|
{
|
|
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
|
|
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
|
sb.AppendLine(string.Join(",",
|
|
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
|
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
|
|
Esc(e.Action), Esc(e.ClientName),
|
|
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
|
|
}
|
|
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
|
|
});
|
|
|
|
app.MapRazorComponents<SharepointToolbox.Web.Components.App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
app.Run();
|