using System.Security.Claims; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using timetracker.Server.Components; using timetracker.Data; using timetracker.Shared; var builder = WebApplication.CreateBuilder(args); // Add Authentication builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/login"; options.LogoutPath = "/auth/logout"; options.ExpireTimeSpan = TimeSpan.FromDays(30); options.SlidingExpiration = true; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Cookie-Konfiguration für APIs & WASM options.Events.OnRedirectToLogin = context => { if (context.Request.Path.StartsWithSegments("/api")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; } else { context.Response.Redirect(context.RedirectUri); } return Task.CompletedTask; }; }); builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireClaim(ClaimTypes.Name, "marc")); }); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddHttpContextAccessor(); // Add SignalR builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Register DB-backed services as the interfaces builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); builder.Services.AddMudServices(); builder.Services.AddHttpClient(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("auth-limit", httpContext => RateLimitPartition.GetFixedWindowLimiter( partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? httpContext.Request.Headers["X-Forwarded-For"].ToString() ?? "unknown", factory: _ => new FixedWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), PermitLimit = 5, QueueLimit = 0 })); }); var dbProvider = builder.Configuration["DB_PROVIDER"] ?? "SQLite"; if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) { var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? builder.Configuration["ConnectionStrings:DefaultConnection"]; builder.Services.AddDbContextFactory(options => options.UseNpgsql(connectionString)); } else { var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH") ?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db"); builder.Services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); } var app = builder.Build(); // Migrate or Ensure Database Created using (var scope = app.Services.CreateScope()) { var factory = scope.ServiceProvider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(); if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) { await db.Database.EnsureCreatedAsync(); } else { await db.Database.MigrateAsync(); } } var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }; forwardedHeadersOptions.KnownProxies.Clear(); forwardedHeadersOptions.KnownIPNetworks.Clear(); app.UseForwardedHeaders(forwardedHeadersOptions); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); } else { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment())) { app.UseHttpsRedirection(); } app.UseStaticFiles(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapStaticAssets(); // Map Hub app.MapHub("/hubs/notifications"); // Map Blazor WASM app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(timetracker.Client._Imports).Assembly); // ── Auth-API-Endpoints ──────────────────────────────────────────────────────── app.MapGet("/api/auth/me", (HttpContext ctx) => { if (ctx.User.Identity?.IsAuthenticated == true) { var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var name = ctx.User.Identity.Name ?? ""; if (int.TryParse(idClaim, out var id)) { return Results.Ok(new UserInfo { Id = id, Username = name }); } } return Results.Unauthorized(); }); app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) => { var user = await authService.LoginAsync(req.Username, req.Password); if (user == null) return Results.BadRequest("Benutzername oder Passwort falsch."); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = true }); return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username }); }).RequireRateLimiting("auth-limit"); app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService) => { if (!string.IsNullOrEmpty(req.Honeypot)) { // Silently reject bots return Results.BadRequest("Registrierung fehlgeschlagen."); } var (user, error) = await authService.RegisterAsync(req.Username, req.Password); if (user == null) return Results.BadRequest(error ?? "Registrierung fehlgeschlagen."); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = true }); return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username }); }).RequireRateLimiting("auth-limit"); app.MapGet("/auth/logout", async (HttpContext ctx) => { await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Redirect("/login"); }); // ── Admin-API-Endpoints (Protected) ─────────────────────────────────────────── var usersApi = app.MapGroup("/api/users").RequireAuthorization("AdminOnly"); usersApi.MapGet("/", async (IAuthService authService) => { var users = await authService.GetAllUsersAsync(); return Results.Ok(users); }); usersApi.MapDelete("/{userId:int}", async (int userId, IAuthService authService) => { await authService.DeleteUserAsync(userId); return Results.NoContent(); }); usersApi.MapPut("/{userId:int}/rename", async (int userId, [FromBody] RenameRequest req, IAuthService authService) => { var error = await authService.RenameUserAsync(userId, req.Username); if (error != null) { return Results.BadRequest(error); } return Results.Ok(); }); // ── Timetracker-API-Endpoints (Protected) ───────────────────────────────────── var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization(); trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); if (DateOnly.TryParse(monday, out var date)) { var days = await trackerService.GetWeekAsync(userId, date); return Results.Ok(days); } return Results.BadRequest("Ungültiges Datum."); }); trackerApi.MapPost("/workday", async (ClaimsPrincipal claimsPrincipal, [FromBody] WorkDay workDay, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); workDay.UserId = userId; // Enforce owner await trackerService.UpsertWorkDayAsync(workDay); return Results.Ok(); }); trackerApi.MapGet("/settings", async (ClaimsPrincipal claimsPrincipal, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); var settings = await trackerService.GetSettingsAsync(userId); return Results.Ok(settings); }); trackerApi.MapPost("/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); settings.UserId = userId; // Enforce owner await trackerService.SaveSettingsAsync(settings); return Results.Ok(); }); trackerApi.MapGet("/vacation/{year:int}", async (ClaimsPrincipal claimsPrincipal, int year, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); var days = await trackerService.GetVacationDaysAsync(userId, year); return Results.Ok(days); }); trackerApi.MapPost("/vacation", async (ClaimsPrincipal claimsPrincipal, [FromBody] VacationDay vacationDay, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); vacationDay.UserId = userId; // Enforce owner await trackerService.AddVacationDayAsync(vacationDay); return Results.Ok(); }); trackerApi.MapDelete("/vacation/{id:int}", async (ClaimsPrincipal claimsPrincipal, int id, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); await trackerService.RemoveVacationDayAsync(userId, id); return Results.NoContent(); }); trackerApi.MapPost("/overtime", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); settings.UserId = userId; // Enforce owner var ts = await trackerService.GetTotalOvertimeAsync(userId, settings); return Results.Ok(ts.TotalHours); }); trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) => { var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); var days = await trackerService.GetMonthAsync(userId, year, month); return Results.Ok(days); }); // ── Holiday-API-Endpoints (Protected) ───────────────────────────────────────── var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization(); holidaysApi.MapGet("/", async ([FromQuery] int year, [FromQuery] string? stateCode, IHolidayService holidayService) => { var list = await holidayService.GetHolidaysAsync(year, stateCode); return Results.Ok(list); }); holidaysApi.MapPost("/fetch/{year:int}", async (int year, IHolidayService holidayService) => { var (success, message) = await holidayService.FetchAndStoreAsync(year); return Results.Ok(new { Success = success, Message = message }); }); holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService) => { await holidayService.DeleteAsync(id); return Results.NoContent(); }); app.Run(); // ── Models for Request Bodies ────────────────────────────────────────────────── public record LoginRequest(string Username, string Password); public record RegisterRequest(string Username, string Password, string? Honeypot = null); public record RenameRequest(string Username);