Fix Docker

This commit is contained in:
Exil Productions
2025-12-10 03:24:28 +01:00
parent 6143c1ca6e
commit 28eb886790
12 changed files with 677 additions and 214 deletions

View 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; }
}

View 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>();
}
}

View 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;
}

View File

@@ -3,7 +3,8 @@ namespace PurrLobby.Models;
// user in lobby // user in lobby
public class LobbyUser 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 required string DisplayName { get; init; }
public int userPing { get; set; } public int userPing { get; set; }
public bool IsReady { get; set; } public bool IsReady { get; set; }

View File

@@ -1,4 +1,4 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpLogging;
using Microsoft.AspNetCore.HttpOverrides; 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<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>(); builder.Services.AddSingleton<ILobbyService, LobbyService>();
builder.Services.AddRazorPages(); // Register Razor Pages services builder.Services.AddRazorPages(); // Register Razor Pages services
@@ -92,16 +93,17 @@ if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseHsts(); app.UseHsts();
app.UseHttpsRedirection();
} }
else else
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
} }
app.UseHttpsRedirection();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseResponseCompression(); app.UseResponseCompression();
app.UseHttpLogging(); app.UseHttpLogging();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication();
app.UseWebSockets(); app.UseWebSockets();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseSwagger(); app.UseSwagger();
@@ -167,14 +169,7 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
return Guid.TryParse(v, out 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 // create lobby
app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) => 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)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); return Results.BadRequest("Missing or invalid gameId cookie");
if (req.MaxPlayers <= 0) return Results.BadRequest("MaxPlayers must be > 0"); 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 try
{ {
var ownerUserId = EnsureUserIdCookie(http); var lobby = await service.CreateLobbyAsync(gameId, user.SessionToken, req.MaxPlayers, req.Properties, ct);
var lobby = await service.CreateLobbyAsync(gameId, ownerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct);
return Results.Ok(lobby); return Results.Ok(lobby);
} }
catch (ArgumentException ax) catch (ArgumentException ax)
{ {
return Results.BadRequest(ax.Message); return Results.BadRequest(ax.Message);
} }
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
@@ -207,13 +209,16 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
.ProducesProblem(StatusCodes.Status400BadRequest); .ProducesProblem(StatusCodes.Status400BadRequest);
// join lobby // 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)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); return Results.BadRequest("Missing or invalid gameId cookie");
if (string.IsNullOrWhiteSpace(req.DisplayName)) return Results.BadRequest("DisplayName required");
var userId = EnsureUserIdCookie(http); var user = http.Items["User"] as AuthenticatedUser;
var lobby = await service.JoinLobbyAsync(gameId, lobbyId, userId, req.DisplayName, ct); 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); return lobby is null ? Results.NotFound() : Results.Ok(lobby);
}).WithTags("Lobbies") }).WithTags("Lobbies")
.WithOpenApi(op => .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."; op.Description = "Adds the current cookie user (server generated userId) to the specified lobby.";
return op; return op;
}) })
.Accepts<JoinLobbyRequest>("application/json")
.Produces<Lobby>(StatusCodes.Status200OK) .Produces<Lobby>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
@@ -233,8 +238,12 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
{ {
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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(); return ok ? Results.Ok() : Results.NotFound();
}).WithTags("Lobbies") }).WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
@@ -248,24 +257,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(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 // search lobbies
app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, int maxRoomsToFind = 10, CancellationToken ct = default) => 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)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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); return lobby is null ? Results.NotFound() : Results.Ok(lobby);
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
@@ -360,7 +356,12 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); return Results.BadRequest("Missing or invalid gameId cookie");
if (string.IsNullOrWhiteSpace(req.Key)) return Results.BadRequest("Key is required"); 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(); return ok ? Results.Ok() : Results.NotFound();
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
@@ -381,8 +382,12 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
{ {
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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(); return ok ? Results.Ok() : Results.NotFound();
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
@@ -398,42 +403,18 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(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 // start lobby
app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) => app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
{ {
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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 (lobby is null) return Results.NotFound(); if (user == null)
if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden); return Results.Unauthorized();
var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, ct);
return ok ? Results.Ok() : Results.BadRequest(); var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, user.SessionToken, ct);
return ok ? Results.Ok() : Results.NotFound();
}) })
.WithTags("Lobbies") .WithTags("Lobbies")
.WithOpenApi(op => .WithOpenApi(op =>
@@ -456,15 +437,17 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby
if (!TryGetGameIdFromCookie(http.Request, out var gameId)) if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie"); 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(); 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; return Results.Empty;
}).WithTags("Lobbies").WithOpenApi(op => }).WithTags("Lobbies").WithOpenApi(op =>
{ {
op.Summary = "Lobby Websocket"; 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; return op;
}); });
@@ -504,7 +487,5 @@ app.Run();
// dto records // dto records
public record SetGameRequest(Guid GameId); public record SetGameRequest(Guid GameId);
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties); public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string DisplayName); public record ReadyRequest(bool IsReady);
public record ReadyRequest(string UserId, bool IsReady);
public record AllReadyRequest(string UserId);
public record LobbyDataRequest(string Key, string Value); public record LobbyDataRequest(string Key, string Value);

View File

@@ -6,7 +6,7 @@
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"applicationUrl": "https://localhost:63036;http://localhost:63037" "applicationUrl": "https://localhost:7443;http://localhost:5080"
} }
} }
} }

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -19,14 +19,12 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
</ItemGroup> </ItemGroup>
<!-- Copy static web assets to output on build so running the exe from bin works -->
<ItemGroup> <ItemGroup>
<Content Update="wwwroot\**\*"> <Content Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<!-- Copy certificates to output directory -->
<ItemGroup> <ItemGroup>
<Content Include="Certs\*.pem"> <Content Include="Certs\*.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View 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();
}
}

View File

@@ -7,7 +7,7 @@ namespace PurrLobby.Services;
public interface ILobbyEventHub 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 BroadcastAsync(Guid gameId, string lobbyId, object evt, CancellationToken ct = default);
Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
} }
@@ -21,6 +21,7 @@ public class LobbyEventHub : ILobbyEventHub
private sealed class Subscriber private sealed class Subscriber
{ {
public required string SessionToken { get; init; }
public required string UserId { get; init; } public required string UserId { get; init; }
public DateTime LastPongUtc { get; set; } = DateTime.UtcNow; public DateTime LastPongUtc { get; set; } = DateTime.UtcNow;
} }
@@ -39,6 +40,7 @@ public class LobbyEventHub : ILobbyEventHub
}; };
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IAuthenticationService _authService;
// ping settings // ping settings
private const int PingIntervalSeconds = 10; private const int PingIntervalSeconds = 10;
@@ -47,16 +49,29 @@ public class LobbyEventHub : ILobbyEventHub
// idle cleanup delay // idle cleanup delay
private const int IdleLobbyCleanupDelaySeconds = 45; private const int IdleLobbyCleanupDelaySeconds = 45;
public LobbyEventHub(IServiceScopeFactory scopeFactory) public LobbyEventHub(IServiceScopeFactory scopeFactory, IAuthenticationService authService)
{ {
_scopeFactory = scopeFactory; _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 key = new LobbyKey(gameId, lobbyId);
var bag = _subscribers.GetOrAdd(key, _ => new()); 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); bag.TryAdd(socket, sub);
EnsurePingLoopStarted(key); EnsurePingLoopStarted(key);
@@ -150,11 +165,11 @@ public class LobbyEventHub : ILobbyEventHub
{ {
bag.TryRemove(ws, out _); bag.TryRemove(ws, out _);
try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "pong timeout", CancellationToken.None); } catch { } try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "pong timeout", CancellationToken.None); } catch { }
try try
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ILobbyService>(); 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 { } catch { }
} }
@@ -180,7 +195,7 @@ public class LobbyEventHub : ILobbyEventHub
{ {
foreach (var m in members) 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) 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 { }
} }
} }

View File

@@ -6,18 +6,18 @@ namespace PurrLobby.Services;
// lobby service core logic // lobby service core logic
public interface ILobbyService public interface ILobbyService
{ {
Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, 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 userId, string displayName, CancellationToken ct = default); Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default); Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string userId, 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<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> SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default);
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, 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<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default);
Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, 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<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default); Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default);
// stats // stats
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default); Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
@@ -31,6 +31,7 @@ internal class LobbyState
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required Guid GameId { get; init; } public required Guid GameId { get; init; }
public required string OwnerSessionToken { get; set; }
public required string OwnerUserId { get; set; } public required string OwnerUserId { get; set; }
public int MaxPlayers { get; init; } public int MaxPlayers { get; init; }
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
@@ -52,13 +53,15 @@ public class LobbyService : ILobbyService
private const int MaxPropertyCount = 32; private const int MaxPropertyCount = 32;
private readonly ConcurrentDictionary<string, LobbyState> _lobbies = new(); 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 ConcurrentDictionary<string, string> _userLobbyIndexByGame = new();
private readonly ILobbyEventHub _events; private readonly ILobbyEventHub _events;
private readonly IAuthenticationService _authService;
public LobbyService(ILobbyEventHub events) public LobbyService(ILobbyEventHub events, IAuthenticationService authService)
{ {
_events = events; _events = events;
_authService = authService;
} }
private static string SanitizeString(string? s, int maxLen) 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 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 var lobby = new Lobby
{ {
@@ -75,21 +78,34 @@ public class LobbyService : ILobbyService
LobbyId = s.Id, LobbyId = s.Id,
LobbyCode = s.LobbyCode, LobbyCode = s.LobbyCode,
MaxPlayers = s.MaxPlayers, 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) foreach (var kv in s.Properties)
lobby.Properties[kv.Key] = kv.Value; lobby.Properties[kv.Key] = kv.Value;
foreach (var m in s.Members) 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; 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)) if (gameId == Guid.Empty || IsInvalidId(sessionToken))
throw new ArgumentException("Invalid gameId or ownerUserId"); 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); var clampedPlayers = Math.Clamp(maxPlayers, MinPlayers, MaxPlayersLimit);
string GenerateLobbyCode() string GenerateLobbyCode()
@@ -112,7 +128,8 @@ public class LobbyService : ILobbyService
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
GameId = gameId, GameId = gameId,
OwnerUserId = ownerUserId, OwnerSessionToken = sessionToken,
OwnerUserId = validation.UserId!,
MaxPlayers = clampedPlayers, MaxPlayers = clampedPlayers,
Name = properties != null && properties.TryGetValue("Name", out var n) ? SanitizeString(n, NameMaxLength) : string.Empty, Name = properties != null && properties.TryGetValue("Name", out var n) ? SanitizeString(n, NameMaxLength) : string.Empty,
LobbyCode = GenerateLobbyCode() LobbyCode = GenerateLobbyCode()
@@ -132,75 +149,100 @@ public class LobbyService : ILobbyService
state.Members.Add(new LobbyUser state.Members.Add(new LobbyUser
{ {
Id = ownerUserId, SessionToken = sessionToken,
DisplayName = display, UserId = validation.UserId!,
DisplayName = validation.DisplayName!,
IsReady = false IsReady = false
}); });
_lobbies[state.Id] = state; _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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return Task.FromResult<Lobby?>(null); return null;
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
return null;
if (!_lobbies.TryGetValue(lobbyId, out var state)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null); return null;
if (state.GameId != gameId) if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null); return null;
// prevent multi lobby join per game // prevent multi lobby join per game
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId) if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var existingLobbyId) && existingLobbyId != lobbyId)
return Task.FromResult<Lobby?>(null); return null;
var name = SanitizeString(displayName, DisplayNameMaxLength);
LobbyUser? existingMember = null;
bool canJoin = false;
lock (state) lock (state)
{ {
if (state.Started) return Task.FromResult<Lobby?>(null); if (state.Started) return null;
if (state.Members.Any(m => m.Id == userId)) existingMember = state.Members.FirstOrDefault(m => m.SessionToken == sessionToken);
return Task.FromResult<Lobby?>(Project(state, userId)); if (existingMember != null)
if (state.Members.Count >= state.MaxPlayers) canJoin = true;
return Task.FromResult<Lobby?>(null); else if (state.Members.Count < state.MaxPlayers)
state.Members.Add(new LobbyUser { Id = userId, DisplayName = name, IsReady = false }); {
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); if (!canJoin) return null;
return Task.FromResult<Lobby?>(Project(state, userId)); 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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
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)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false); return false;
if (state.GameId != gameId) if (state.GameId != gameId)
return Task.FromResult(false); return false;
var removed = false; var removed = false;
string? newOwner = null; string? newOwner = null;
lock (state) lock (state)
{ {
var idx = state.Members.FindIndex(m => m.Id == userId); var idx = state.Members.FindIndex(m => m.SessionToken == sessionToken);
if (idx >= 0) if (idx >= 0)
{ {
var member = state.Members[idx];
state.Members.RemoveAt(idx); state.Members.RemoveAt(idx);
removed = true; 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 var newOwnerMember = state.Members[0];
newOwner = state.OwnerUserId; 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) if (removed)
{ {
// remove lobby if empty // remove lobby if empty
@@ -212,28 +254,28 @@ public class LobbyService : ILobbyService
} }
else 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)) if (gameId == Guid.Empty || IsInvalidId(sessionToken))
return Task.FromResult(false); 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) if (gameId == Guid.Empty)
return Task.FromResult(new List<Lobby>()); return new List<Lobby>();
var take = Math.Clamp(maxRoomsToFind, 1, 100); var take = Math.Clamp(maxRoomsToFind, 1, 100);
IEnumerable<LobbyState> query = _lobbies.Values.Where(l => l.GameId == gameId && !l.Started && l.Members.Count < l.MaxPlayers); 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)); 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(); var list = new List<Lobby>();
return Task.FromResult(list); 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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
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)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false); return false;
if (state.GameId != gameId) if (state.GameId != gameId)
return Task.FromResult(false); return false;
lock (state) lock (state)
{ {
if (state.Started) return Task.FromResult(false); if (state.Started) return false;
var m = state.Members.FirstOrDefault(x => x.Id == userId); var m = state.Members.FirstOrDefault(x => x.SessionToken == sessionToken);
if (m is null) return Task.FromResult(false); if (m is null) return false;
m.IsReady = isReady; m.IsReady = isReady;
} }
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId, isReady }, ct); _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId = validation.UserId, isReady }, ct);
return Task.FromResult(true); 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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return Task.FromResult(false); return false;
if (string.IsNullOrWhiteSpace(key)) 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)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false); return false;
if (state.GameId != gameId) 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) lock (state)
{ {
var k = SanitizeString(key, PropertyKeyMaxLength); var k = SanitizeString(key, PropertyKeyMaxLength);
if (string.IsNullOrEmpty(k)) return Task.FromResult(false); if (string.IsNullOrEmpty(k)) return false;
var v = SanitizeString(value, PropertyValueMaxLength); var v = SanitizeString(value, PropertyValueMaxLength);
if (!state.Properties.ContainsKey(k) && state.Properties.Count >= MaxPropertyCount) if (!state.Properties.ContainsKey(k) && state.Properties.Count >= MaxPropertyCount)
return Task.FromResult(false); return false;
state.Properties[k] = v; state.Properties[k] = v;
if (string.Equals(k, "Name", StringComparison.OrdinalIgnoreCase)) if (string.Equals(k, "Name", StringComparison.OrdinalIgnoreCase))
state.Name = SanitizeString(v, NameMaxLength); state.Name = SanitizeString(v, NameMaxLength);
} }
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_data", key, value }, ct); _ = _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) 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 return Task.FromResult(state.Members.Select(m => new LobbyUser
{ {
Id = m.Id, SessionToken = m.SessionToken,
UserId = m.UserId,
DisplayName = m.DisplayName, DisplayName = m.DisplayName,
IsReady = m.IsReady IsReady = m.IsReady
}).ToList()); }).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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
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)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(false); return false;
if (state.GameId != gameId) if (state.GameId != gameId)
return Task.FromResult(false); return false;
lock (state)
{ // Only owner can start lobby
if (state.Started) return Task.FromResult(false); if (state.OwnerUserId != validation.UserId)
foreach (var m in state.Members) return false;
m.IsReady = true;
} if (state.Started) return false;
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "all_ready" }, ct);
return Task.FromResult(true);
}
public Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
return Task.FromResult(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);
state.Started = true; state.Started = true;
_ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_started" }, ct); _ = _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)) if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(sessionToken))
return Task.FromResult<Lobby?>(null); return null;
var validation = await _authService.ValidateTokenAsync(sessionToken, ct);
if (!validation.IsValid)
return null;
if (!_lobbies.TryGetValue(lobbyId, out var state)) if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null); return null;
if (state.GameId != gameId) if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null); return null;
return Task.FromResult<Lobby?>(Project(state, currentUserId));
// 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) public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default)
@@ -411,7 +473,7 @@ public class LobbyService : ILobbyService
foreach (var m in state.Members) foreach (var m in state.Members)
{ {
// unique per user id in game // 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 };
} }
} }
} }

View File

@@ -5,9 +5,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "purrlobby.exil.dev", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": {
"Url": "http://0.0.0.0:8080"
},
"Https": { "Https": {
"Url": "https://0.0.0.0:443", "Url": "https://0.0.0.0:443",
"Certificate": { "Certificate": {

View File

@@ -13,8 +13,9 @@ services:
context: . context: .
target: final target: final
ports: ports:
- 443:8080 - 443:443
network_mode: "host" - 8080:8080
# The commented out section below is an example of how to define a PostgreSQL # 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 # database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the # start the database before your application. The `db-data` volume persists the
@@ -42,7 +43,6 @@ services:
# interval: 10s # interval: 10s
# timeout: 5s # timeout: 5s
# retries: 5 # retries: 5
# volumes: # volumes:
# db-data: # db-data:
# secrets: # secrets: