Files
GameList/Endpoints/AuthEndpoints.cs

151 lines
6.7 KiB
C#

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").WithTags("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));
}).WithName("GetAuthOptions");
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,
PasswordHashVersion = PasswordHasher.CurrentVersion,
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
));
}).WithName("Register");
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, player.PasswordHashVersion, out var needsRehash))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
}
if (needsRehash)
{
var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(request.Password ?? string.Empty);
player.PasswordHash = upgradedHash;
player.PasswordSalt = upgradedSalt;
player.PasswordHashVersion = PasswordHasher.CurrentVersion;
}
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
));
}).WithName("Login");
group.MapPost("/logout", async (HttpContext ctx) =>
{
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
return Results.NoContent();
}).WithName("Logout");
}
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
}