365 lines
14 KiB
C#
365 lines
14 KiB
C#
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<UserNotificationService>();
|
|
builder.Services.AddSingleton<IUserNotificationService>(sp => sp.GetRequiredService<UserNotificationService>());
|
|
|
|
// Register DB-backed services as the interfaces
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<ITimetrackerService, TimetrackerService>();
|
|
builder.Services.AddScoped<IHolidayService, HolidayService>();
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveWebAssemblyComponents();
|
|
|
|
builder.Services.AddMudServices();
|
|
builder.Services.AddHttpClient<HolidayService>();
|
|
|
|
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<TimetrackerDbContext>(options =>
|
|
options.UseNpgsql(connectionString));
|
|
}
|
|
else
|
|
{
|
|
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
|
|
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
|
|
builder.Services.AddDbContextFactory<TimetrackerDbContext>(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<IDbContextFactory<TimetrackerDbContext>>();
|
|
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<NotificationHub>("/hubs/notifications");
|
|
|
|
// Map Blazor WASM
|
|
app.MapRazorComponents<App>()
|
|
.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);
|