JWT-Authentifizierung & GameState-Verbesserungen

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.
This commit is contained in:
Tim Krampitz
2026-07-26 13:30:03 +02:00
parent d29bff17a1
commit 070727d5cd
5 changed files with 190 additions and 1 deletions

View File

@@ -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<SlipItInDbContext> _contextFactory;
private readonly IConfiguration _configuration;
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
{
_contextFactory = contextFactory;
_configuration = configuration;
}
[HttpPost("register")]
public async Task<ActionResult<AuthResponseDto>> 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<ActionResult<AuthResponseDto>> 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<object> 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<Claim>
{
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
};
}
}

View File

@@ -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");
}
/// <summary>
/// Speichert die ConnectionId für einen Spieler in der Datenbank
/// </summary>
@@ -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");

View File

@@ -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
};
}

View File

@@ -24,4 +24,8 @@
<ProjectReference Include="..\SlipItIn.Shared\SlipItIn.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>