Files
SharepointToolbox-Web/Program.cs
T

327 lines
16 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.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.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();
// ── 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;
options.DefaultChallengeScheme = OpenIdConnectDefaults.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;
options.SaveTokens = true;
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>();
await userService.ProvisionAsync(ctx.Principal!);
};
});
}
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 ────────────────────────────────────────────────────────────────
builder.Services.Configure<AppConfiguration>(opt =>
{
opt.DataFolder = dataFolder;
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
Directory.CreateDirectory(opt.ExportsFolder);
});
// ── 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")));
// ── 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.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<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>();
var app = builder.Build();
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.UseStaticFiles();
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);
});
// ── 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();