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:
129
SlipItIn.Server/Controllers/AuthController.cs
Normal file
129
SlipItIn.Server/Controllers/AuthController.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,4 +24,8 @@
|
||||
<ProjectReference Include="..\SlipItIn.Shared\SlipItIn.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user