Some checks failed
Build / build (pull_request) Has been cancelled
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
141 lines
5.1 KiB
C#
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
|
|
};
|
|
}
|
|
}
|