diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index cf778b6..f409df5 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -4,6 +4,7 @@ using System.Text; using LehrerApp.Core.Models; using LehrerApp.Sync.Models; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; namespace LehrerApp.Api; @@ -21,7 +22,7 @@ public static class Endpoints if (!store.VerifyPassword(req.Username, req.Password)) return Results.Unauthorized(); return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); - }); + }).RequireRateLimiting("login"); } // ── Sync ────────────────────────────────────────────────────────────────── diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index b134faa..a585c32 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -1,6 +1,8 @@ using System.Text; +using System.Threading.RateLimiting; using LehrerApp.Api; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -9,6 +11,9 @@ builder.WebHost.UseKestrel(o => { var port = builder.Configuration.GetValue("Api:Port", 5000); o.ListenAnyIP(port); + // Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas + // Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze. + o.Limits.MaxRequestBodySize = 15 * 1024 * 1024; }); var data = builder.Configuration["Api:DataPath"] ?? "./data"; @@ -29,6 +34,30 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddAuthorization(); +// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein +// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte. +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddFixedWindowLimiter("login", o => + { + o.PermitLimit = 5; + o.Window = TimeSpan.FromMinutes(1); + o.QueueLimit = 0; + }); + + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); +}); + builder.Services.AddSingleton(_ => new UserStore(data)); builder.Services.AddSingleton(_ => new AttachmentStore(data)); builder.Services.AddSingleton(_ => new EventStore(data)); @@ -38,6 +67,7 @@ builder.Services.AddSingleton(sp => new PlainEventStore(sp.GetRequiredService())); var app = builder.Build(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapAuthEndpoints(secret); diff --git a/TODO.md b/TODO.md index 5c00b55..8fe54fd 100644 --- a/TODO.md +++ b/TODO.md @@ -1387,10 +1387,24 @@ ist aber nur aktiv, wenn eine Server-URL konfiguriert ist. entfernt (kein offener Registrierungs-Endpunkt für ein Einzel-/Familien-Deployment); neue Nutzer werden per CLI angelegt (`dotnet LehrerApp.Api.dll create-user `, dokumentiert in `docker/README.md`), damit keine zusätzliche unauthentifizierte Angriffsfläche entsteht. -- [ ] **10.2.2** Rate Limiting und Request-Größenbegrenzung. -- [ ] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien. +- [x] **10.2.2** Rate Limiting und Request-Größenbegrenzung. + + **Umsetzung:** ASP.NET Cores eingebautes `Microsoft.AspNetCore.RateLimiting` (keine neue + Paketabhängigkeit). `/api/auth/login` speziell auf 5 Versuche/Minute begrenzt + (Brute-Force-Schutz), alle Endpunkte zusätzlich global auf 120 Anfragen/Minute je IP. Kestrel + `MaxRequestBodySize` auf 15 MB gedeckelt (Anhänge sind clientseitig ohnehin auf 10 MB + begrenzt, siehe `IAttachmentStorage.MaxSizeBytes`). +- [x] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien. + + **Umsetzung:** `docker/backup.sh` — Tar-Archiv von `./data` (Ereignis-Logs, Snapshots, + Anhänge, Nutzer), räumt Archive älter als 30 Tage auf, läuft direkt auf dem Host (kein + Container-Zugriff nötig), dokumentiert samt Cron-Beispiel in `docker/README.md`. - [ ] **10.2.4** Docker-Setup in [docker/](docker/) verifizieren und dokumentieren. + **Teilweise:** `docker/README.md` um `create-user`-Flow, Backup und Rate-Limits ergänzt. + Ein tatsächlicher `docker compose up`-Durchlauf konnte in dieser Umgebung nicht verifiziert + werden (kein Docker verfügbar) — steht vor dem ersten echten Deployment noch aus. + ### 10.3 Verschlüsselung - [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). - [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel. diff --git a/docker/README.md b/docker/README.md index e15162c..dce288b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -28,3 +28,26 @@ docker compose exec api dotnet LehrerApp.Api.dll create-user --pa Der Befehl beendet sich danach sofort wieder, ohne den API-Dienst zu starten – für den eigentlichen Serverbetrieb läuft `docker compose up` unverändert weiter. + +## Backup + +`backup.sh` sichert `./data` (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als komprimiertes +Tar-Archiv unter `docker/backups/` und räumt Archive älter als 30 Tage automatisch auf. Läuft +direkt auf dem Host (kein Container-Zugriff nötig, `./data` liegt dort per Bind-Mount ohnehin): + +```bash +./docker/backup.sh +``` + +Für regelmäßige Sicherung z. B. per Cron, einmal täglich nachts: + +```bash +0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1 +``` + +## Rate Limiting + +`/api/auth/login` ist auf 5 Versuche pro Minute begrenzt (Brute-Force-Schutz). Alle übrigen +Endpunkte sind zusätzlich global auf 120 Anfragen pro Minute je IP begrenzt. Anhänge sind auf +15 MB Anfragegröße gedeckelt (Kestrel `MaxRequestBodySize`), einzelne Dateien clientseitig +zusätzlich auf 10 MB (`IAttachmentStorage.MaxSizeBytes`). diff --git a/docker/backup.sh b/docker/backup.sh new file mode 100755 index 0000000..e264fc3 --- /dev/null +++ b/docker/backup.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Sichert das komplette ./data-Verzeichnis (Ereignis-Logs, Snapshots, Anhänge, Nutzer) als +# komprimiertes Tar-Archiv. Läuft außerhalb des Containers direkt auf dem Host, da ./data per +# Bind-Mount ohnehin dort liegt (siehe docker-compose.yml) — kein Zugriff auf den Container nötig. +# +# Aufruf z.B. per Cron: +# 0 3 * * * /pfad/zu/docker/backup.sh >> /pfad/zu/docker/backup.log 2>&1 +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DATA_DIR="$SCRIPT_DIR/data" +BACKUP_DIR="$SCRIPT_DIR/backups" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +KEEP_DAYS=30 + +if [ ! -d "$DATA_DIR" ]; then + echo "Kein Datenverzeichnis unter $DATA_DIR gefunden - nichts zu sichern." >&2 + exit 1 +fi + +mkdir -p "$BACKUP_DIR" +tar -czf "$BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data + +# Alte Backups jenseits von KEEP_DAYS aufräumen, damit das Verzeichnis nicht unbegrenzt wächst. +find "$BACKUP_DIR" -name 'lehrerapp-data-*.tar.gz' -mtime "+$KEEP_DAYS" -delete + +echo "Backup erstellt: $BACKUP_DIR/lehrerapp-data-$TIMESTAMP.tar.gz"