Removed symbol requirement for password, fix formatting.
This commit is contained in:
2
API.md
2
API.md
@@ -9,7 +9,7 @@ GET /api/auth/options — `{ ownerExists }` for registration UX (hide admin-key
|
|||||||
POST /api/auth/login
|
POST /api/auth/login
|
||||||
POST /api/auth/logout
|
POST /api/auth/logout
|
||||||
Display names are set during registration and are immutable afterward.
|
Display names are set during registration and are immutable afterward.
|
||||||
Passwords must be 8-128 chars and contain uppercase, lowercase, number, and symbol.
|
Passwords must be 8-128 chars and contain uppercase, lowercase and number.
|
||||||
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
|
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
|
||||||
|
|
||||||
## State (requires auth)
|
## State (requires auth)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public record UnlinkSuggestionsRequest(int SuggestionId);
|
|||||||
public record GrantJokerRequest(Guid PlayerId);
|
public record GrantJokerRequest(Guid PlayerId);
|
||||||
|
|
||||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||||
|
|
||||||
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
||||||
|
|
||||||
public record AdminPasswordRequest(string Password);
|
public record AdminPasswordRequest(string Password);
|
||||||
|
|||||||
@@ -4,17 +4,7 @@ namespace GameList.Contracts;
|
|||||||
|
|
||||||
public record SuggestionCreatedResponse(int Id);
|
public record SuggestionCreatedResponse(int Id);
|
||||||
|
|
||||||
public record SuggestionUpdatedResponse(
|
public record SuggestionUpdatedResponse(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string? Genre,
|
|
||||||
string? Description,
|
|
||||||
string? ScreenshotUrl,
|
|
||||||
string? YoutubeUrl,
|
|
||||||
string? GameUrl,
|
|
||||||
int? MinPlayers,
|
|
||||||
int? MaxPlayers
|
|
||||||
);
|
|
||||||
|
|
||||||
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
|
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
|
||||||
|
|
||||||
@@ -25,6 +15,7 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated
|
|||||||
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
||||||
|
|
||||||
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
||||||
|
|
||||||
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
|
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
|
||||||
|
|
||||||
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
||||||
@@ -37,50 +28,14 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
|
|||||||
|
|
||||||
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
||||||
|
|
||||||
public record ResultItemDto(
|
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string? Author,
|
|
||||||
int? MinPlayers,
|
|
||||||
int? MaxPlayers,
|
|
||||||
int Total,
|
|
||||||
int Count,
|
|
||||||
double Average,
|
|
||||||
IReadOnlyList<int> Votes,
|
|
||||||
int? MyVote,
|
|
||||||
string? ScreenshotUrl,
|
|
||||||
string? YoutubeUrl,
|
|
||||||
string? GameUrl,
|
|
||||||
string? Description,
|
|
||||||
string? Genre,
|
|
||||||
int? ParentSuggestionId,
|
|
||||||
IReadOnlyList<int> LinkedIds,
|
|
||||||
IReadOnlyList<string> LinkedTitles
|
|
||||||
);
|
|
||||||
|
|
||||||
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
||||||
|
|
||||||
public record AuthOptionsResponse(bool OwnerExists);
|
public record AuthOptionsResponse(bool OwnerExists);
|
||||||
|
|
||||||
public record StateSummaryResponse(
|
public record StateSummaryResponse(Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
||||||
Phase CurrentPhase,
|
|
||||||
bool VotesFinal,
|
|
||||||
bool HasJoker,
|
|
||||||
bool ResultsOpen,
|
|
||||||
DateTimeOffset UpdatedAt,
|
|
||||||
int Players,
|
|
||||||
int Suggestions,
|
|
||||||
int Votes
|
|
||||||
);
|
|
||||||
|
|
||||||
public record MeResponse(
|
public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
|
||||||
Guid Id,
|
|
||||||
string Username,
|
|
||||||
string? DisplayName,
|
|
||||||
bool IsAdmin,
|
|
||||||
bool IsOwner,
|
|
||||||
Phase CurrentPhase,
|
|
||||||
bool VotesFinal,
|
|
||||||
bool HasJoker
|
|
||||||
);
|
|
||||||
|
|
||||||
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);
|
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using GameList.Data;
|
|||||||
using GameList.Contracts;
|
using GameList.Contracts;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,8 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await db.Players
|
await db.Players.Where(p => p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
||||||
.Where(p => p.Suggestions.Any())
|
await db.Players.Where(p => !p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
|
||||||
await db.Players
|
|
||||||
.Where(p => !p.Suggestions.Any())
|
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -38,22 +34,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<IResult> GetVoteStatusAsync()
|
public async Task<IResult> GetVoteStatusAsync()
|
||||||
{
|
{
|
||||||
var voters = await db.Players
|
var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.IsAdmin, p.IsOwner, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
|
||||||
.AsNoTracking()
|
|
||||||
.Include(p => p.Suggestions)
|
|
||||||
.OrderBy(p => p.DisplayName ?? p.Username)
|
|
||||||
.Select(p => new VoteStatusDto(
|
|
||||||
p.Id,
|
|
||||||
p.DisplayName ?? p.Username,
|
|
||||||
p.Username,
|
|
||||||
p.CurrentPhase,
|
|
||||||
p.VotesFinal,
|
|
||||||
p.HasJoker,
|
|
||||||
p.IsAdmin,
|
|
||||||
p.IsOwner,
|
|
||||||
p.Suggestions.Count,
|
|
||||||
p.Suggestions.Select(s => s.Name).ToList()))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||||
var ready = waiting.Count == 0;
|
var ready = waiting.Count == 0;
|
||||||
@@ -131,9 +112,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
|
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
|
||||||
if (suggestionIds.Count > 0)
|
if (suggestionIds.Count > 0)
|
||||||
{
|
{
|
||||||
await db.Suggestions
|
await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||||
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
|
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
|
||||||
|
|
||||||
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using GameList.Data;
|
|||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
@@ -24,7 +23,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
|
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
|
||||||
{
|
{
|
||||||
authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username?.Trim() ?? "unknown", "validation-failed");
|
authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username.Trim(), "validation-failed");
|
||||||
return EndpointHelpers.BadRequestError(registrationError);
|
return EndpointHelpers.BadRequestError(registrationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
|
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
|
||||||
{
|
{
|
||||||
authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username?.Trim() ?? "unknown", "validation-failed");
|
authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username.Trim(), "validation-failed");
|
||||||
return EndpointHelpers.BadRequestError(loginError);
|
return EndpointHelpers.BadRequestError(loginError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,10 @@ internal static class AuthValidator
|
|||||||
var hasUpper = password.Any(char.IsUpper);
|
var hasUpper = password.Any(char.IsUpper);
|
||||||
var hasLower = password.Any(char.IsLower);
|
var hasLower = password.Any(char.IsLower);
|
||||||
var hasDigit = password.Any(char.IsDigit);
|
var hasDigit = password.Any(char.IsDigit);
|
||||||
var hasSymbol = password.Any(ch => !char.IsLetterOrDigit(ch));
|
if (!hasUpper || !hasLower || !hasDigit)
|
||||||
if (!hasUpper || !hasLower || !hasDigit || !hasSymbol)
|
|
||||||
{
|
{
|
||||||
validated = default;
|
validated = default;
|
||||||
error = "Password must include uppercase, lowercase, number, and symbol.";
|
error = "Password must include at least one uppercase and one lowercase characters and and digit.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +94,5 @@ internal static class AuthValidator
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct ValidatedRegistration(
|
public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string DisplayName, string? AdminKey);
|
||||||
string Username,
|
|
||||||
string NormalizedUsername,
|
|
||||||
string DisplayName,
|
|
||||||
string? AdminKey
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
{
|
{
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||||
var summary = new StateSummaryResponse(
|
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
|
||||||
phase,
|
|
||||||
player.VotesFinal,
|
|
||||||
player.HasJoker,
|
|
||||||
state.ResultsOpen,
|
|
||||||
state.UpdatedAt,
|
|
||||||
await db.Players.CountAsync(),
|
|
||||||
await db.Suggestions.CountAsync(),
|
|
||||||
await db.Votes.CountAsync()
|
|
||||||
);
|
|
||||||
return Results.Ok(summary);
|
return Results.Ok(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,16 +19,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
{
|
{
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||||
return Results.Ok(new MeResponse(
|
return Results.Ok(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
|
||||||
player.Id,
|
|
||||||
player.Username,
|
|
||||||
player.DisplayName,
|
|
||||||
player.IsAdmin,
|
|
||||||
player.IsOwner,
|
|
||||||
phase,
|
|
||||||
player.VotesFinal,
|
|
||||||
player.HasJoker
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> NextPhaseAsync(Player player)
|
public async Task<IResult> NextPhaseAsync(Player player)
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public class AdminTests
|
|||||||
|
|
||||||
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
{
|
{
|
||||||
playerId = playerId,
|
playerId,
|
||||||
isAdmin = true
|
isAdmin = true
|
||||||
});
|
});
|
||||||
grant.EnsureSuccessStatusCode();
|
grant.EnsureSuccessStatusCode();
|
||||||
@@ -120,7 +120,7 @@ public class AdminTests
|
|||||||
|
|
||||||
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
{
|
{
|
||||||
playerId = playerId,
|
playerId,
|
||||||
isAdmin = false
|
isAdmin = false
|
||||||
});
|
});
|
||||||
revoke.EnsureSuccessStatusCode();
|
revoke.EnsureSuccessStatusCode();
|
||||||
@@ -147,10 +147,7 @@ public class AdminTests
|
|||||||
});
|
});
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
|
||||||
|
|
||||||
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}")
|
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") { Content = JsonContent.Create(new { password = AdminPassword }) });
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { password = AdminPassword })
|
|
||||||
});
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +209,7 @@ public class AdminTests
|
|||||||
Score = 8
|
Score = 8
|
||||||
});
|
});
|
||||||
|
|
||||||
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
|
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") { Content = JsonContent.Create(new { password = AdminPassword }) });
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { password = AdminPassword })
|
|
||||||
});
|
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -630,10 +624,7 @@ public class AdminTests
|
|||||||
|
|
||||||
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
|
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
|
||||||
|
|
||||||
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
|
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") { Content = JsonContent.Create(new { password = "wrong" }) });
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { password = "wrong" })
|
|
||||||
});
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class AuthTests
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
|
||||||
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
|
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
Assert.Equal("Password must include uppercase, lowercase, number, and symbol.", json.GetProperty("error").GetString());
|
Assert.Equal("Password must include at least one uppercase and one lowercase characters and and digit.", json.GetProperty("error").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user