Switch to signed cookie auth and stop leaking player IDs
This commit is contained in:
@@ -3,7 +3,6 @@ using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
@@ -59,7 +58,7 @@ public static class AuthEndpoints
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id, player.Username);
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
});
|
||||
@@ -84,14 +83,14 @@ public static class AuthEndpoints
|
||||
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id, player.Username);
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
});
|
||||
|
||||
group.MapPost("/logout", (HttpContext ctx) =>
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
PlayerIdentityExtensions.ClearPlayerCookie(ctx);
|
||||
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -10,12 +11,24 @@ internal static class EndpointHelpers
|
||||
{
|
||||
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)
|
||||
if (ctx?.User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var idValue = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(idValue) || !Guid.TryParse(idValue, out var playerId))
|
||||
{
|
||||
// Auth cookie is present but malformed; clear and reject.
|
||||
await Infrastructure.PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await db.Players.FindAsync(playerId);
|
||||
if (existing is null)
|
||||
{
|
||||
await Infrastructure.PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -43,7 +56,11 @@ internal static class EndpointHelpers
|
||||
player.VotesFinal = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var changed = db.ChangeTracker.HasChanges();
|
||||
if (changed)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return player.CurrentPhase;
|
||||
}
|
||||
|
||||
@@ -152,8 +169,7 @@ internal static class EndpointHelpers
|
||||
var player = await GetAuthenticatedPlayer(ctx, db);
|
||||
if (player?.IsAdmin == true) return true;
|
||||
|
||||
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault()
|
||||
?? ctx.Request.Query["key"].FirstOrDefault();
|
||||
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault();
|
||||
var expected = config["ADMIN_PASSWORD"];
|
||||
return !string.IsNullOrWhiteSpace(expected) && provided == expected;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ public static class ResultsEndpoints
|
||||
{
|
||||
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/api/results",
|
||||
var group = app.MapGroup("/api/results").RequireAuthorization();
|
||||
|
||||
group.MapGet(
|
||||
"/",
|
||||
async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
|
||||
@@ -10,7 +10,9 @@ public static class StateEndpoints
|
||||
{
|
||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
var group = app.MapGroup("/api").RequireAuthorization();
|
||||
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -31,7 +33,7 @@ public static class StateEndpoints
|
||||
return Results.Ok(summary);
|
||||
});
|
||||
|
||||
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -39,7 +41,7 @@ public static class StateEndpoints
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -59,7 +61,7 @@ public static class StateEndpoints
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -76,7 +78,7 @@ public static class StateEndpoints
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var name = EndpointHelpers.TrimTo(request.Name, 16);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
|
||||
@@ -10,7 +10,9 @@ public static class SuggestEndpoints
|
||||
{
|
||||
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
var group = app.MapGroup("/api/suggestions").RequireAuthorization();
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -40,7 +42,7 @@ public static class SuggestEndpoints
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
|
||||
app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
|
||||
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||
{
|
||||
@@ -103,7 +105,7 @@ public static class SuggestEndpoints
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||
});
|
||||
|
||||
app.MapDelete("/api/suggestions/{id:int}", async (int id, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -132,7 +134,7 @@ public static class SuggestEndpoints
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapPut("/api/suggestions/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, IHttpClientFactory http) =>
|
||||
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, IHttpClientFactory http) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
|
||||
@@ -200,7 +202,7 @@ public static class SuggestEndpoints
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -213,7 +215,6 @@ public static class SuggestEndpoints
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
@@ -224,7 +225,8 @@ public static class SuggestEndpoints
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -242,7 +244,6 @@ public static class SuggestEndpoints
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
@@ -253,6 +254,7 @@ public static class SuggestEndpoints
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
|
||||
@@ -10,7 +10,9 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization();
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
@@ -25,7 +27,7 @@ public static class VoteEndpoints
|
||||
return Results.Ok(votes);
|
||||
});
|
||||
|
||||
app.MapPost("/api/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
if (request.Score is < 0 or > 10)
|
||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||
@@ -77,7 +79,7 @@ public static class VoteEndpoints
|
||||
return Results.Ok(new { SuggestionIds = linkedIds, request.Score });
|
||||
});
|
||||
|
||||
app.MapPost("/api/votes/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
|
||||
Reference in New Issue
Block a user