diff --git a/API.md b/API.md index a2b4771..d1d5c30 100644 --- a/API.md +++ b/API.md @@ -1,28 +1,33 @@ -# API Contract (MVP) +# API Contract (Auth-enabled) -All endpoints are JSON. Player identity comes from HttpOnly cookie `player`. +All endpoints are JSON. Most routes require the HttpOnly cookie `player`, which is issued after successful register/login. Legacy player rows are given `legacy-xxxxxxxx` usernames during migration; they must register/login to get a valid auth cookie. + +## Auth +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout ## State -GET /api/state +GET /api/state (public) -## Player -GET /api/me +## Player (requires auth) +GET /api/me POST /api/me/name -## Suggestions -GET /api/suggestions/mine -POST /api/suggestions -DELETE /api/suggestions/{id} +## Suggestions (requires auth + phase gating) +GET /api/suggestions/mine +POST /api/suggestions +DELETE /api/suggestions/{id} GET /api/suggestions/all -## Votes -GET /api/votes/mine +## Votes (requires auth + phase gating) +GET /api/votes/mine POST /api/votes -## Results +## Results (requires auth + phase gating) GET /api/results -## Admin -POST /api/admin/phase -POST /api/admin/reset +## Admin (admin key header/query required) +POST /api/admin/phase +POST /api/admin/reset POST /api/admin/factory-reset diff --git a/Contracts/AuthRequests.cs b/Contracts/AuthRequests.cs new file mode 100644 index 0000000..a0a73bd --- /dev/null +++ b/Contracts/AuthRequests.cs @@ -0,0 +1,4 @@ +namespace GameList.Contracts; + +public record RegisterRequest(string Username, string Password, string? DisplayName); +public record LoginRequest(string Username, string Password); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 27fbdc9..587ad2e 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -20,6 +20,11 @@ public class AppDbContext : DbContext { builder.HasKey(p => p.Id); builder.Property(p => p.DisplayName).HasMaxLength(64); + builder.Property(p => p.Username).IsRequired().HasMaxLength(64); + builder.Property(p => p.NormalizedUsername).IsRequired().HasMaxLength(64); + builder.HasIndex(p => p.NormalizedUsername).IsUnique(); + builder.Property(p => p.PasswordHash).IsRequired(); + builder.Property(p => p.PasswordSalt).IsRequired(); builder.HasMany(p => p.Suggestions) .WithOne(s => s.Player!) .HasForeignKey(s => s.PlayerId) diff --git a/Data/Migrations/20260128235657_AddAuthToPlayers.Designer.cs b/Data/Migrations/20260128235657_AddAuthToPlayers.Designer.cs new file mode 100644 index 0000000..c64de13 --- /dev/null +++ b/Data/Migrations/20260128235657_AddAuthToPlayers.Designer.cs @@ -0,0 +1,206 @@ +// +using System; +using GameList.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameList.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260128235657_AddAuthToPlayers")] + partial class AddAuthToPlayers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("GameList.Domain.AppState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.ToTable("Suggestions"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SuggestionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SuggestionId"); + + b.HasIndex("PlayerId", "SuggestionId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260128235657_AddAuthToPlayers.cs b/Data/Migrations/20260128235657_AddAuthToPlayers.cs new file mode 100644 index 0000000..f41591e --- /dev/null +++ b/Data/Migrations/20260128235657_AddAuthToPlayers.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddAuthToPlayers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastLoginAt", + table: "Players", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedUsername", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Players", + type: "BLOB", + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "PasswordSalt", + table: "Players", + type: "BLOB", + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "Username", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.Sql(@" +UPDATE Players +SET Username = 'legacy-' || substr(Id,1,8), + NormalizedUsername = lower('legacy-' || substr(Id,1,8)) +WHERE coalesce(Username,'') = '' OR coalesce(NormalizedUsername,'') = ''; + +UPDATE Players +SET PasswordHash = X'', PasswordSalt = X'' +WHERE (PasswordHash IS NULL OR length(PasswordHash)=0) OR (PasswordSalt IS NULL OR length(PasswordSalt)=0); +"); + + migrationBuilder.CreateIndex( + name: "IX_Players_NormalizedUsername", + table: "Players", + column: "NormalizedUsername", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Players_NormalizedUsername", + table: "Players"); + + migrationBuilder.DropColumn( + name: "LastLoginAt", + table: "Players"); + + migrationBuilder.DropColumn( + name: "NormalizedUsername", + table: "Players"); + + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Players"); + + migrationBuilder.DropColumn( + name: "PasswordSalt", + table: "Players"); + + migrationBuilder.DropColumn( + name: "Username", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 1b135a9..805088a 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -55,8 +55,32 @@ namespace GameList.Data.Migrations .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.HasKey("Id"); + b.HasIndex("NormalizedUsername") + .IsUnique(); + b.ToTable("Players"); }); diff --git a/Domain/Player.cs b/Domain/Player.cs index b13a95b..b288761 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -9,6 +9,17 @@ public class Player [MaxLength(64)] public string? DisplayName { get; set; } + [MaxLength(64)] + public string Username { get; set; } = string.Empty; + + [MaxLength(64)] + public string NormalizedUsername { get; set; } = string.Empty; + + public byte[] PasswordHash { get; set; } = Array.Empty(); + public byte[] PasswordSalt { get; set; } = Array.Empty(); + + public DateTimeOffset? LastLoginAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public ICollection Suggestions { get; set; } = new List(); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..2545ae5 --- /dev/null +++ b/Endpoints/AuthEndpoints.cs @@ -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(); + }); + } +} diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 207a63e..937f3d6 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -7,20 +7,15 @@ namespace GameList.Endpoints; internal static class EndpointHelpers { - public static async Task GetOrCreatePlayer(HttpContext ctx, AppDbContext db) + public static async Task 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 GetPhase(AppDbContext db) diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index e96191c..017409e 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -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) diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 189a067..372325f 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -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 }); diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 07e992c..5d6af5f 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -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 diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index df26c85..18c41ae 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -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." }); diff --git a/Infrastructure/PasswordHasher.cs b/Infrastructure/PasswordHasher.cs new file mode 100644 index 0000000..45b53fb --- /dev/null +++ b/Infrastructure/PasswordHasher.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace GameList.Infrastructure; + +public static class PasswordHasher +{ + private const int SaltSize = 16; + private const int KeySize = 32; + private const int Iterations = 100_000; + + public static (byte[] Hash, byte[] Salt) HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("Password required", nameof(password)); + + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = PBKDF2(password, salt); + return (hash, salt); + } + + public static bool Verify(string password, byte[] hash, byte[] salt) + { + if (hash is null || salt is null || hash.Length == 0 || salt.Length == 0) return false; + var computed = PBKDF2(password, salt); + return CryptographicOperations.FixedTimeEquals(computed, hash); + } + + private static byte[] PBKDF2(string password, byte[] salt) + { + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + Iterations, + HashAlgorithmName.SHA256, + KeySize); + } +} diff --git a/Infrastructure/PlayerIdentityExtensions.cs b/Infrastructure/PlayerIdentityExtensions.cs index 9f8ca62..8394415 100644 --- a/Infrastructure/PlayerIdentityExtensions.cs +++ b/Infrastructure/PlayerIdentityExtensions.cs @@ -13,24 +13,14 @@ public static class PlayerIdentityExtensions var pathBase = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/"; var isHttps = string.Equals(ctx.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase); - var cookieOptions = new CookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Strict, - Secure = isHttps, - IsEssential = true, - Expires = DateTimeOffset.UtcNow.AddYears(1), - Path = pathBase - }; - Guid playerId; if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId)) { - playerId = Guid.NewGuid(); + await next(); + return; } - ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), cookieOptions); - ctx.Items[PlayerCookieName] = playerId; + IssuePlayerCookie(ctx, playerId); await next(); }); @@ -38,6 +28,36 @@ public static class PlayerIdentityExtensions return app; } + public static void IssuePlayerCookie(HttpContext ctx, Guid playerId) + { + var options = BuildCookieOptions(ctx); + ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), options); + ctx.Items[PlayerCookieName] = playerId; + } + + public static void ClearPlayerCookie(HttpContext ctx) + { + var options = BuildCookieOptions(ctx); + options.Expires = DateTimeOffset.UtcNow.AddDays(-1); + ctx.Response.Cookies.Append(PlayerCookieName, string.Empty, options); + ctx.Items.Remove(PlayerCookieName); + } + + private static CookieOptions BuildCookieOptions(HttpContext ctx) + { + var pathBase = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/"; + var isHttps = string.Equals(ctx.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase); + return new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Secure = isHttps, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.AddYears(1), + Path = pathBase + }; + } + public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app) { app.UseExceptionHandler(handler => diff --git a/Program.cs b/Program.cs index 846e9e9..8381517 100644 --- a/Program.cs +++ b/Program.cs @@ -64,6 +64,7 @@ app.UseStaticFiles(); app.UsePlayerIdentity(); app.MapHealthChecks(); +app.MapAuthEndpoints(); app.MapStateEndpoints(); app.MapSuggestEndpoints(); app.MapVoteEndpoints(); diff --git a/SPEC.md b/SPEC.md index df47436..7523c8c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9,7 +9,7 @@ A micro web app for a closed Discord group (4–8 players) to decide what co-op ## MVP Scope - Single shared instance -- Anonymous join via cookie +- Username/password login (cookie holds auth token after register/login) - Organizer-controlled phase switching ## Suggest Phase diff --git a/wwwroot/app.js b/wwwroot/app.js index 0c63a07..b35ee4f 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,6 +1,8 @@ import { api, adminApi } from "./js/api.js"; const state = { + isAuthenticated: false, + authMode: "login", me: null, phase: null, counts: null, @@ -21,11 +23,54 @@ function toast(msg, isError = false) { setTimeout(() => toastEl.classList.add("hidden"), 2000); } +function setAuthUI(isAuthed) { + const main = document.querySelector("main"); + const statusBar = document.querySelector(".status-bar"); + const authCard = $("auth-card"); + [main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed)); + if (authCard) authCard.classList.toggle("hidden", isAuthed); + const adminToggle = $("admin-toggle"); + if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed); +} + +function setAuthMode(mode) { + state.authMode = mode; + document.querySelectorAll(".auth-form").forEach(form => { + form.classList.toggle("hidden", form.dataset.mode !== mode); + }); + document.querySelectorAll("[data-auth-tab]").forEach(btn => { + btn.classList.toggle("active", btn.dataset.authTab === mode); + }); +} + +function clearUserState() { + state.me = null; + state.phase = null; + state.counts = null; + state.mySuggestions = []; + state.allSuggestions = []; + state.myVotes = []; + state.results = []; +} + +function handleAuthError(err) { + if (err?.status === 401) { + clearUserState(); + state.isAuthenticated = false; + setAuthUI(false); + return true; + } + toast(err?.message || "Unexpected error", true); + return false; +} + async function loadState() { const [me, stateData] = await Promise.all([api.me(), api.state()]); + state.isAuthenticated = true; state.me = me; state.phase = stateData.currentPhase; state.counts = stateData; + setAuthUI(true); renderPhasePill(); renderCounts(); const nameInput = $("name-input"); @@ -181,6 +226,52 @@ function renderResults() { } function setupHandlers() { + document.querySelectorAll("[data-auth-tab]").forEach(btn => { + btn.addEventListener("click", () => setAuthMode(btn.dataset.authTab)); + }); + setAuthMode(state.authMode); + + const loginForm = $("login-form"); + if (loginForm) { + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = $("login-username").value.trim(); + const password = $("login-password").value; + if (!username || !password) return toast("Username and password required", true); + try { + await api.login({ username, password }); + state.isAuthenticated = true; + setAuthUI(true); + await refreshPhaseData(); + toast("Logged in"); + } catch (err) { + if (err?.status === 401) return toast("Invalid username or password", true); + if (handleAuthError(err)) return; + } + }); + } + + const registerForm = $("register-form"); + if (registerForm) { + registerForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = $("register-username").value.trim(); + const password = $("register-password").value; + const displayName = $("register-displayName").value.trim(); + if (!username || !password) return toast("Username and password required", true); + try { + await api.register({ username, password, displayName }); + state.isAuthenticated = true; + setAuthUI(true); + await refreshPhaseData(); + toast("Registered"); + } catch (err) { + if (handleAuthError(err)) return; + toast(err.message, true); + } + }); + } + const nameInput = $("name-input"); if (nameInput) { ["focus", "input"].forEach(evt => { @@ -241,6 +332,20 @@ function setupHandlers() { $("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete")); $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete")); + const logoutBtn = $("logout"); + if (logoutBtn) { + logoutBtn.addEventListener("click", async () => { + try { + await api.logout(); + } catch (err) { + toast(err.message, true); + } + clearUserState(); + state.isAuthenticated = false; + setAuthUI(false); + }); + } + const adminToggle = $("admin-toggle"); const adminCard = $("admin-card"); const adminClose = $("admin-close"); @@ -263,8 +368,13 @@ async function adminAction(fn, successMessage) { } async function refreshPhaseData() { - await loadState(); - await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]); + try { + await loadState(); + await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]); + } catch (err) { + if (handleAuthError(err)) return; + throw err; + } } function buildCard(s, { showAuthor = false, allowDelete = false }) { @@ -328,6 +438,7 @@ function openLightbox(url, title) { } function applyNameRequirementUI() { + if (!state.isAuthenticated) return; const requiresName = !state.me?.displayName?.trim(); const warning = $("name-warning"); if (warning) warning.classList.toggle("hidden", !requiresName); @@ -359,7 +470,11 @@ async function main() { } catch (err) { toast(err.message, true); } - setInterval(refreshPhaseData, 4000); + setInterval(() => { + refreshPhaseData().catch(err => { + if (!handleAuthError(err)) toast(err.message, true); + }); + }, 4000); } main(); diff --git a/wwwroot/index.html b/wwwroot/index.html index 6bb98d3..b5a8604 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -9,11 +9,30 @@ + +
+
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index bb633c3..a94a0d5 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -24,7 +24,9 @@ async function request(path, { method = "GET", body, adminKey } = {}) { const data = await res.json(); msg = data.error || JSON.stringify(data); } catch { /* ignore */ } - throw new Error(msg); + const err = new Error(msg); + err.status = res.status; + throw err; } return res.status === 204 ? null : res.json(); } @@ -33,6 +35,9 @@ export const api = { state: () => request("/api/state"), me: () => request("/api/me"), setName: (name) => request("/api/me/name", { method: "POST", body: { name } }), + register: (payload) => request("/api/auth/register", { method: "POST", body: payload }), + login: (payload) => request("/api/auth/login", { method: "POST", body: payload }), + logout: () => request("/api/auth/logout", { method: "POST" }), mySuggestions: () => request("/api/suggestions/mine"), createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), diff --git a/wwwroot/styles.css b/wwwroot/styles.css index 6fcf1a2..df914a7 100644 --- a/wwwroot/styles.css +++ b/wwwroot/styles.css @@ -245,6 +245,9 @@ input[type="range"].full-slider::-moz-range-track { } .toast.error { background: #dc2626; } +.auth-card .active { font-weight: 700; } +.auth-form { margin-top: 8px; } + .admin-toggle { position: fixed; bottom: 18px;