using GameList.Contracts; using GameList.Data; using GameList.Domain; using GameList.Infrastructure; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; public static class AuthEndpoints { public static void MapAuthEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive"); group.MapGet("/options", async (AppDbContext db) => { var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner); return Results.Ok(new AuthOptionsResponse(ownerExists)); }); group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) => { if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) { authAttemptMonitor.RecordFailure(ctx, "auth-register", NormalizeActor(request.Username), "validation-failed"); return EndpointHelpers.BadRequestError(registrationError); } var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername); if (exists) return EndpointHelpers.ConflictError("Username already taken."); var (hash, salt) = PasswordHasher.HashPassword(validated.Password); var expectedAdminKey = config["ADMIN_PASSWORD"]; var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey); if (wantsAdmin) { if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey) { authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "invalid-admin-key"); return EndpointHelpers.BadRequestError("Invalid admin key."); } var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner); if (ownerExists) { authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-disabled"); return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists."); } } var isAdmin = wantsAdmin; var isOwner = wantsAdmin; var player = new Player { Id = Guid.NewGuid(), Username = validated.Username, NormalizedUsername = validated.NormalizedUsername, PasswordHash = hash, PasswordSalt = salt, DisplayName = validated.DisplayName, IsAdmin = isAdmin, IsOwner = isOwner, CreatedAt = DateTimeOffset.UtcNow, LastLoginAt = DateTimeOffset.UtcNow }; db.Players.Add(player); try { await db.SaveChangesAsync(); } catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName)) { authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race"); return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists."); } catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername")) { return EndpointHelpers.ConflictError("Username already taken."); } if (isAdmin) authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); return Results.Ok(new AuthSessionResponse( player.Id, player.Username, player.DisplayName, player.IsAdmin )); }); group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db, AuthAttemptMonitor authAttemptMonitor) => { if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError)) { authAttemptMonitor.RecordFailure(ctx, "auth-login", NormalizeActor(request.Username), "validation-failed"); return EndpointHelpers.BadRequestError(loginError); } var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt)) { authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials"); return EndpointHelpers.UnauthorizedError("Invalid username or password."); } if (string.IsNullOrWhiteSpace(player.DisplayName)) { player.DisplayName = EndpointHelpers.TrimTo(player.Username, AuthValidator.MaxDisplayNameLength); } player.LastLoginAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); authAttemptMonitor.RecordSuccess(ctx, "auth-login", normalizedUsername); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); return Results.Ok(new AuthSessionResponse( player.Id, player.Username, player.DisplayName, player.IsAdmin )); }); group.MapPost("/logout", async (HttpContext ctx) => { await PlayerIdentityExtensions.SignOutPlayerAsync(ctx); return Results.NoContent(); }); } private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim(); }