Fix Docker
This commit is contained in:
94
PurrLobby/Controllers/AuthenticationController.cs
Normal file
94
PurrLobby/Controllers/AuthenticationController.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PurrLobby.Models;
|
||||
using PurrLobby.Services;
|
||||
|
||||
namespace PurrLobby.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("auth")]
|
||||
public class AuthenticationController : ControllerBase
|
||||
{
|
||||
private readonly IAuthenticationService _authService;
|
||||
|
||||
public AuthenticationController(IAuthenticationService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<ActionResult<UserSession>> CreateSession([FromBody] CreateSessionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserId) || string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return BadRequest(new { error = "UserId and DisplayName are required" });
|
||||
}
|
||||
|
||||
var session = await _authService.CreateSessionAsync(request.UserId, request.DisplayName);
|
||||
return Ok(session);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("validate")]
|
||||
public async Task<ActionResult<TokenValidationResult>> ValidateToken([FromBody] ValidateTokenRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return BadRequest(new { error = "Token is required" });
|
||||
}
|
||||
|
||||
var result = await _authService.ValidateTokenAsync(request.Token);
|
||||
return Ok(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("revoke")]
|
||||
public async Task<ActionResult<bool>> RevokeToken([FromBody] RevokeTokenRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return BadRequest(new { error = "Token is required" });
|
||||
}
|
||||
|
||||
var result = await _authService.RevokeTokenAsync(request.Token);
|
||||
return Ok(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateSessionRequest
|
||||
{
|
||||
public required string UserId { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
}
|
||||
|
||||
public class ValidateTokenRequest
|
||||
{
|
||||
public required string Token { get; set; }
|
||||
}
|
||||
|
||||
public class RevokeTokenRequest
|
||||
{
|
||||
public required string Token { get; set; }
|
||||
}
|
||||
94
PurrLobby/Middleware/AuthenticationMiddleware.cs
Normal file
94
PurrLobby/Middleware/AuthenticationMiddleware.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using PurrLobby.Models;
|
||||
using PurrLobby.Services;
|
||||
|
||||
namespace PurrLobby.Middleware;
|
||||
|
||||
public class AuthenticationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IAuthenticationService _authService;
|
||||
private static readonly string[] ExcludedPaths =
|
||||
{
|
||||
"/auth/create",
|
||||
"/health",
|
||||
"/metrics"
|
||||
};
|
||||
|
||||
public AuthenticationMiddleware(RequestDelegate next, IAuthenticationService authService)
|
||||
{
|
||||
_next = next;
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// Skip authentication for excluded paths
|
||||
if (ExcludedPaths.Any(excluded => path.StartsWith(excluded)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract token from Authorization header or query parameter
|
||||
var token = ExtractToken(context);
|
||||
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
await WriteUnauthorizedResponse(context, "Authentication token required");
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(token);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
await WriteUnauthorizedResponse(context, validation.ErrorMessage ?? "Invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user info to context for downstream handlers
|
||||
context.Items["User"] = new AuthenticatedUser
|
||||
{
|
||||
UserId = validation.UserId!,
|
||||
DisplayName = validation.DisplayName!,
|
||||
SessionToken = token
|
||||
};
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string? ExtractToken(HttpContext context)
|
||||
{
|
||||
// Try Authorization header first
|
||||
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
|
||||
{
|
||||
return authHeader["Bearer ".Length..];
|
||||
}
|
||||
|
||||
// Fall back to query parameter
|
||||
return context.Request.Query["token"].FirstOrDefault();
|
||||
}
|
||||
|
||||
private static async Task WriteUnauthorizedResponse(HttpContext context, string message)
|
||||
{
|
||||
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new { error = "Unauthorized", message };
|
||||
var json = JsonSerializer.Serialize(response);
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthenticationMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseAuthentication(this IApplicationBuilder builder)
|
||||
{
|
||||
return builder.UseMiddleware<AuthenticationMiddleware>();
|
||||
}
|
||||
}
|
||||
37
PurrLobby/Models/Authentication.cs
Normal file
37
PurrLobby/Models/Authentication.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PurrLobby.Models;
|
||||
|
||||
public record UserSession
|
||||
{
|
||||
public required string SessionToken { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAtUtc { get; init; }
|
||||
public string? PublicKey { get; init; }
|
||||
}
|
||||
|
||||
public class AuthenticatedUser
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public required string SessionToken { get; init; }
|
||||
}
|
||||
|
||||
public class TokenValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? UserId { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public static class SecurityConstants
|
||||
{
|
||||
public const int TokenLength = 64;
|
||||
public const int SessionExpirationHours = 24;
|
||||
public const int MaxDisplayNameLength = 64;
|
||||
public const int MaxUserIdLength = 128;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ namespace PurrLobby.Models;
|
||||
// user in lobby
|
||||
public class LobbyUser
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SessionToken { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public int userPing { get; set; }
|
||||
public bool IsReady { get; set; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpLogging;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
@@ -84,6 +84,7 @@ builder.Services.AddSwaggerGen(o =>
|
||||
}
|
||||
});
|
||||
});
|
||||
builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();
|
||||
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
|
||||
builder.Services.AddSingleton<ILobbyService, LobbyService>();
|
||||
builder.Services.AddRazorPages(); // Register Razor Pages services
|
||||
@@ -92,16 +93,17 @@ if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler();
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
app.UseHttpsRedirection();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseResponseCompression();
|
||||
app.UseHttpLogging();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseWebSockets();
|
||||
app.UseStaticFiles();
|
||||
app.UseSwagger();
|
||||
@@ -167,14 +169,7 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
|
||||
return Guid.TryParse(v, out gameId);
|
||||
}
|
||||
|
||||
static string EnsureUserIdCookie(HttpContext http)
|
||||
{
|
||||
if (http.Request.Cookies.TryGetValue("userId", out var v) && !string.IsNullOrWhiteSpace(v))
|
||||
return v;
|
||||
var newId = Guid.NewGuid().ToString("N");
|
||||
http.Response.Cookies.Append("userId", newId, BuildStdCookieOptions());
|
||||
return newId;
|
||||
}
|
||||
|
||||
|
||||
// create lobby
|
||||
app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) =>
|
||||
@@ -182,17 +177,24 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
if (req.MaxPlayers <= 0) return Results.BadRequest("MaxPlayers must be > 0");
|
||||
if (string.IsNullOrWhiteSpace(req.OwnerDisplayName)) return Results.BadRequest("OwnerDisplayName required");
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var ownerUserId = EnsureUserIdCookie(http);
|
||||
var lobby = await service.CreateLobbyAsync(gameId, ownerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct);
|
||||
var lobby = await service.CreateLobbyAsync(gameId, user.SessionToken, req.MaxPlayers, req.Properties, ct);
|
||||
return Results.Ok(lobby);
|
||||
}
|
||||
catch (ArgumentException ax)
|
||||
{
|
||||
return Results.BadRequest(ax.Message);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
@@ -207,13 +209,16 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// join lobby
|
||||
app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService service, string lobbyId, JoinLobbyRequest req, CancellationToken ct) =>
|
||||
app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
if (string.IsNullOrWhiteSpace(req.DisplayName)) return Results.BadRequest("DisplayName required");
|
||||
var userId = EnsureUserIdCookie(http);
|
||||
var lobby = await service.JoinLobbyAsync(gameId, lobbyId, userId, req.DisplayName, ct);
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var lobby = await service.JoinLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
||||
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
|
||||
}).WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
@@ -222,7 +227,7 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
|
||||
op.Description = "Adds the current cookie user (server generated userId) to the specified lobby.";
|
||||
return op;
|
||||
})
|
||||
.Accepts<JoinLobbyRequest>("application/json")
|
||||
|
||||
.Produces<Lobby>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
@@ -233,8 +238,12 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
var userId = EnsureUserIdCookie(http);
|
||||
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, userId, ct);
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
||||
return ok ? Results.Ok() : Results.NotFound();
|
||||
}).WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
@@ -248,24 +257,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// leave any lobby by user id
|
||||
app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService service, string userId, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
var ok = await service.LeaveLobbyAsync(gameId, userId, ct);
|
||||
return ok ? Results.Ok() : Results.NotFound();
|
||||
}).WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
{
|
||||
op.Summary = "Force a user to leave their lobby";
|
||||
op.Description = "Removes the specified user from whichever lobby they are currently in for the current game.";
|
||||
return op;
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
|
||||
// search lobbies
|
||||
app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, int maxRoomsToFind = 10, CancellationToken ct = default) =>
|
||||
@@ -299,8 +291,12 @@ app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service,
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
var userId = EnsureUserIdCookie(http);
|
||||
var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct);
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var lobby = await service.GetLobbyAsync(gameId, lobbyId, user.SessionToken, ct);
|
||||
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
@@ -360,7 +356,12 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
if (string.IsNullOrWhiteSpace(req.Key)) return Results.BadRequest("Key is required");
|
||||
var ok = await service.SetLobbyDataAsync(gameId, lobbyId, req.Key, req.Value, ct);
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ok = await service.SetLobbyDataAsync(gameId, lobbyId, user.SessionToken, req.Key, req.Value, ct);
|
||||
return ok ? Results.Ok() : Results.NotFound();
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
@@ -381,8 +382,12 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required");
|
||||
var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct);
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ok = await service.SetIsReadyAsync(gameId, lobbyId, user.SessionToken, req.IsReady, ct);
|
||||
return ok ? Results.Ok() : Results.NotFound();
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
@@ -398,42 +403,18 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// set all ready
|
||||
app.MapPost("/lobbies/{lobbyId}/ready/all", async (HttpContext http, ILobbyService service, string lobbyId, AllReadyRequest req, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required");
|
||||
var lobby = await service.GetLobbyAsync(gameId, lobbyId, req.UserId, ct);
|
||||
if (lobby is null) return Results.NotFound();
|
||||
if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
var ok = await service.SetAllReadyAsync(gameId, lobbyId, ct);
|
||||
return ok ? Results.Ok() : Results.BadRequest();
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
{
|
||||
op.Summary = "Mark all members ready";
|
||||
op.Description = "Owner only. Sets all members' ready state to true and broadcasts all_ready. Requires userId in body identifying the acting owner.";
|
||||
return op;
|
||||
})
|
||||
.Accepts<AllReadyRequest>("application/json")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// start lobby
|
||||
app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
var userId = EnsureUserIdCookie(http);
|
||||
var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct);
|
||||
if (lobby is null) return Results.NotFound();
|
||||
if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, ct);
|
||||
return ok ? Results.Ok() : Results.BadRequest();
|
||||
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, user.SessionToken, ct);
|
||||
return ok ? Results.Ok() : Results.NotFound();
|
||||
})
|
||||
.WithTags("Lobbies")
|
||||
.WithOpenApi(op =>
|
||||
@@ -456,15 +437,17 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby
|
||||
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
|
||||
return Results.BadRequest("Missing or invalid gameId cookie");
|
||||
|
||||
var userId = EnsureUserIdCookie(http);
|
||||
var user = http.Items["User"] as AuthenticatedUser;
|
||||
if (user == null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
using var socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||
await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct);
|
||||
await hub.HandleConnectionAsync(gameId, lobbyId, user.SessionToken, socket, ct);
|
||||
return Results.Empty;
|
||||
}).WithTags("Lobbies").WithOpenApi(op =>
|
||||
{
|
||||
op.Summary = "Lobby Websocket";
|
||||
op.Description = "WebSocket for lobby-specific updates. Uses server-generated 'userId' cookie.";
|
||||
op.Description = "WebSocket for lobby-specific updates. Requires valid session token.";
|
||||
return op;
|
||||
});
|
||||
|
||||
@@ -504,7 +487,5 @@ app.Run();
|
||||
// dto records
|
||||
public record SetGameRequest(Guid GameId);
|
||||
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
|
||||
public record JoinLobbyRequest(string DisplayName);
|
||||
public record ReadyRequest(string UserId, bool IsReady);
|
||||
public record AllReadyRequest(string UserId);
|
||||
public record ReadyRequest(bool IsReady);
|
||||
public record LobbyDataRequest(string Key, string Value);
|
||||
@@ -6,7 +6,7 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:63036;http://localhost:63037"
|
||||
"applicationUrl": "https://localhost:7443;http://localhost:5080"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
@@ -19,14 +19,12 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy static web assets to output on build so running the exe from bin works -->
|
||||
<ItemGroup>
|
||||
<Content Update="wwwroot\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy certificates to output directory -->
|
||||
<ItemGroup>
|
||||
<Content Include="Certs\*.pem">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
178
PurrLobby/Services/AuthenticationService.cs
Normal file
178
PurrLobby/Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using PurrLobby.Models;
|
||||
|
||||
namespace PurrLobby.Services;
|
||||
|
||||
public interface IAuthenticationService
|
||||
{
|
||||
Task<UserSession> CreateSessionAsync(string userId, string displayName, CancellationToken ct = default);
|
||||
Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct = default);
|
||||
Task<bool> RevokeTokenAsync(string token, CancellationToken ct = default);
|
||||
Task<bool> ExtendSessionAsync(string token, CancellationToken ct = default);
|
||||
Task CleanupExpiredSessionsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class AuthenticationService : IAuthenticationService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, UserSession> _activeSessions = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
public AuthenticationService()
|
||||
{
|
||||
// Start cleanup timer to run every hour
|
||||
_cleanupTimer = new Timer(async _ => await CleanupExpiredSessionsAsync(),
|
||||
null, TimeSpan.FromHours(1), TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
public Task<UserSession> CreateSessionAsync(string userId, string displayName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId) || userId.Length > SecurityConstants.MaxUserIdLength)
|
||||
throw new ArgumentException("Invalid user ID", nameof(userId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
throw new ArgumentException("Display name cannot be empty", nameof(displayName));
|
||||
|
||||
var sanitizedDisplayName = SanitizeString(displayName, SecurityConstants.MaxDisplayNameLength);
|
||||
var sessionToken = GenerateSecureToken();
|
||||
var keyPair = GenerateKeyPair();
|
||||
|
||||
var session = new UserSession
|
||||
{
|
||||
SessionToken = sessionToken,
|
||||
UserId = userId,
|
||||
DisplayName = sanitizedDisplayName,
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddHours(SecurityConstants.SessionExpirationHours),
|
||||
PublicKey = keyPair.Public
|
||||
};
|
||||
|
||||
_activeSessions[sessionToken] = session;
|
||||
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
public Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return Task.FromResult(new TokenValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Token is required"
|
||||
});
|
||||
|
||||
if (!_activeSessions.TryGetValue(token, out var session))
|
||||
return Task.FromResult(new TokenValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Invalid token"
|
||||
});
|
||||
|
||||
if (DateTime.UtcNow > session.ExpiresAtUtc)
|
||||
{
|
||||
_activeSessions.TryRemove(token, out _);
|
||||
return Task.FromResult(new TokenValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Token expired"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new TokenValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
UserId = session.UserId,
|
||||
DisplayName = session.DisplayName
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> RevokeTokenAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return Task.FromResult(false);
|
||||
|
||||
return Task.FromResult(_activeSessions.TryRemove(token, out _));
|
||||
}
|
||||
|
||||
public Task<bool> ExtendSessionAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return Task.FromResult(false);
|
||||
|
||||
if (_activeSessions.TryGetValue(token, out var session))
|
||||
{
|
||||
var updatedSession = session with
|
||||
{
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddHours(SecurityConstants.SessionExpirationHours)
|
||||
};
|
||||
_activeSessions[token] = updatedSession;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task CleanupExpiredSessionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredTokens = new List<string>();
|
||||
|
||||
foreach (var kvp in _activeSessions)
|
||||
{
|
||||
if (now > kvp.Value.ExpiresAtUtc)
|
||||
{
|
||||
expiredTokens.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var token in expiredTokens)
|
||||
{
|
||||
_activeSessions.TryRemove(token, out _);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(SecurityConstants.TokenLength);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.Replace("=", "");
|
||||
}
|
||||
|
||||
private static (string Public, string Private) GenerateKeyPair()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var publicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
|
||||
var privateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey());
|
||||
return (publicKey, privateKey);
|
||||
}
|
||||
|
||||
private static string SanitizeString(string? input, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return string.Empty;
|
||||
|
||||
var trimmed = input.Trim();
|
||||
if (trimmed.Length > maxLength)
|
||||
trimmed = trimmed.Substring(0, maxLength);
|
||||
|
||||
// Remove potentially harmful characters
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in trimmed)
|
||||
{
|
||||
if (char.IsControl(c) && c != '\t' && c != '\n' && c != '\r')
|
||||
continue;
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace PurrLobby.Services;
|
||||
|
||||
public interface ILobbyEventHub
|
||||
{
|
||||
Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct);
|
||||
Task HandleConnectionAsync(Guid gameId, string lobbyId, string sessionToken, WebSocket socket, CancellationToken ct);
|
||||
Task BroadcastAsync(Guid gameId, string lobbyId, object evt, CancellationToken ct = default);
|
||||
Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
|
||||
private sealed class Subscriber
|
||||
{
|
||||
public required string SessionToken { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public DateTime LastPongUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -39,6 +40,7 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
};
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IAuthenticationService _authService;
|
||||
|
||||
// ping settings
|
||||
private const int PingIntervalSeconds = 10;
|
||||
@@ -47,16 +49,29 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
// idle cleanup delay
|
||||
private const int IdleLobbyCleanupDelaySeconds = 45;
|
||||
|
||||
public LobbyEventHub(IServiceScopeFactory scopeFactory)
|
||||
public LobbyEventHub(IServiceScopeFactory scopeFactory, IAuthenticationService authService)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public async Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct)
|
||||
public async Task HandleConnectionAsync(Guid gameId, string lobbyId, string sessionToken, WebSocket socket, CancellationToken ct)
|
||||
{
|
||||
// Validate session token before allowing connection
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "Invalid session token", ct);
|
||||
}
|
||||
catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
var key = new LobbyKey(gameId, lobbyId);
|
||||
var bag = _subscribers.GetOrAdd(key, _ => new());
|
||||
var sub = new Subscriber { UserId = userId, LastPongUtc = DateTime.UtcNow };
|
||||
var sub = new Subscriber { SessionToken = sessionToken, UserId = validation.UserId!, LastPongUtc = DateTime.UtcNow };
|
||||
bag.TryAdd(socket, sub);
|
||||
|
||||
EnsurePingLoopStarted(key);
|
||||
@@ -150,11 +165,11 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
{
|
||||
bag.TryRemove(ws, out _);
|
||||
try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "pong timeout", CancellationToken.None); } catch { }
|
||||
try
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<ILobbyService>();
|
||||
await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, sub.UserId, CancellationToken.None);
|
||||
await svc.LeaveLobbyAsync(key.GameId, sub.SessionToken, CancellationToken.None);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
@@ -180,7 +195,7 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
{
|
||||
foreach (var m in members)
|
||||
{
|
||||
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { }
|
||||
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.SessionToken, CancellationToken.None); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +299,7 @@ public class LobbyEventHub : ILobbyEventHub
|
||||
{
|
||||
foreach (var m in members)
|
||||
{
|
||||
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { }
|
||||
try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.SessionToken, CancellationToken.None); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ namespace PurrLobby.Services;
|
||||
// lobby service core logic
|
||||
public interface ILobbyService
|
||||
{
|
||||
Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default);
|
||||
Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default);
|
||||
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default);
|
||||
Task<bool> LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default);
|
||||
Task<Lobby> CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default);
|
||||
Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
|
||||
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
|
||||
Task<bool> LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default);
|
||||
Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default);
|
||||
Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default);
|
||||
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default);
|
||||
Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default);
|
||||
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default);
|
||||
Task<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default);
|
||||
Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
|
||||
Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
|
||||
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
|
||||
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
|
||||
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
|
||||
|
||||
// stats
|
||||
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
|
||||
@@ -31,6 +31,7 @@ internal class LobbyState
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required Guid GameId { get; init; }
|
||||
public required string OwnerSessionToken { get; set; }
|
||||
public required string OwnerUserId { get; set; }
|
||||
public int MaxPlayers { get; init; }
|
||||
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
|
||||
@@ -52,13 +53,15 @@ public class LobbyService : ILobbyService
|
||||
private const int MaxPropertyCount = 32;
|
||||
|
||||
private readonly ConcurrentDictionary<string, LobbyState> _lobbies = new();
|
||||
// user index key gameIdN:userId -> lobbyId
|
||||
// user index key gameIdN:sessionToken -> lobbyId
|
||||
private readonly ConcurrentDictionary<string, string> _userLobbyIndexByGame = new();
|
||||
private readonly ILobbyEventHub _events;
|
||||
private readonly IAuthenticationService _authService;
|
||||
|
||||
public LobbyService(ILobbyEventHub events)
|
||||
public LobbyService(ILobbyEventHub events, IAuthenticationService authService)
|
||||
{
|
||||
_events = events;
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
private static string SanitizeString(string? s, int maxLen)
|
||||
@@ -66,7 +69,7 @@ public class LobbyService : ILobbyService
|
||||
|
||||
private static bool IsInvalidId(string? id) => string.IsNullOrWhiteSpace(id) || id.Length > 128;
|
||||
|
||||
private static Lobby Project(LobbyState s, string? currentUserId = null)
|
||||
private async Task<Lobby> ProjectAsync(LobbyState s, string? currentSessionToken = null, CancellationToken ct = default)
|
||||
{
|
||||
var lobby = new Lobby
|
||||
{
|
||||
@@ -75,21 +78,34 @@ public class LobbyService : ILobbyService
|
||||
LobbyId = s.Id,
|
||||
LobbyCode = s.LobbyCode,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
IsOwner = currentUserId != null && string.Equals(s.OwnerUserId, currentUserId, StringComparison.Ordinal)
|
||||
IsOwner = false
|
||||
};
|
||||
|
||||
if (currentSessionToken != null)
|
||||
{
|
||||
var validation = await _authService.ValidateTokenAsync(currentSessionToken, ct);
|
||||
if (validation.IsValid && string.Equals(s.OwnerUserId, validation.UserId, StringComparison.Ordinal))
|
||||
{
|
||||
lobby.IsOwner = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in s.Properties)
|
||||
lobby.Properties[kv.Key] = kv.Value;
|
||||
foreach (var m in s.Members)
|
||||
lobby.Members.Add(new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady });
|
||||
lobby.Members.Add(new LobbyUser { SessionToken = m.SessionToken, UserId = m.UserId, DisplayName = m.DisplayName, IsReady = m.IsReady });
|
||||
return lobby;
|
||||
}
|
||||
|
||||
public async Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default)
|
||||
public async Task<Lobby> CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(ownerUserId))
|
||||
throw new ArgumentException("Invalid gameId or ownerUserId");
|
||||
if (gameId == Guid.Empty || IsInvalidId(sessionToken))
|
||||
throw new ArgumentException("Invalid gameId or sessionToken");
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
throw new UnauthorizedAccessException("Invalid session token");
|
||||
|
||||
var display = SanitizeString(ownerDisplayName, DisplayNameMaxLength);
|
||||
var clampedPlayers = Math.Clamp(maxPlayers, MinPlayers, MaxPlayersLimit);
|
||||
|
||||
string GenerateLobbyCode()
|
||||
@@ -112,7 +128,8 @@ public class LobbyService : ILobbyService
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
GameId = gameId,
|
||||
OwnerUserId = ownerUserId,
|
||||
OwnerSessionToken = sessionToken,
|
||||
OwnerUserId = validation.UserId!,
|
||||
MaxPlayers = clampedPlayers,
|
||||
Name = properties != null && properties.TryGetValue("Name", out var n) ? SanitizeString(n, NameMaxLength) : string.Empty,
|
||||
LobbyCode = GenerateLobbyCode()
|
||||
@@ -132,75 +149,100 @@ public class LobbyService : ILobbyService
|
||||
|
||||
state.Members.Add(new LobbyUser
|
||||
{
|
||||
Id = ownerUserId,
|
||||
DisplayName = display,
|
||||
SessionToken = sessionToken,
|
||||
UserId = validation.UserId!,
|
||||
DisplayName = validation.DisplayName!,
|
||||
IsReady = false
|
||||
});
|
||||
|
||||
_lobbies[state.Id] = state;
|
||||
_userLobbyIndexByGame[$"{gameId:N}:{ownerUserId}"] = state.Id;
|
||||
_userLobbyIndexByGame[$"{gameId:N}:{sessionToken}"] = state.Id;
|
||||
|
||||
await _events.BroadcastAsync(gameId, state.Id, new { type = "lobby_created", lobbyId = state.Id, ownerUserId, ownerDisplayName = display, maxPlayers = state.MaxPlayers }, ct);
|
||||
await _events.BroadcastAsync(gameId, state.Id, new { type = "lobby_created", lobbyId = state.Id, ownerUserId = validation.UserId, ownerDisplayName = validation.DisplayName, maxPlayers = state.MaxPlayers }, ct);
|
||||
|
||||
return Project(state, ownerUserId);
|
||||
return await ProjectAsync(state, sessionToken, ct);
|
||||
}
|
||||
|
||||
public Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default)
|
||||
public async Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return null;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return null;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
return null;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
return null;
|
||||
|
||||
// prevent multi lobby join per game
|
||||
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId)
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var existingLobbyId) && existingLobbyId != lobbyId)
|
||||
return null;
|
||||
|
||||
var name = SanitizeString(displayName, DisplayNameMaxLength);
|
||||
LobbyUser? existingMember = null;
|
||||
bool canJoin = false;
|
||||
|
||||
lock (state)
|
||||
{
|
||||
if (state.Started) return Task.FromResult<Lobby?>(null);
|
||||
if (state.Members.Any(m => m.Id == userId))
|
||||
return Task.FromResult<Lobby?>(Project(state, userId));
|
||||
if (state.Members.Count >= state.MaxPlayers)
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
state.Members.Add(new LobbyUser { Id = userId, DisplayName = name, IsReady = false });
|
||||
if (state.Started) return null;
|
||||
existingMember = state.Members.FirstOrDefault(m => m.SessionToken == sessionToken);
|
||||
if (existingMember != null)
|
||||
canJoin = true;
|
||||
else if (state.Members.Count < state.MaxPlayers)
|
||||
{
|
||||
state.Members.Add(new LobbyUser {
|
||||
SessionToken = sessionToken,
|
||||
UserId = validation.UserId!,
|
||||
DisplayName = validation.DisplayName!,
|
||||
IsReady = false
|
||||
});
|
||||
canJoin = true;
|
||||
}
|
||||
}
|
||||
_userLobbyIndexByGame[$"{gameId:N}:{userId}"] = lobbyId;
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_joined", userId, displayName = name }, ct);
|
||||
return Task.FromResult<Lobby?>(Project(state, userId));
|
||||
|
||||
if (!canJoin) return null;
|
||||
if (existingMember != null)
|
||||
return await ProjectAsync(state, sessionToken, ct);
|
||||
_userLobbyIndexByGame[$"{gameId:N}:{sessionToken}"] = lobbyId;
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_joined", userId = validation.UserId, displayName = validation.DisplayName }, ct);
|
||||
return await ProjectAsync(state, sessionToken, ct);
|
||||
}
|
||||
|
||||
public Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default)
|
||||
public async Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
|
||||
return Task.FromResult(false);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return false;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
var removed = false;
|
||||
string? newOwner = null;
|
||||
lock (state)
|
||||
{
|
||||
var idx = state.Members.FindIndex(m => m.Id == userId);
|
||||
var idx = state.Members.FindIndex(m => m.SessionToken == sessionToken);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var member = state.Members[idx];
|
||||
state.Members.RemoveAt(idx);
|
||||
removed = true;
|
||||
if (state.OwnerUserId == userId && state.Members.Count > 0)
|
||||
if (state.OwnerUserId == member.UserId && state.Members.Count > 0)
|
||||
{
|
||||
state.OwnerUserId = state.Members[0].Id; // promote first
|
||||
newOwner = state.OwnerUserId;
|
||||
var newOwnerMember = state.Members[0];
|
||||
state.OwnerUserId = newOwnerMember.UserId;
|
||||
state.OwnerSessionToken = newOwnerMember.SessionToken;
|
||||
newOwner = newOwnerMember.UserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
_userLobbyIndexByGame.TryRemove($"{gameId:N}:{userId}", out _);
|
||||
_userLobbyIndexByGame.TryRemove($"{gameId:N}:{sessionToken}", out _);
|
||||
if (removed)
|
||||
{
|
||||
// remove lobby if empty
|
||||
@@ -212,28 +254,28 @@ public class LobbyService : ILobbyService
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_left", userId, newOwnerUserId = newOwner }, ct);
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_left", userId = validation.UserId, newOwnerUserId = newOwner }, ct);
|
||||
}
|
||||
}
|
||||
return Task.FromResult(removed);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public Task<bool> LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default)
|
||||
public async Task<bool> LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(userId))
|
||||
return Task.FromResult(false);
|
||||
if (gameId == Guid.Empty || IsInvalidId(sessionToken))
|
||||
return false;
|
||||
|
||||
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var lobbyId))
|
||||
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var lobbyId))
|
||||
{
|
||||
return LeaveLobbyAsync(gameId, lobbyId, userId, ct);
|
||||
return await LeaveLobbyAsync(gameId, lobbyId, sessionToken, ct);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default)
|
||||
public async Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty)
|
||||
return Task.FromResult(new List<Lobby>());
|
||||
return new List<Lobby>();
|
||||
|
||||
var take = Math.Clamp(maxRoomsToFind, 1, 100);
|
||||
IEnumerable<LobbyState> query = _lobbies.Values.Where(l => l.GameId == gameId && !l.Started && l.Members.Count < l.MaxPlayers);
|
||||
@@ -247,55 +289,72 @@ public class LobbyService : ILobbyService
|
||||
query = query.Where(l => l.Properties.TryGetValue(k, out var pv) && string.Equals(pv, v, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
var list = query.OrderByDescending(l => l.CreatedAtUtc).Take(take).Select(s => Project(s)).ToList();
|
||||
return Task.FromResult(list);
|
||||
var list = new List<Lobby>();
|
||||
foreach (var state in query.OrderByDescending(l => l.CreatedAtUtc).Take(take))
|
||||
{
|
||||
list.Add(await ProjectAsync(state, null, ct));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default)
|
||||
public async Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
|
||||
return Task.FromResult(false);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return false;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
lock (state)
|
||||
{
|
||||
if (state.Started) return Task.FromResult(false);
|
||||
var m = state.Members.FirstOrDefault(x => x.Id == userId);
|
||||
if (m is null) return Task.FromResult(false);
|
||||
if (state.Started) return false;
|
||||
var m = state.Members.FirstOrDefault(x => x.SessionToken == sessionToken);
|
||||
if (m is null) return false;
|
||||
m.IsReady = isReady;
|
||||
}
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId, isReady }, ct);
|
||||
return Task.FromResult(true);
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId = validation.UserId, isReady }, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default)
|
||||
public async Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
|
||||
return Task.FromResult(false);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return false;
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
|
||||
// Only owner can set lobby data
|
||||
if (state.OwnerUserId != validation.UserId)
|
||||
return false;
|
||||
|
||||
lock (state)
|
||||
{
|
||||
var k = SanitizeString(key, PropertyKeyMaxLength);
|
||||
if (string.IsNullOrEmpty(k)) return Task.FromResult(false);
|
||||
if (string.IsNullOrEmpty(k)) return false;
|
||||
var v = SanitizeString(value, PropertyValueMaxLength);
|
||||
if (!state.Properties.ContainsKey(k) && state.Properties.Count >= MaxPropertyCount)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
|
||||
state.Properties[k] = v;
|
||||
if (string.Equals(k, "Name", StringComparison.OrdinalIgnoreCase))
|
||||
state.Name = SanitizeString(v, NameMaxLength);
|
||||
}
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_data", key, value }, ct);
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default)
|
||||
@@ -323,56 +382,59 @@ public class LobbyService : ILobbyService
|
||||
{
|
||||
return Task.FromResult(state.Members.Select(m => new LobbyUser
|
||||
{
|
||||
Id = m.Id,
|
||||
SessionToken = m.SessionToken,
|
||||
UserId = m.UserId,
|
||||
DisplayName = m.DisplayName,
|
||||
IsReady = m.IsReady
|
||||
}).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
|
||||
|
||||
|
||||
public async Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
|
||||
return Task.FromResult(false);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return false;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult(false);
|
||||
lock (state)
|
||||
{
|
||||
if (state.Started) return Task.FromResult(false);
|
||||
foreach (var m in state.Members)
|
||||
m.IsReady = true;
|
||||
}
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "all_ready" }, ct);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
return false;
|
||||
|
||||
public Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
|
||||
return Task.FromResult(false);
|
||||
// Only owner can start lobby
|
||||
if (state.OwnerUserId != validation.UserId)
|
||||
return false;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult(false);
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult(false);
|
||||
if (state.Started) return Task.FromResult(false);
|
||||
if (state.Started) return false;
|
||||
state.Started = true;
|
||||
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_started" }, ct);
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default)
|
||||
public async Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default)
|
||||
{
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId))
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
|
||||
return null;
|
||||
|
||||
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
|
||||
if (!validation.IsValid)
|
||||
return null;
|
||||
|
||||
if (!_lobbies.TryGetValue(lobbyId, out var state))
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
return null;
|
||||
if (state.GameId != gameId)
|
||||
return Task.FromResult<Lobby?>(null);
|
||||
return Task.FromResult<Lobby?>(Project(state, currentUserId));
|
||||
return null;
|
||||
|
||||
// Check if user is member of this lobby
|
||||
if (!state.Members.Any(m => m.SessionToken == sessionToken))
|
||||
return null;
|
||||
|
||||
return await ProjectAsync(state, sessionToken, ct);
|
||||
}
|
||||
|
||||
public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default)
|
||||
@@ -411,7 +473,7 @@ public class LobbyService : ILobbyService
|
||||
foreach (var m in state.Members)
|
||||
{
|
||||
// unique per user id in game
|
||||
players[m.Id] = new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady };
|
||||
players[m.UserId] = new LobbyUser { SessionToken = m.SessionToken, UserId = m.UserId, DisplayName = m.DisplayName, IsReady = m.IsReady };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "purrlobby.exil.dev",
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://0.0.0.0:8080"
|
||||
},
|
||||
"Https": {
|
||||
"Url": "https://0.0.0.0:443",
|
||||
"Certificate": {
|
||||
|
||||
@@ -13,8 +13,9 @@ services:
|
||||
context: .
|
||||
target: final
|
||||
ports:
|
||||
- 443:8080
|
||||
network_mode: "host"
|
||||
- 443:443
|
||||
- 8080:8080
|
||||
|
||||
# The commented out section below is an example of how to define a PostgreSQL
|
||||
# database that your application can use. `depends_on` tells Docker Compose to
|
||||
# start the database before your application. The `db-data` volume persists the
|
||||
@@ -42,7 +43,6 @@ services:
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
# volumes:
|
||||
# db-data:
|
||||
# secrets:
|
||||
|
||||
Reference in New Issue
Block a user