using System.Globalization; using System.Security.Claims; using NLog.Web; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection; using Couchbase.Lite; using WorkTracker.Components; using WorkTracker.Configuration; using WorkTracker.Services.Auth; using WorkTracker.Services.Festivities; using WorkTracker.Services.Exports; using WorkTracker.Services.Settings; using WorkTracker.Services.Storage; using WorkTracker.Services.WorkDays; var builder = WebApplication.CreateBuilder(args); var runningInContainer = string.Equals( Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase) || File.Exists("/.dockerenv"); if (!runningInContainer) { builder.WebHost.UseStaticWebAssets(); } builder.Logging.ClearProviders(); builder.Host.UseNLog(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); builder.Services.Configure(builder.Configuration.GetSection(AppAuthOptions.SectionName)); var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get() ?? new AppAuthOptions(); var authenticationEnabled = appAuthOptions.Enabled; builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorization(); if (authenticationEnabled) { builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/login"; options.LogoutPath = "/logout"; options.AccessDeniedPath = "/login"; options.SlidingExpiration = true; options.ExpireTimeSpan = TimeSpan.FromDays(14); }); } else { builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName) .AddScheme( DefaultAdminAuthenticationHandler.SchemeName, static _ => { }); } builder.Services.AddLocalization(); builder.Services.Configure(builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(SingleUserOptions.SectionName)); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); var app = builder.Build(); var italianCulture = new CultureInfo("it-IT"); CultureInfo.DefaultThreadCurrentCulture = italianCulture; CultureInfo.DefaultThreadCurrentUICulture = italianCulture; var localizationOptions = new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture(italianCulture), SupportedCultures = [italianCulture], SupportedUICultures = [italianCulture] }; // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } var useHttpsRedirection = app.Configuration.GetValue("UseHttpsRedirection", !app.Environment.IsDevelopment()); if (useHttpsRedirection) { app.UseHttpsRedirection(); } app.UseStaticFiles(); app.UseRequestLocalization(localizationOptions); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); static string GetSafeReturnUrl(string? returnUrl) { return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) ? "/" : returnUrl; } app.MapPost("/api/login", async (HttpContext context) => { var form = await context.Request.ReadFormAsync(); var returnUrl = form["returnUrl"].ToString(); var safeReturnUrl = GetSafeReturnUrl(returnUrl); if (!authenticationEnabled) { context.Response.Redirect(safeReturnUrl); return; } var authService = context.RequestServices.GetRequiredService(); var logger = context.RequestServices.GetRequiredService().CreateLogger("Auth.Login"); var username = form["username"].ToString(); var password = form["password"].ToString(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { context.Response.Redirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}"); return; } var user = await authService.ValidateCredentialsAsync(username, password, context.RequestAborted); if (user is null) { logger.LogWarning("Login failed for username {Username}", username); context.Response.Redirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}"); return; } var claims = new List { new(ClaimTypes.NameIdentifier, user.Id), new(ClaimTypes.Name, user.Username) }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); // If the seeded or existing user must change password, redirect to change-password flow if (user.MustChangePassword) { context.Response.Redirect("/change-password"); return; } context.Response.Redirect(safeReturnUrl); return; }).DisableAntiforgery(); app.MapPost("/api/logout", async (HttpContext context) => { if (!authenticationEnabled) { context.Response.Redirect("/"); return; } await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); context.Response.Redirect("/login"); return; }).DisableAntiforgery(); app.MapPost("/api/change-password", async (HttpContext context) => { if (!authenticationEnabled) { context.Response.Redirect("/"); return; } var authService = context.RequestServices.GetRequiredService(); if (!context.User?.Identity?.IsAuthenticated ?? true) { await context.ChallengeAsync(); return; } var form = await context.Request.ReadFormAsync(); var newPassword = form["newPassword"].ToString(); var confirm = form["confirmPassword"].ToString(); if (string.IsNullOrWhiteSpace(newPassword) || string.IsNullOrWhiteSpace(confirm) || newPassword != confirm) { context.Response.Redirect("/change-password?error=Passwords%20do%20not%20match"); return; } var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrWhiteSpace(userId)) { await context.ChallengeAsync(); return; } var changed = await authService.ChangePasswordAsync(userId, newPassword, context.RequestAborted); if (!changed) { context.Response.Redirect("/change-password?error=Unable%20to%20change%20password"); return; } context.Response.Redirect("/"); return; }).DisableAntiforgery(); app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions authOptions, CouchbaseLiteDatabaseProvider databaseProvider) => { return Results.Json(new { status = "Healthy", authenticationEnabled = authOptions.Value.Enabled, currentUser = context.User.Identity?.Name, environment = app.Environment.EnvironmentName, storage = new { appSettingsReady = databaseProvider.AppSettings is not null, usersReady = databaseProvider.Users is not null, workDaysReady = databaseProvider.WorkDays is not null }, timestampUtc = DateTimeOffset.UtcNow }); }); app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async ( int year, int month, bool includePreview, IMonthlyTimesheetExcelExportService exportService, CancellationToken cancellationToken) => { if (month is < 1 or > 12) { return Results.BadRequest("Month must be between 1 and 12."); } var file = await exportService.ExportAsync(year, month, includePreview, cancellationToken); return Results.File(file.Content, file.ContentType, file.FileName); }).RequireAuthorization(); // Development-only endpoint to reset the seeded Admin password (protected by secret in URL) if (app.Environment.IsDevelopment()) { app.MapGet("/debug/reset-admin/{secret}", (HttpContext context, string secret) => { const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3"; if (!string.Equals(secret, expected, StringComparison.Ordinal)) { return Results.StatusCode(403); } var provider = context.RequestServices.GetRequiredService(); var opts = context.RequestServices.GetRequiredService>(); var username = opts.Value.Username.Trim(); var id = username.Trim().ToUpperInvariant(); // Hash the configured seed password var passwordHasher = new Microsoft.AspNetCore.Identity.PasswordHasher(); var tempUser = new WorkTracker.Services.Auth.AuthUser { Id = id, Username = username, UsernameNormalized = id }; var newHash = passwordHasher.HashPassword(tempUser, opts.Value.Password); var doc = provider.Users.GetDocument(id); if (doc is null) { var mutable = new MutableDocument(id); mutable.SetString("username", username); mutable.SetString("usernameNormalized", id); mutable.SetString("passwordHash", newHash); mutable.SetBoolean("mustChangePassword", true); provider.Users.Save(mutable); } else { var mutable = doc.ToMutable(); mutable.SetString("passwordHash", newHash); mutable.SetBoolean("mustChangePassword", true); provider.Users.Save(mutable); } return Results.Json(new { ok = true, id, username }); }); app.MapGet("/debug/users/{secret}", (HttpContext context, string secret) => { const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3"; if (!string.Equals(secret, expected, StringComparison.Ordinal)) { return Results.StatusCode(403); } var provider = context.RequestServices.GetRequiredService(); var opts = context.RequestServices.GetRequiredService>(); var username = opts.Value.Username.Trim(); var id = username.Trim().ToUpperInvariant(); var doc = provider.Users.GetDocument(id); if (doc is null) { return Results.NotFound(); } var result = new { id = doc.Id, username = doc.GetString("username") ?? doc.GetString("email"), usernameNormalized = doc.GetString("usernameNormalized") ?? doc.GetString("emailNormalized"), passwordHash = doc.GetString("passwordHash"), mustChangePassword = doc.GetBoolean("mustChangePassword") }; return Results.Json(result); }); } app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); app.Run();