lightfinder config, securing methods with stricter checking and added pair request notifications

This commit is contained in:
azyges
2025-09-29 05:31:58 +09:00
parent 6bc9da1519
commit ed13ee8921
9 changed files with 311 additions and 20 deletions

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Configuration;
public class BroadcastConfiguration : IBroadcastConfiguration
{
private static readonly TimeSpan DefaultEntryTtl = TimeSpan.FromMinutes(5);
private const int DefaultMaxStatusBatchSize = 30;
private const string DefaultNotificationTemplate = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.";
private readonly IOptionsMonitor<BroadcastOptions> _optionsMonitor;
public BroadcastConfiguration(IOptionsMonitor<BroadcastOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
private BroadcastOptions Options => _optionsMonitor.CurrentValue ?? new BroadcastOptions();
public string RedisKeyPrefix
{
get
{
var prefix = Options.RedisKeyPrefix;
return string.IsNullOrWhiteSpace(prefix) ? "broadcast:" : prefix!;
}
}
public TimeSpan BroadcastEntryTtl
{
get
{
var seconds = Options.EntryTtlSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : DefaultEntryTtl;
}
}
public int MaxStatusBatchSize
{
get
{
var value = Options.MaxStatusBatchSize;
return value > 0 ? value : DefaultMaxStatusBatchSize;
}
}
public bool NotifyOwnerOnPairRequest => Options.NotifyOwnerOnPairRequest;
public bool EnableBroadcasting => Options.EnableBroadcasting;
public bool EnableSyncshellBroadcastPayloads => Options.EnableSyncshellBroadcastPayloads;
public string BuildRedisKey(string hashedCid)
{
if (string.IsNullOrEmpty(hashedCid))
return RedisKeyPrefix;
return string.Concat(RedisKeyPrefix, hashedCid);
}
public string BuildPairRequestNotification(string displayName)
{
var template = Options.PairRequestNotificationTemplate;
if (string.IsNullOrWhiteSpace(template))
{
template = DefaultNotificationTemplate;
}
displayName = string.IsNullOrWhiteSpace(displayName) ? "Someone" : displayName;
return template.Replace("{DisplayName}", displayName, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace LightlessSyncServer.Configuration;
public class BroadcastOptions
{
[Required]
public string RedisKeyPrefix { get; set; } = "broadcast:";
[Range(1, int.MaxValue)]
public int EntryTtlSeconds { get; set; } = 300;
[Range(1, int.MaxValue)]
public int MaxStatusBatchSize { get; set; } = 30;
public bool NotifyOwnerOnPairRequest { get; set; } = true;
public bool EnableBroadcasting { get; set; } = true;
public bool EnableSyncshellBroadcastPayloads { get; set; } = true;
public string PairRequestNotificationTemplate { get; set; } = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.";
}

View File

@@ -0,0 +1,16 @@
using System;
namespace LightlessSyncServer.Configuration;
public interface IBroadcastConfiguration
{
string RedisKeyPrefix { get; }
TimeSpan BroadcastEntryTtl { get; }
int MaxStatusBatchSize { get; }
bool NotifyOwnerOnPairRequest { get; }
bool EnableBroadcasting { get; }
bool EnableSyncshellBroadcastPayloads { get; }
string BuildRedisKey(string hashedCid);
string BuildPairRequestNotification(string displayName);
}

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
@@ -925,6 +925,13 @@ public partial class LightlessHub
return false;
}
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
{
_logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", "User", UserUID, "GID", dto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false);
return false;
}
var (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false);
if (!isOwner)
{
@@ -941,6 +948,9 @@ public partial class LightlessHub
{
_logger.LogCallInfo(LightlessHubLogger.Args("Requested Syncshells", broadcastEntries.Select(b => b.GID)));
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
return new List<GroupJoinDto>();
var results = new List<GroupJoinDto>();
var gidsToValidate = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -949,10 +959,19 @@ public partial class LightlessHub
if (string.IsNullOrWhiteSpace(entry.HashedCID) || string.IsNullOrWhiteSpace(entry.GID))
continue;
var redisKey = $"broadcast:{entry.HashedCID}";
var redisKey = _broadcastConfiguration.BuildRedisKey(entry.HashedCID);
var redisEntry = await _redis.GetAsync<BroadcastRedisEntry>(redisKey).ConfigureAwait(false);
if (redisEntry?.GID != null && string.Equals(redisEntry.GID, entry.GID, StringComparison.OrdinalIgnoreCase))
if (redisEntry is null)
continue;
if (!string.IsNullOrEmpty(redisEntry.HashedCID) && !string.Equals(redisEntry.HashedCID, entry.HashedCID, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid for group lookup", "Requested", entry.HashedCID, "EntryCID", redisEntry.HashedCID));
continue;
}
if (redisEntry.GID != null && string.Equals(redisEntry.GID, entry.GID, StringComparison.OrdinalIgnoreCase))
gidsToValidate.Add(entry.GID);
}

View File

@@ -4,14 +4,17 @@ using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Utils;
using LightlessSyncServer.Configuration;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using StackExchange.Redis;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -252,9 +255,62 @@ public partial class LightlessHub
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, $"Pair request sent. Waiting for the other player to confirm.").ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("stored pairing request", myCid, otherCid));
await NotifyBroadcastOwnerOfPairRequest(otherCid).ConfigureAwait(false);
}
}
private async Task NotifyBroadcastOwnerOfPairRequest(string targetHashedCid)
{
if (string.IsNullOrWhiteSpace(targetHashedCid))
return;
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.NotifyOwnerOnPairRequest)
return;
var db = _redis.Database;
var broadcastKey = _broadcastConfiguration.BuildRedisKey(targetHashedCid);
RedisValueWithExpiry broadcastValue;
try
{
broadcastValue = await db.StringGetWithExpiryAsync(broadcastKey).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("failed to fetch broadcast for pair notify", "CID", targetHashedCid, "Error", ex));
return;
}
if (broadcastValue.Value.IsNullOrEmpty || broadcastValue.Expiry is null || broadcastValue.Expiry <= TimeSpan.Zero)
return;
BroadcastRedisEntry? entry;
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue.Value!);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast for pair notify", "CID", targetHashedCid, "Value", broadcastValue.Value, "Error", ex));
return;
}
if (entry is null || !string.Equals(entry.HashedCID, targetHashedCid, StringComparison.Ordinal))
return;
if (!entry.HasOwner())
return;
if (string.Equals(entry.OwnerUID, UserUID, StringComparison.Ordinal))
return;
var senderAlias = Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Alias, StringComparison.Ordinal))?.Value;
var displayName = string.IsNullOrWhiteSpace(senderAlias) ? UserUID : senderAlias;
var message = _broadcastConfiguration.BuildPairRequestNotification(displayName);
await Clients.User(entry.OwnerUID).Client_ReceiveServerMessage(MessageSeverity.Information, message).ConfigureAwait(false);
}
private class PairingPayload
{
public string UID { get; set; } = string.Empty;
@@ -264,14 +320,26 @@ public partial class LightlessHub
public class BroadcastRedisEntry
{
public string HashedCID { get; set; } = string.Empty;
public string OwnerUID { get; set; } = string.Empty;
public string? GID { get; set; }
public bool OwnedBy(string userUid) => !string.IsNullOrEmpty(userUid) && string.Equals(OwnerUID, userUid, StringComparison.Ordinal);
public bool HasOwner() => !string.IsNullOrEmpty(OwnerUID);
}
[Authorize(Policy = "Identified")]
public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null)
{
if (enabled && !_broadcastConfiguration.EnableBroadcasting)
{
_logger.LogCallWarning(LightlessHubLogger.Args("broadcast disabled", UserUID, "CID", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Broadcasting is currently disabled.").ConfigureAwait(false);
return;
}
var db = _redis.Database;
var broadcastKey = $"broadcast:{hashedCid}";
var broadcastKey = _broadcastConfiguration.BuildRedisKey(hashedCid);
if (enabled)
{
@@ -279,6 +347,13 @@ public partial class LightlessHub
if (groupDto is not null)
{
if (!_broadcastConfiguration.EnableSyncshellBroadcastPayloads)
{
_logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", UserUID, "CID", hashedCid, "GID", groupDto.GID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false);
return;
}
groupDto.HashedCID = hashedCid;
var valid = await SetGroupBroadcastStatus(groupDto).ConfigureAwait(false);
@@ -288,14 +363,36 @@ public partial class LightlessHub
gid = groupDto.GID;
}
BroadcastRedisEntry? existingEntry = null;
var existingValue = await db.StringGetAsync(broadcastKey).ConfigureAwait(false);
if (!existingValue.IsNullOrEmpty)
{
try
{
existingEntry = JsonSerializer.Deserialize<BroadcastRedisEntry>(existingValue!);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast entry during enable", "CID", hashedCid, "Value", existingValue, "Error", ex));
}
if (existingEntry is not null && existingEntry.HasOwner() && !existingEntry.OwnedBy(UserUID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to take broadcast ownership", UserUID, "CID", hashedCid, "ExistingOwner", existingEntry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Another user is already broadcasting with that CID.");
return;
}
}
var entry = new BroadcastRedisEntry
{
HashedCID = hashedCid,
OwnerUID = UserUID,
GID = gid,
};
var json = JsonSerializer.Serialize(entry);
await db.StringSetAsync(broadcastKey, json, TimeSpan.FromMinutes(5)).ConfigureAwait(false);
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid));
}
else
@@ -315,13 +412,20 @@ public partial class LightlessHub
return;
}
if (entry is null || entry.HashedCID != hashedCid)
if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Stored", entry?.HashedCID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3");
return;
}
if (entry.HasOwner() && !entry.OwnedBy(UserUID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Owner", entry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3");
return;
}
await db.KeyDeleteAsync(broadcastKey).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID));
}
@@ -331,8 +435,11 @@ public partial class LightlessHub
[Authorize(Policy = "Identified")]
public async Task<BroadcastStatusInfoDto?> IsUserBroadcasting(string hashedCid)
{
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
var db = _redis.Database;
var key = $"broadcast:{hashedCid}";
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
var result = await db.StringGetWithExpiryAsync(key).ConfigureAwait(false);
if (result.Expiry is null || result.Expiry <= TimeSpan.Zero || result.Value.IsNullOrEmpty)
@@ -348,6 +455,12 @@ public partial class LightlessHub
return null;
}
if (entry is not null && !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast entry", "CID", hashedCid, "EntryCID", entry.HashedCID));
return null;
}
var dto = new BroadcastStatusInfoDto
{
HashedCID = entry?.HashedCID ?? hashedCid,
@@ -363,8 +476,11 @@ public partial class LightlessHub
[Authorize(Policy = "Identified")]
public async Task<TimeSpan?> GetBroadcastTtl(string hashedCid)
{
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
var db = _redis.Database;
var key = $"broadcast:{hashedCid}";
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
var value = await db.StringGetAsync(key).ConfigureAwait(false);
if (value.IsNullOrEmpty)
@@ -381,9 +497,21 @@ public partial class LightlessHub
return null;
}
if (entry?.HashedCID != hashedCid)
if (entry is null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "EntryCID", entry?.HashedCID));
_logger.LogCallWarning(LightlessHubLogger.Args("missing broadcast entry during ttl query", "CID", hashedCid));
return null;
}
if (!string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "EntryCID", entry.HashedCID));
return null;
}
if (entry.HasOwner() && !entry.OwnedBy(UserUID))
{
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "Owner", entry.OwnerUID));
return null;
}
@@ -396,19 +524,25 @@ public partial class LightlessHub
}
private const int MaxBatchSize = 30;
[Authorize(Policy = "Identified")]
public async Task<BroadcastStatusBatchDto> AreUsersBroadcasting(List<string> hashedCids)
public async Task<BroadcastStatusBatchDto?> AreUsersBroadcasting(List<string> hashedCids)
{
if (!_broadcastConfiguration.EnableBroadcasting)
{
_logger.LogCallInfo(LightlessHubLogger.Args("batch broadcast disabled", "Count", hashedCids.Count));
return null;
}
var maxBatchSize = _broadcastConfiguration.MaxStatusBatchSize;
if (hashedCids.Count > maxBatchSize)
hashedCids = hashedCids.Take(maxBatchSize).ToList();
var db = _redis.Database;
if (hashedCids.Count > MaxBatchSize)
hashedCids = hashedCids.Take(MaxBatchSize).ToList();
var tasks = new Dictionary<string, Task<RedisValueWithExpiry>>(hashedCids.Count);
foreach (var cid in hashedCids)
{
var key = $"broadcast:{cid}";
var key = _broadcastConfiguration.BuildRedisKey(cid);
tasks[cid] = db.StringGetWithExpiryAsync(key);
}
@@ -433,7 +567,17 @@ public partial class LightlessHub
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(raw!);
gid = entry?.GID;
if (entry is not null && !string.Equals(entry.HashedCID, cid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid in batch", "Requested", cid, "EntryCID", entry.HashedCID));
entry = null;
gid = null;
isBroadcasting = false;
}
else
{
gid = entry?.GID;
}
}
catch (Exception ex)
{

View File

@@ -1,8 +1,9 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Services;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Utils;
using LightlessSyncShared;
using LightlessSyncShared.Data;
@@ -29,6 +30,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private readonly LightlessHubLogger _logger;
private readonly string _shardName;
private readonly int _maxExistingGroupsByUser;
private readonly IBroadcastConfiguration _broadcastConfiguration;
private readonly int _maxJoinedGroupsByUser;
private readonly int _maxGroupUserCount;
private readonly IRedisDatabase _redis;
@@ -46,7 +48,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
GPoseLobbyDistributionService gPoseLobbyDistributionService, PairService pairService)
GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService)
{
_lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService;
@@ -65,6 +67,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
_gPoseLobbyDistributionService = gPoseLobbyDistributionService;
_logger = new LightlessHubLogger(this, logger);
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
_broadcastConfiguration = broadcastConfiguration;
_pairService = pairService;
}

View File

@@ -2,6 +2,7 @@ using AspNetCoreRateLimit;
using LightlessSync.API.SignalR;
using LightlessSyncAuthService.Controllers;
using LightlessSyncServer.Controllers;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Hubs;
using LightlessSyncServer.Services;
using LightlessSyncShared.Data;
@@ -87,7 +88,9 @@ public class Startup
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast"));
services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>();
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<SystemInfoService>();
services.AddSingleton<OnlineSyncedPairCacheService>();

View File

@@ -29,6 +29,15 @@
"ServiceAddress": "http://localhost:5002",
"StaticFileServiceAddress": "http://localhost:5003"
},
"Broadcast": {
"RedisKeyPrefix": "broadcast:",
"EntryTtlSeconds": 10800,
"MaxStatusBatchSize": 30,
"NotifyOwnerOnPairRequest": true,
"EnableBroadcasting": true,
"EnableSyncshellBroadcastPayloads": true,
"PairRequestNotificationTemplate": "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back."
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {