diff --git a/Agents/Architecture.md b/Agents/Architecture.md new file mode 100644 index 0000000..4ee87b6 --- /dev/null +++ b/Agents/Architecture.md @@ -0,0 +1,136 @@ +# Slip It In – Architektur-Updates (Phase 2b & 3b) + +Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden. + +## 🔒 Backend-Sicherheit (Phase 2b) + +### 1. JWT-Claims Validation im GameHub +- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing) +- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims +- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)` + +### 2. IDbContextFactory statt Scoped DbContext +- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen +- **Lösung**: `IDbContextFactory` erzeugt isolierte Sessions pro Aufruf +- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges + +### 3. Serverseitige Validierung +- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES +- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server: + - ✅ Gehört die Phrase dem Spieler? + - ✅ Ist die Runde aktiv? + - ✅ Ist der Spieler noch im Spiel? + +### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto) +- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar +- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst +- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe! + +--- + +## 📱 Client-Stabilität (Phase 3b) + +### 1. WeakReferenceMessenger für Event-Entkopplung +- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed +- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()` +- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten + +**Beispiel:** +```csharp +// Service sendet Event +WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState)); + +// ViewModel empfängt (mit auto-cleanup beim Dispose) +WeakReferenceMessenger.Default.Register(this, (r, m) => +{ + CurrentGameState = m.Value; +}); +``` + +### 2. Automatischer Reconnect +- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s) +- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben +- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen + +**Flow:** +1. SignalR-Verbindung getrennt +2. Client speichert "Slip zu Phrase X" in lokale Queue +3. Nach 2s Automatischer Reconnect-Versuch +4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet + +### 3. State Restoration +- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage` +- **App-Resume**: `ResyncAsync()` wird aufgerufen + - ✅ Holt aktuellen GameState vom Server + - ✅ Sendet gepufferte Offline-Aktionen erneut + - ✅ UI zeigt "Syncing..." bis fertig + +### 4. Dual-Layer Storage +- **In-Memory Layer**: `GameStateService` halten aktuellen State +- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup +- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden + +--- + +## 📊 Schnellreferenz: Neue Components + +### Backend +``` +GameHub +├─ GetAuthenticatedUserId() → JWT-Validierung +├─ ValidatePlayerAccessAsync() → Authorization Check +└─ Alle Methoden nutzen IDbContextFactory + +Program.cs +├─ AddDbContextFactory() +├─ AddAuthentication(JwtBearerDefaults) +└─ AddSignalR() mit JWT-Filter + +DTOs +├─ GameStateDto (öffentlich) +├─ PlayerHandDto (privat) +└─ Keine CardText in GameStateDto! +``` + +### Client +``` +GameStateService +├─ CurrentGameState (in-memory) +├─ CurrentPlayerHand (in-memory) +├─ UpdateGameStateAsync() → speichert + sendet Message +└─ ResyncAsync() → bei App-Resume + +SignalRService +├─ ConnectAsync(token) mit Auto-Reconnect +├─ RequestGameStateAsync() +└─ On<> Handler senden Messages + +Messages (WeakReferenceMessenger) +├─ GameStateChangedMessage +├─ PlayerHandChangedMessage +└─ ChallengeReceivedMessage +``` + +--- + +## 🧪 Wichtigste Tests + +| Test | Ziel | +|------|------| +| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab | +| **Player-Access** | User A kann nicht auf Players von User B zugreifen | +| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges | +| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet | +| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) | +| **State-Sync** | App-Resume synct korrekt mit Server-State | + +--- + +## 🚀 Implementierungs-Reihenfolge + +1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren +2. **GameService erweitern**: Serverseitige Validierung in alle Methoden +3. **Client-Services**: GameStateService + SignalRService implementieren +4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren +5. **Tests**: Integrationstest-Suite schreiben +6. **UI**: Views an neue Message-Events binden diff --git a/ProjectPlan.md b/Agents/ProjectPlan.md similarity index 73% rename from ProjectPlan.md rename to Agents/ProjectPlan.md index 2ec2fa7..78a7764 100644 --- a/ProjectPlan.md +++ b/Agents/ProjectPlan.md @@ -10,7 +10,11 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel ## Phase 2: Backend-Entwicklung (Web API & SignalR) - [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite -- [ ] **User Management**: Simple Registrierung, Login und JWT-basierte Authentifizierung +- [ ] **User Management**: Einfache JWT-basierte Authentifizierung + - Registrierung (Username, Email, Passwort) + - Login mit BCrypt Password Hashing + - JWT Tokens für API-Authentifizierung + - Token-Validierung auf API-Endpoints - [ ] **Game Engine & Hub**: - `GameHub` für Echtzeit-Kommunikation - Logik für Lobby-Erstellung und Beitritt via Code @@ -23,19 +27,51 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel - Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben - Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip) +## Phase 2b: Backend-Erweiterungen (Sicherheit & In-Memory-State) +- [ ] **SignalR Auth Integration**: JWT-Claims Validation im `GameHub` + - Verhindert Spoofing von PlayerIds + - User-Identity aus JWT extrahiert und validiert + - Nur autorisierte Spieler können auf ihre Daten zugreifen +- [ ] **Datenschutz/Karten-Isolation**: Trennung von öffentlichen und privaten Daten + - `GameStateDto` – Öffentliche Spieldaten (Spieler, Scores, aktive Runden) + - `PlayerHandDto` – Private Handkarten (nur für den Spieler selbst sichtbar) + - Clients erhalten nur Daten, die sie sehen dürfen +- [ ] **EF Core Context Factory**: Umstellung von Scoped auf `IDbContextFactory` + - Vermeidet Parallelitätskonflikte bei SignalR-Aufrufen + - Jede SignalR-Methode nutzt eine eigene, isolierte DB-Session + - Verhindert Race Conditions beim Phrase-Transfer +- [ ] **Serverseitige Validierung**: Logik-Verlagerung auf Server + - Clients senden nur Wünsche (z.B. "Ich möchte dieser Phrase zuordnen") + - Server prüft alle Bedingungen (IsPlayersTurn, CardBelongsToPlayer, etc.) + - Server speichert nur validierte Zustandsänderungen in DB + ## Phase 3: Client-Entwicklung (.NET MAUI) - [ ] **Infrastruktur**: - MVVM-Setup mit CommunityToolkit.Mvvm - Integration von Dependency Injection für Services + - Messenger-Architektur mit `WeakReferenceMessenger` für Event-Entkopplung - [ ] **Services**: - - `ApiService` für REST-Anfragen - - `SignalRService` für Echtzeit-Events + - `ApiService` für REST-Anfragen (mit JWT-Token aus `SecureStorage`) + - `SignalRService` für Echtzeit-Events mit automatischem Reconnect + - `GameStateService` für In-Memory-Spielzustand-Verwaltung + - `LocalStorageService` für State-Persistence (Wiederherstellung nach Crash) - [ ] **UI/Views**: - `LoginPage` & `RegisterPage` - `LobbyPage` (Spielerliste & Start-Button für Host) - `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button) - `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button) +## Phase 3b: MAUI Client-Stabilität +- [ ] **Messenger-Architektur**: Entkopplung via `WeakReferenceMessenger` + - SignalR-Events erzeugen Messages (z.B. `GameStateChangedMessage`) + - ViewModels abonnieren Messages statt direkter Service-Events + - Verhindert Memory Leaks durch Circular Dependencies +- [ ] **State Restoration**: Automatisches Re-Syncing + - App-Resume triggert `GameStateService.ResyncAsync()` + - SignalR-Reconnect lädt aktuellen Spielzustand vom Server + - Offline-Puffering: Lokale Aktionen werden queued und nach Reconnect abgesendet + - UI zeigt "Syncing..." bis Verbindung wiederhergestellt + ## Phase 4: Testen & Optimierung - [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients - [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR) @@ -47,6 +83,20 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel **Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert. +### Architektur-Highlights (Sicherheit & Stabilität) + +#### Backend-Sicherheit: +- **JWT-Claims Validation** im GameHub → Verhindert PlayerId-Spoofing +- **IDbContextFactory** statt Scoped DbContext → Isoliert SignalR-Aufrufe, verhindert Race Conditions +- **Serverseitige Validierung** → Clients senden nur Wünsche, Server prüft alles +- **Daten-Isolation** → `GameStateDto` (öffentlich) vs. `PlayerHandDto` (privat) + +#### Client-Stabilität: +- **WeakReferenceMessenger** → Entkopplung von Services & ViewModels, verhindert Memory Leaks +- **Automatischer Reconnect** → SignalR mit exponentialem Backoff (0s, 2s, 10s, 30s) +- **State Persistence** → Offline-Aktionen in Queue, Auto-Resync nach App-Resume +- **Dual-Layer Storage** → In-Memory (`GameStateService`) + Local (`SecureStorage`/`Preferences`) + ### Spielmechanik-Übersicht **Grundkonzept:** @@ -337,16 +387,25 @@ public class GameStateDto public class PlayerInfoDto { + // Öffentliche Daten (für alle sichtbar) public int PlayerId { get; set; } public string Username { get; set; } = string.Empty; public int Score { get; set; } public bool IsReady { get; set; } - public List ActiveCards { get; set; } = []; + public int CardCount { get; set; } // Anzahl der Karten (nicht die Karten selbst!) } -public class PhraseDto +public class PlayerHandDto +{ + // Private Daten (nur für den Spieler selbst) + public int PlayerId { get; set; } + public List Cards { get; set; } = []; +} + +public class PlayerCardDto { public int CardId { get; set; } + public int PhraseId { get; set; } public string Text { get; set; } = string.Empty; public bool IsUsed { get; set; } } @@ -356,8 +415,10 @@ public class SlipChallengeDto public int ChallengeId { get; set; } public int ChallengingPlayerId { get; set; } public string ChallengingPlayerName { get; set; } = string.Empty; + public int TargetPlayerId { get; set; } public int TargetCardId { get; set; } public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } } ``` @@ -366,25 +427,55 @@ public class SlipChallengeDto public class GameHub : Hub { private readonly IGameService _gameService; + private readonly IDbContextFactory _contextFactory; private readonly ILogger _logger; - public GameHub(IGameService gameService, ILogger logger) + public GameHub(IGameService gameService, IDbContextFactory contextFactory, ILogger logger) { _gameService = gameService; + _contextFactory = contextFactory; _logger = logger; } + /// + /// Validiert die JWT-Claims des aktuellen Nutzers + /// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt + /// + private int GetAuthenticatedUserId() + { + var userIdClaim = Context.User?.FindFirst(ClaimTypes.NameIdentifier); + if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId)) + throw new UnauthorizedAccessException("Invalid or missing user claim"); + return userId; + } + + /// + /// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift + /// + private async Task ValidatePlayerAccessAsync(int playerId, int authUserId) + { + using var context = _contextFactory.CreateDbContext(); + var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId); + if (player?.UserId != authUserId) + throw new UnauthorizedAccessException("Player does not belong to authenticated user"); + } + // Lobby-Verwaltung - public async Task CreateLobby(string username) + public async Task CreateLobby() { try { - var game = await _gameService.CreateGameAsync(username); + var userId = GetAuthenticatedUserId(); + var game = await _gameService.CreateGameAsync(userId); var connectionId = Context.ConnectionId; await Groups.AddToGroupAsync(connectionId, game.LobbyCode); await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode }); - _logger.LogInformation($"Lobby {game.LobbyCode} created by {username}"); + _logger.LogInformation($"Lobby {game.LobbyCode} created by user {userId}"); + } + catch (UnauthorizedAccessException) + { + await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" }); } catch (Exception ex) { @@ -393,17 +484,22 @@ public class GameHub : Hub } } - public async Task JoinLobby(string lobbyCode, string username) + public async Task JoinLobby(string lobbyCode) { try { - var game = await _gameService.JoinGameAsync(lobbyCode, username); + var userId = GetAuthenticatedUserId(); + var game = await _gameService.JoinGameAsync(lobbyCode, userId); var connectionId = Context.ConnectionId; await Groups.AddToGroupAsync(connectionId, lobbyCode); var gameState = await _gameService.GetGameStateAsync(game.Id); await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState); - _logger.LogInformation($"{username} joined lobby {lobbyCode}"); + _logger.LogInformation($"User {userId} joined lobby {lobbyCode}"); + } + catch (UnauthorizedAccessException) + { + await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" }); } catch (Exception ex) { @@ -773,19 +869,43 @@ public class GameService : IGameService var builder = WebApplicationBuilder.CreateBuilder(args); -// Datenbankverbindung (SQLite für Development) +// Datenbankverbindung (SQLite) builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=slipitIn.db")); +// IDbContextFactory für SignalR-Isolation +builder.Services.AddDbContextFactory(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=slipitIn.db")); + // Services registrieren builder.Services.AddScoped(); -// SignalR -builder.Services.AddSignalR(); +// JWT Authentication +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = "https://your-domain.com"; // oder issuer aus config + options.TokenValidationParameters = new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true + }; + }); + +// SignalR mit JWT +builder.Services.AddSignalR(options => +{ + options.AddFilter(); +}); // Controller & Services builder.Services.AddControllers(); +builder.Services.AddAuthorization(); var app = builder.Build(); @@ -804,6 +924,106 @@ app.Run(); --- +### MAUI Client-Services (SlipItIn/Services/) + +#### 1. GameStateService.cs – Zentrale Zustandsverwaltung + +```csharp +public interface IGameStateService +{ + GameStateDto? CurrentGameState { get; } + PlayerHandDto? CurrentPlayerHand { get; } + + Task ResyncAsync(); // App-Resume oder Reconnect + Task UpdateGameStateAsync(GameStateDto state); + Task UpdatePlayerHandAsync(PlayerHandDto hand); +} + +public class GameStateService : IGameStateService +{ + private readonly ISignalRService _signalR; + private readonly ILocalStorageService _localStorage; + private GameStateDto? _currentGameState; + private PlayerHandDto? _currentPlayerHand; + + public GameStateDto? CurrentGameState => _currentGameState; + public PlayerHandDto? CurrentPlayerHand => _currentPlayerHand; + + public async Task ResyncAsync() + { + if (_currentGameState?.GameId > 0) + await _signalR.RequestGameStateAsync(_currentGameState.GameId); + + var queuedActions = await _localStorage.GetQueuedActionsAsync(); + foreach (var action in queuedActions) + await _signalR.SendActionAsync(action); + } + + public async Task UpdateGameStateAsync(GameStateDto state) + { + _currentGameState = state; + await _localStorage.SaveGameStateAsync(state); + WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state)); + } + + public async Task UpdatePlayerHandAsync(PlayerHandDto hand) + { + _currentPlayerHand = hand; + await _localStorage.SavePlayerHandAsync(hand); + WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand)); + } +} +``` + +#### 2. SignalRService.cs – Mit automatischem Reconnect + +```csharp +public class SignalRService : ISignalRService +{ + private HubConnection? _connection; + + public async Task ConnectAsync(string token) + { + _connection = new HubConnectionBuilder() + .WithUrl(_hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(token); + }) + .WithAutomaticReconnect(new[] + { + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }) + .Build(); + + _connection.On("GameStateUpdated", state => + WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state))); + + _connection.On("PlayerHandUpdated", hand => + WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand))); + + _connection.On("ChallengeReceived", challenge => + WeakReferenceMessenger.Default.Send(new ChallengeReceivedMessage(challenge))); + + await _connection.StartAsync(); + return true; + } +} +``` + +#### 3. MauiProgram.cs – Dependency Injection + +```csharp +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +--- + ### Testplan für Integrationstests #### Integration Test: Lobby-Workflow @@ -831,4 +1051,18 @@ app.Run(); #### Integration Test: SignalR-Gruppen - [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges - [ ] Challenge-Notification wird an Beschuldigen-Spieler gesendet + +#### Integration Test: JWT-Auth & Sicherheit +- [ ] SignalR-Hub lehnt Requests ohne gültiges JWT ab +- [ ] Player-ID Spoofing wird verhindert (User kann nur auf seine Players zugreifen) +- [ ] `IDbContextFactory` isoliert DB-Sessions bei parallelen Hub-Aufrufen +- [ ] Keine Race Conditions beim Phrase-Transfer zwischen 2 Hub-Clients + +#### Integration Test: MAUI State Restoration +- [ ] App-Pause speichert `GameStateDto` lokal +- [ ] App-Resume synct Spielzustand mit Server +- [ ] Offline-Aktionen werden in Queue gepuffert +- [ ] Nach Reconnect werden gepufferte Aktionen abgesendet +- [ ] UI zeigt "Syncing..." bis Verbindung wiederhergestellt +- [ ] `WeakReferenceMessenger` - keine Memory Leaks bei View-Model Cleanup - [ ] Disconnect-Handler aktualisiert Spielerstatus \ No newline at end of file