Add username/password auth and login UI

This commit is contained in:
2026-01-29 01:01:13 +01:00
parent ca25d4f0ee
commit f1534b7631
21 changed files with 690 additions and 50 deletions

View File

@@ -0,0 +1,79 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth");
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db) =>
{
var username = request.Username?.Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > 64)
return Results.BadRequest(new { error = "Username is required and must be <= 64 characters." });
if (string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest(new { error = "Password is required." });
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 64);
var normalized = username.ToLowerInvariant();
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
if (exists)
return Results.Conflict(new { error = "Username already taken." });
var (hash, salt) = PasswordHasher.HashPassword(request.Password);
var player = new Player
{
Id = Guid.NewGuid(),
Username = username,
NormalizedUsername = normalized,
PasswordHash = hash,
PasswordSalt = salt,
DisplayName = displayName,
CreatedAt = DateTimeOffset.UtcNow,
LastLoginAt = DateTimeOffset.UtcNow
};
db.Players.Add(player);
await db.SaveChangesAsync();
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
return Results.Ok(new { player.Id, player.Username, player.DisplayName });
});
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
{
var username = request.Username?.Trim();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest(new { error = "Username and password are required." });
var normalized = username.ToLowerInvariant();
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalized);
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized);
player.LastLoginAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
return Results.Ok(new { player.Id, player.Username, player.DisplayName });
});
group.MapPost("/logout", (HttpContext ctx) =>
{
PlayerIdentityExtensions.ClearPlayerCookie(ctx);
return Results.NoContent();
});
}
}

View File

@@ -7,20 +7,15 @@ namespace GameList.Endpoints;
internal static class EndpointHelpers
{
public static async Task<Player> GetOrCreatePlayer(HttpContext ctx, AppDbContext db)
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{
if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, out var value) || value is not Guid playerId)
{
throw new InvalidOperationException("Player cookie missing.");
return null;
}
var existing = await db.Players.FindAsync(playerId);
if (existing != null) return existing;
var player = new Player { Id = playerId };
db.Players.Add(player);
await db.SaveChangesAsync();
return player;
return existing;
}
public static async Task<Phase> GetPhase(AppDbContext db)

View File

@@ -8,12 +8,15 @@ public static class ResultsEndpoints
{
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/results", async (AppDbContext db) =>
app.MapGet("/api/results", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await EndpointHelpers.GetPhase(db);
if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var results = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Include(s => s.Votes)

View File

@@ -25,7 +25,8 @@ public static class StateEndpoints
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
{
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
return Results.Ok(new { player.Id, player.DisplayName });
});
@@ -36,7 +37,9 @@ public static class StateEndpoints
return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." });
}
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
player.DisplayName = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new { player.Id, player.DisplayName });

View File

@@ -16,7 +16,8 @@ public static class SuggestEndpoints
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var mine = await db.Suggestions.AsNoTracking()
.Where(s => s.PlayerId == player.Id)
.Select(s => new
@@ -50,7 +51,8 @@ public static class SuggestEndpoints
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
}
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(player.DisplayName))
{
@@ -86,7 +88,8 @@ public static class SuggestEndpoints
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
@@ -96,12 +99,15 @@ public static class SuggestEndpoints
return Results.NoContent();
});
app.MapGet("/api/suggestions/all", async (AppDbContext db) =>
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await EndpointHelpers.GetPhase(db);
if (phase < Phase.Reveal)
return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var all = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Select(s => new

View File

@@ -16,7 +16,8 @@ public static class VoteEndpoints
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var votes = await db.Votes.AsNoTracking()
.Where(v => v.PlayerId == player.Id)
.Select(v => new { v.SuggestionId, v.Score })
@@ -34,7 +35,8 @@ public static class VoteEndpoints
if (request.Score is < 0 or > 10)
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." });