From 070727d5cd1fff7fdd7e0db903f1c696b0045ecd Mon Sep 17 00:00:00 2001 From: Tim Krampitz Date: Sun, 26 Jul 2026 13:30:03 +0200 Subject: [PATCH] JWT-Authentifizierung & GameState-Verbesserungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer AuthController mit Endpunkten für Registrierung, Login und Nutzerinfo (/register, /login, /me) implementiert. Auth-spezifische DTOs hinzugefügt. GameHub mit [Authorize] geschützt und Spielteilnahme validiert. GameService liefert jetzt Rundeninfos, aktuelle Rundennummer und verbleibende Rundendauer. Migrations-Ordner im Projekt angelegt. --- SlipItIn.Server/Controllers/AuthController.cs | 129 ++++++++++++++++++ SlipItIn.Server/Hubs/GameHub.cs | 17 +++ SlipItIn.Server/Services/GameService.cs | 18 ++- SlipItIn.Server/SlipItIn.Server.csproj | 4 + SlipItIn.Shared/DTOs/AuthDtos.cs | 23 ++++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 SlipItIn.Server/Controllers/AuthController.cs create mode 100644 SlipItIn.Shared/DTOs/AuthDtos.cs diff --git a/SlipItIn.Server/Controllers/AuthController.cs b/SlipItIn.Server/Controllers/AuthController.cs new file mode 100644 index 0000000..e3fe619 --- /dev/null +++ b/SlipItIn.Server/Controllers/AuthController.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using SlipItIn.Server.Data; +using SlipItIn.Shared.DTOs; +using SlipItIn.Shared.Models; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace SlipItIn.Server.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IDbContextFactory _contextFactory; + private readonly IConfiguration _configuration; + + public AuthController(IDbContextFactory contextFactory, IConfiguration configuration) + { + _contextFactory = contextFactory; + _configuration = configuration; + } + + [HttpPost("register")] + public async Task> Register(RegisterRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest("Username, Email und Passwort sind erforderlich."); + + using var context = _contextFactory.CreateDbContext(); + + var normalizedEmail = request.Email.Trim().ToLowerInvariant(); + var normalizedUsername = request.Username.Trim(); + + var emailExists = await context.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail); + if (emailExists) + return Conflict("Email ist bereits vergeben."); + + var usernameExists = await context.Users.AnyAsync(u => u.Username.ToLower() == normalizedUsername.ToLower()); + if (usernameExists) + return Conflict("Username ist bereits vergeben."); + + var user = new User + { + Username = normalizedUsername, + Email = normalizedEmail, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password) + }; + + context.Users.Add(user); + await context.SaveChangesAsync(); + + return Ok(CreateAuthResponse(user)); + } + + [HttpPost("login")] + public async Task> Login(LoginRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest("Email und Passwort sind erforderlich."); + + using var context = _contextFactory.CreateDbContext(); + + var normalizedEmail = request.Email.Trim().ToLowerInvariant(); + var user = await context.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail); + + if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + return Unauthorized("Ungültige Anmeldedaten."); + + return Ok(CreateAuthResponse(user)); + } + + [Authorize] + [HttpGet("me")] + public ActionResult Me() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + var username = User.FindFirstValue(ClaimTypes.Name); + var email = User.FindFirstValue(ClaimTypes.Email); + + if (string.IsNullOrWhiteSpace(userIdClaim)) + return Unauthorized(); + + return Ok(new + { + UserId = int.Parse(userIdClaim), + Username = username, + Email = email + }); + } + + private AuthResponseDto CreateAuthResponse(User user) + { + var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!"; + var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer"; + var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient"; + var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440; + + var expiresAt = DateTime.UtcNow.AddMinutes(expirationMinutes); + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new(ClaimTypes.Email, user.Email) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: jwtIssuer, + audience: jwtAudience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials); + + return new AuthResponseDto + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + ExpiresAtUtc = expiresAt, + UserId = user.Id, + Username = user.Username, + Email = user.Email + }; + } +} diff --git a/SlipItIn.Server/Hubs/GameHub.cs b/SlipItIn.Server/Hubs/GameHub.cs index 2809e3f..5d2d12c 100644 --- a/SlipItIn.Server/Hubs/GameHub.cs +++ b/SlipItIn.Server/Hubs/GameHub.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using SlipItIn.Server.Data; @@ -7,6 +8,7 @@ using System.Security.Claims; namespace SlipItIn.Server.Hubs; +[Authorize] public class GameHub : Hub { private readonly IGameService _gameService; @@ -43,6 +45,14 @@ public class GameHub : Hub throw new UnauthorizedAccessException("Player does not belong to authenticated user"); } + private async Task ValidateUserInGameAsync(int gameId, int authUserId) + { + using var context = _contextFactory.CreateDbContext(); + var isParticipant = await context.Players.AnyAsync(p => p.GameId == gameId && p.UserId == authUserId); + if (!isParticipant) + throw new UnauthorizedAccessException("User is not part of this game"); + } + /// /// Speichert die ConnectionId für einen Spieler in der Datenbank /// @@ -329,9 +339,16 @@ public class GameHub : Hub { try { + var userId = GetAuthenticatedUserId(); + await ValidateUserInGameAsync(gameId, userId); + var gameState = await _gameService.GetGameStateAsync(gameId); await Clients.Caller.SendAsync("GameStateUpdated", gameState); } + catch (UnauthorizedAccessException ex) + { + await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); + } catch (Exception ex) { _logger.LogError(ex, "Error requesting game state"); diff --git a/SlipItIn.Server/Services/GameService.cs b/SlipItIn.Server/Services/GameService.cs index 5e9c1a5..eda875c 100644 --- a/SlipItIn.Server/Services/GameService.cs +++ b/SlipItIn.Server/Services/GameService.cs @@ -205,7 +205,9 @@ public class GameService : IGameService .ThenInclude(p => p.User) .Include(g => g.Players) .ThenInclude(p => p.Cards) + .Include(g => g.Rounds) .FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); + var playerInfos = game.Players.Select(p => new PlayerInfoDto { PlayerId = p.Id, @@ -215,13 +217,27 @@ public class GameService : IGameService CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst! }).ToList(); + var currentRound = game.Rounds + .OrderByDescending(r => r.RoundNumber) + .FirstOrDefault(); + + var roundDurationSeconds = Math.Clamp(game.RoundDurationSeconds, 30, 60); + var roundTimeRemaining = roundDurationSeconds; + + if (currentRound?.Status == RoundStatus.Active) + { + var elapsedSeconds = (int)(DateTime.UtcNow - currentRound.StartedAt).TotalSeconds; + roundTimeRemaining = Math.Max(0, roundDurationSeconds - elapsedSeconds); + } + return new GameStateDto { GameId = game.Id, LobbyCode = game.LobbyCode, Status = game.Status.ToString(), Players = playerInfos, - CurrentRound = game.Rounds?.Count ?? 0 + CurrentRound = currentRound?.RoundNumber ?? 0, + RoundTimeRemaining = roundTimeRemaining }; } diff --git a/SlipItIn.Server/SlipItIn.Server.csproj b/SlipItIn.Server/SlipItIn.Server.csproj index a381114..362de7b 100644 --- a/SlipItIn.Server/SlipItIn.Server.csproj +++ b/SlipItIn.Server/SlipItIn.Server.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/SlipItIn.Shared/DTOs/AuthDtos.cs b/SlipItIn.Shared/DTOs/AuthDtos.cs new file mode 100644 index 0000000..bc53d82 --- /dev/null +++ b/SlipItIn.Shared/DTOs/AuthDtos.cs @@ -0,0 +1,23 @@ +namespace SlipItIn.Shared.DTOs; + +public class RegisterRequestDto +{ + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class LoginRequestDto +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class AuthResponseDto +{ + public string Token { get; set; } = string.Empty; + public DateTime ExpiresAtUtc { get; set; } + public int UserId { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +}