From 28eb88679080f5aaea798be91a6ccc7238a07cd6 Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Wed, 10 Dec 2025 03:24:28 +0100 Subject: [PATCH] Fix Docker --- .../Controllers/AuthenticationController.cs | 94 ++++++ .../Middleware/AuthenticationMiddleware.cs | 94 ++++++ PurrLobby/Models/Authentication.cs | 37 +++ PurrLobby/Models/Lobby.cs | 3 +- PurrLobby/Program.cs | 141 ++++----- PurrLobby/Properties/launchSettings.json | 2 +- PurrLobby/PurrLobby.csproj | 4 +- PurrLobby/Services/AuthenticationService.cs | 178 +++++++++++ PurrLobby/Services/LobbyEventHub.cs | 31 +- PurrLobby/Services/LobbyService.cs | 296 +++++++++++------- PurrLobby/appsettings.json | 5 +- compose.yaml | 6 +- 12 files changed, 677 insertions(+), 214 deletions(-) create mode 100644 PurrLobby/Controllers/AuthenticationController.cs create mode 100644 PurrLobby/Middleware/AuthenticationMiddleware.cs create mode 100644 PurrLobby/Models/Authentication.cs create mode 100644 PurrLobby/Services/AuthenticationService.cs diff --git a/PurrLobby/Controllers/AuthenticationController.cs b/PurrLobby/Controllers/AuthenticationController.cs new file mode 100644 index 0000000..7df3081 --- /dev/null +++ b/PurrLobby/Controllers/AuthenticationController.cs @@ -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> 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> 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> 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; } +} \ No newline at end of file diff --git a/PurrLobby/Middleware/AuthenticationMiddleware.cs b/PurrLobby/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 0000000..00e51b7 --- /dev/null +++ b/PurrLobby/Middleware/AuthenticationMiddleware.cs @@ -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(); + } +} \ No newline at end of file diff --git a/PurrLobby/Models/Authentication.cs b/PurrLobby/Models/Authentication.cs new file mode 100644 index 0000000..a22302a --- /dev/null +++ b/PurrLobby/Models/Authentication.cs @@ -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; +} \ No newline at end of file diff --git a/PurrLobby/Models/Lobby.cs b/PurrLobby/Models/Lobby.cs index 975cebd..9004863 100644 --- a/PurrLobby/Models/Lobby.cs +++ b/PurrLobby/Models/Lobby.cs @@ -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; } diff --git a/PurrLobby/Program.cs b/PurrLobby/Program.cs index b76f1d4..124fac6 100644 --- a/PurrLobby/Program.cs +++ b/PurrLobby/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); 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("application/json") + .Produces(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("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? 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); \ No newline at end of file diff --git a/PurrLobby/Properties/launchSettings.json b/PurrLobby/Properties/launchSettings.json index 55745b6..ca0da48 100644 --- a/PurrLobby/Properties/launchSettings.json +++ b/PurrLobby/Properties/launchSettings.json @@ -6,7 +6,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:63036;http://localhost:63037" + "applicationUrl": "https://localhost:7443;http://localhost:5080" } } } \ No newline at end of file diff --git a/PurrLobby/PurrLobby.csproj b/PurrLobby/PurrLobby.csproj index 5aa6247..79b0d97 100644 --- a/PurrLobby/PurrLobby.csproj +++ b/PurrLobby/PurrLobby.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -19,14 +19,12 @@ - PreserveNewest - PreserveNewest diff --git a/PurrLobby/Services/AuthenticationService.cs b/PurrLobby/Services/AuthenticationService.cs new file mode 100644 index 0000000..ae27a80 --- /dev/null +++ b/PurrLobby/Services/AuthenticationService.cs @@ -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 CreateSessionAsync(string userId, string displayName, CancellationToken ct = default); + Task ValidateTokenAsync(string token, CancellationToken ct = default); + Task RevokeTokenAsync(string token, CancellationToken ct = default); + Task ExtendSessionAsync(string token, CancellationToken ct = default); + Task CleanupExpiredSessionsAsync(CancellationToken ct = default); +} + +public class AuthenticationService : IAuthenticationService +{ + private readonly ConcurrentDictionary _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 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 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 RevokeTokenAsync(string token, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) + return Task.FromResult(false); + + return Task.FromResult(_activeSessions.TryRemove(token, out _)); + } + + public Task 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(); + + 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(); + } +} \ No newline at end of file diff --git a/PurrLobby/Services/LobbyEventHub.cs b/PurrLobby/Services/LobbyEventHub.cs index 9f1a4bb..608b155 100644 --- a/PurrLobby/Services/LobbyEventHub.cs +++ b/PurrLobby/Services/LobbyEventHub.cs @@ -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(); - 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 { } } } diff --git a/PurrLobby/Services/LobbyService.cs b/PurrLobby/Services/LobbyService.cs index 9532a57..a50d899 100644 --- a/PurrLobby/Services/LobbyService.cs +++ b/PurrLobby/Services/LobbyService.cs @@ -6,18 +6,18 @@ namespace PurrLobby.Services; // lobby service core logic public interface ILobbyService { - Task CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary? properties, CancellationToken ct = default); - Task JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default); - Task LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default); - Task LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default); + Task CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary? properties, CancellationToken ct = default); + Task JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default); + Task LeaveLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default); + Task LeaveLobbyAsync(Guid gameId, string sessionToken, CancellationToken ct = default); Task> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default); - Task SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default); - Task SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default); + Task SetIsReadyAsync(Guid gameId, string lobbyId, string sessionToken, bool isReady, CancellationToken ct = default); + Task SetLobbyDataAsync(Guid gameId, string lobbyId, string sessionToken, string key, string value, CancellationToken ct = default); Task GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default); Task> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default); - Task SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); - Task SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default); - Task GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default); + + Task SetLobbyStartedAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default); + Task GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default); // stats Task 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 _lobbies = new(); - // user index key gameIdN:userId -> lobbyId + // user index key gameIdN:sessionToken -> lobbyId private readonly ConcurrentDictionary _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 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 CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary? properties, CancellationToken ct = default) + public async Task CreateLobbyAsync(Guid gameId, string sessionToken, int maxPlayers, Dictionary? 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 JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default) + public async Task JoinLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default) { - if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId)) - return Task.FromResult(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(null); + return null; if (state.GameId != gameId) - return Task.FromResult(null); + return null; // prevent multi lobby join per game - if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId) - return Task.FromResult(null); - - var name = SanitizeString(displayName, DisplayNameMaxLength); + if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{sessionToken}", out var existingLobbyId) && existingLobbyId != lobbyId) + return null; + LobbyUser? existingMember = null; + bool canJoin = false; + lock (state) { - if (state.Started) return Task.FromResult(null); - if (state.Members.Any(m => m.Id == userId)) - return Task.FromResult(Project(state, userId)); - if (state.Members.Count >= state.MaxPlayers) - return Task.FromResult(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(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 LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default) + public async Task 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 LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default) + public async Task 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> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default) + public async Task> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default) { if (gameId == Guid.Empty) - return Task.FromResult(new List()); + return new List(); var take = Math.Clamp(maxRoomsToFind, 1, 100); IEnumerable 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(); + foreach (var state in query.OrderByDescending(l => l.CreatedAtUtc).Take(take)) + { + list.Add(await ProjectAsync(state, null, ct)); + } + return list; } - public Task SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default) + public async Task 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 SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default) + public async Task 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 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 SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default) + + + public async Task 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); - } - - public Task 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); + return false; + + // Only owner can start lobby + if (state.OwnerUserId != validation.UserId) + return 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 GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default) + public async Task GetLobbyAsync(Guid gameId, string lobbyId, string sessionToken, CancellationToken ct = default) { - if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId)) - return Task.FromResult(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(null); + return null; if (state.GameId != gameId) - return Task.FromResult(null); - return Task.FromResult(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 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 }; } } } diff --git a/PurrLobby/appsettings.json b/PurrLobby/appsettings.json index 5c63240..57add17 100644 --- a/PurrLobby/appsettings.json +++ b/PurrLobby/appsettings.json @@ -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": { diff --git a/compose.yaml b/compose.yaml index 4f0013c..6ad803b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: