139 lines
5.9 KiB
C#
139 lines
5.9 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").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", request.Username.Trim(), "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(request.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", request.Username.Trim(), "validation-failed");
|
|
return EndpointHelpers.BadRequestError(loginError);
|
|
}
|
|
|
|
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
|
if (player == null || !PasswordHasher.Verify(request.Password, 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();
|
|
});
|
|
}
|
|
}
|