Files
SlipItIn/SlipItIn.Server/Controllers/AuthController.cs
Tim Krampitz af6528ee68
Some checks failed
Build / build (pull_request) Has been cancelled
Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
2026-08-22 19:37:54 +02:00

141 lines
5.1 KiB
C#

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;
private readonly IHostEnvironment _environment;
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
{
_contextFactory = contextFactory;
_configuration = configuration;
_environment = environment;
}
[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.");
if (request.Password.Length < 8)
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
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.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
Username = username,
Email = email
});
}
private AuthResponseDto CreateAuthResponse(User user)
{
var jwtKey = _configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey))
{
if (!_environment.IsDevelopment())
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
}
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
};
}
}