From 09a4801eaa040f7c53e6a6464f7d47964c919487 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:09:01 +0200 Subject: [PATCH] feat(moderation): send ban and kick reasons to clients Signed-off-by: tintinhamans <5984296+tintinhamans@users.noreply.github.com> --- GenOnlineService/Constants.cs | 33 +++++++- .../CheckLogin/CheckLoginController.cs | 7 +- .../LoginWithTokenController.cs | 7 +- .../RefreshToken/RefreshTokenController.cs | 7 +- GenOnlineService/Database/Database.User.cs | 32 ++++++++ GenOnlineService/Discord.cs | 72 +++++++---------- GenOnlineService/Moderation.cs | 78 +++++++++++++++++++ GenOnlineService/Program.cs | 32 +++++++- GenOnlineService/TokenRevocation.cs | 25 +----- 9 files changed, 219 insertions(+), 74 deletions(-) create mode 100644 GenOnlineService/Moderation.cs diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 06f02cc..aeda6de 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -658,6 +658,30 @@ public static List GetAllDataFromUser(Int64 userID) return lstRet; } + public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) + { + List userSessions = GetAllDataFromUser(userID); + + foreach (UserSession userSession in userSessions) + { + try + { + UserWebSocketInstance? oldWS = GetWebSocketForSession(userSession); + if (oldWS != null) + { + await oldWS.SendAsync(finalMessage, WebSocketMessageType.Text); + } + + await DeleteSession(userID, userSession.GetSessionType(), oldWS, true); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] DisconnectUser failed for user {userID}, session {userSession.GetSessionType()}: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + } + public static async Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) { // NOTE: This is when a player is truly disconnected and we can destroy session, remove form lobby etc, websocket disconnect doesnt mean that because the clietn reconnects @@ -2545,7 +2569,8 @@ public enum EWebSocketMessageID AC_REGISTER_PLAYER = 40, AC_DEREGISTER_PLAYER = 41, WS_KEEPALIVE = 42, - WS_KEEPALIVE_CLIENT = 43 + WS_KEEPALIVE_CLIENT = 43, + MODERATION_ACTION = 46 }; public static class UserPresence @@ -2643,6 +2668,12 @@ public class WebSocketMessage_StartMatch : WebSocketMessage public string screenshot_url { get; set; } = String.Empty; } + public class WebSocketMessage_ModerationAction : WebSocketMessage + { + public string action_type { get; set; } = String.Empty; + public string reason { get; set; } = String.Empty; + } + public abstract class WebSocketMessage { public int msg_id { get; set; } diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index d40e629..bf97f91 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -41,6 +41,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; public string ws_uri { get; set; } = ""; } @@ -180,12 +181,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr if (clientID != null && Program.g_tokenGenerator != null) { // ban check - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index e20d2c1..8c58ed2 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -41,6 +41,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; public string ws_uri { get; set; } = ""; } @@ -129,13 +130,15 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr } // ban check - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { // kill every token they hold, not just this request await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs index 8ce557f..1c65a3a 100644 --- a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs +++ b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs @@ -35,6 +35,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; } // Pure token rotation. Unlike LoginWithToken this does NOT establish a session - the caller's @@ -97,13 +98,15 @@ public async Task Post_InternalHandler(string ipAddr) // re-check the ban on every rotation so a ban applied since the last refresh takes // effect immediately rather than waiting for the periodic reconcile - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { // kill every token they hold, not just this request await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 1e796e8..36f599c 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -98,6 +98,12 @@ public class UserLobbyPreferences public bool favorite_limit_superweapons = false; } +public sealed class UserBanStatus +{ + public bool IsBanned { get; set; } + public string BanReason { get; set; } = String.Empty; +} + // TODO_EFCORE: add index for code public class PendingLoginConfiguration : IEntityTypeConfiguration { @@ -372,6 +378,18 @@ public static class Users .Select(u => u.IsBanned) .FirstOrDefault()); + private static readonly Func> _getUserBanStatusQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => new UserBanStatus + { + IsBanned = u.IsBanned, + BanReason = u.BanReason ?? String.Empty + }) + .FirstOrDefault()); + private static readonly Func> _getDisplayNameQuery = EF.CompileAsyncQuery((AppDbContext db, long userId) => db.Users @@ -539,6 +557,20 @@ public static async Task IsUserBanned(AppDbContext db, long userId) } } + public static async Task GetUserBanStatus(AppDbContext db, long userId) + { + try + { + return await _getUserBanStatusQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserBanStatus failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + public static async Task GetDisplayName(AppDbContext db, long userId) { diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 6e9c598..ffca30f 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -460,10 +460,9 @@ private async Task OnMessageReceived(SocketMessage message) } } } - else if (message.Content.ToLower().StartsWith("!kick")) + else if (message.Content.Equals("!kick", StringComparison.OrdinalIgnoreCase) + || message.Content.StartsWith("!kick ", StringComparison.OrdinalIgnoreCase)) { - // TODO: In future we should validate users not just channels - // is it in the admin channel? if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) { if (Program.g_Config == null) @@ -471,60 +470,47 @@ private async Task OnMessageReceived(SocketMessage message) return; } - // is it an admin? - IConfiguration? discordSettings = Program.g_Config.GetSection("Discord"); - - if (discordSettings == null) - { - return; - } - - List? discord_admins = discordSettings.GetSection("discord_admins").Get>(); - if (discord_admins == null) + List? discordAdmins = Program.g_Config + .GetSection("Discord") + .GetSection("discord_admins") + .Get>(); + if (discordAdmins?.Contains(message.Author.Id) == true) { - return; - } - - if (discord_admins.Contains(message.Author.Id)) - { - string[] strComponents = message.Content.Split(' '); - //var clients = message.Author.ActiveClients; - - //var user = message.Author as IGuildUser; // Get the user from the command context - if (strComponents.Length == 2) + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length >= 2) { - string strUser = string.Join(' ', strComponents.Skip(1)); + string strUser = strComponents[1]; if (Int64.TryParse(strUser, out Int64 TargetUserID)) { - SharedUserData? targetData = GenOnlineService.WebSocketManager.GetSharedDataForUser(TargetUserID); - - if (targetData != null) - { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} ({targetData.m_strDisplayName}) has been kicked from the server."); + string strReason = string.Join(' ', strComponents.Skip(2)); + ModerationResult kickResult = await ModerationManager.KickUser(TargetUserID, strReason); - // we need to kill all websockets they have - List lstUserSessions = GenOnlineService.WebSocketManager.GetAllDataFromUser(TargetUserID); - foreach (UserSession userSession in lstUserSessions) - { - UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(userSession); - await GenOnlineService.WebSocketManager.DeleteSession(TargetUserID, userSession.GetSessionType(), oldWS, true); - } - - - } - else + switch (kickResult.Result) { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} is not active on the server."); + case EModerationResult.ReasonTooLong: + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Kick reason must be {ModerationManager.MaximumReasonLength} characters or fewer."); + break; + case EModerationResult.TargetNotOnline: + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} is not active on the server."); + break; + case EModerationResult.Success: + string confirmation = $"User {TargetUserID} ({kickResult.TargetDisplayName}) has been kicked from the server."; + if (!String.IsNullOrWhiteSpace(strReason)) + { + confirmation += $" Reason: {strReason}"; + } + PushChannelMessage(EDiscordChannelIDs.AdminCommands, confirmation); + break; } } else { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick (e.g. !kick 123)"); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick [reason] (e.g. !kick 123 reconnect abuse)"); } } else { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick (e.g. !kick 123)"); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick [reason] (e.g. !kick 123 reconnect abuse)"); } } else diff --git a/GenOnlineService/Moderation.cs b/GenOnlineService/Moderation.cs new file mode 100644 index 0000000..b5a4da4 --- /dev/null +++ b/GenOnlineService/Moderation.cs @@ -0,0 +1,78 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +*/ + +using System.Text; +using System.Text.Json; + +namespace GenOnlineService +{ + public enum EModerationResult + { + Success, + TargetNotOnline, + ReasonTooLong + } + + public enum EModerationAction + { + Ban, + Kick + } + + public sealed class ModerationResult + { + public EModerationResult Result { get; init; } + public string TargetDisplayName { get; init; } = String.Empty; + } + + public static class ModerationManager + { + public const int MaximumReasonLength = 256; + + public static async Task KickUser(Int64 targetUserID, string reason) + { + if (reason.Length > MaximumReasonLength) + { + return new ModerationResult { Result = EModerationResult.ReasonTooLong }; + } + + SharedUserData? target = WebSocketManager.GetSharedDataForUser(targetUserID); + if (target == null) + { + return new ModerationResult { Result = EModerationResult.TargetNotOnline }; + } + + await DisconnectUser(targetUserID, EModerationAction.Kick, reason); + return new ModerationResult + { + Result = EModerationResult.Success, + TargetDisplayName = target.m_strDisplayName + }; + } + + public static async Task DisconnectUser(Int64 userID, EModerationAction action, string? reason) + { + WebSocketMessage_ModerationAction notice = new WebSocketMessage_ModerationAction + { + msg_id = (int)EWebSocketMessageID.MODERATION_ACTION, + action_type = action switch + { + EModerationAction.Ban => "ban", + EModerationAction.Kick => "kick", + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }, + reason = reason ?? String.Empty + }; + byte[] noticeJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(notice)); + + await WebSocketManager.DisconnectUser(userID, noticeJson); + } + } +} diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index b9b823d..e2fa70c 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -370,6 +370,8 @@ public static string GetIPAddress(ControllerBase controller) public class Program { + private const string BannedUserContextKey = "GenOnlineService.BannedUserID"; + public static IConfiguration? g_Config = null; public static DiscordBot? g_Discord = null; @@ -599,6 +601,7 @@ private static Task AdditionalValidation(TokenValidatedContext context) // Revocation checks. All in-memory, no database access per request. if (TokenRevocationManager.IsUserBanned(userID)) { + context.HttpContext.Items[BannedUserContextKey] = userID; context.Fail("Failed Validation #12 - User is banned"); return Task.CompletedTask; } @@ -661,6 +664,28 @@ private static Task AdditionalValidation(TokenValidatedContext context) return Task.CompletedTask; } + private static async Task HandleJwtChallenge(JwtBearerChallengeContext context) + { + if (!context.HttpContext.Items.TryGetValue(BannedUserContextKey, out object? value) + || value is not Int64 userID) + { + return; + } + + IDbContextFactory dbFactory = context.HttpContext.RequestServices.GetRequiredService>(); + await using var db = await dbFactory.CreateDbContextAsync(); + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, userID); + + if (banStatus?.IsBanned != true) + { + return; + } + + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status423Locked; + await context.Response.WriteAsJsonAsync(new { ban_reason = banStatus.BanReason }); + } + public class JwtTokenGenerator { private readonly IConfiguration _configuration; @@ -955,7 +980,8 @@ public static async Task Main(string[] args) options.Events = new JwtBearerEvents { - OnTokenValidated = AdditionalValidation + OnTokenValidated = AdditionalValidation, + OnChallenge = HandleJwtChallenge }; }).AddScheme("Basic", null); @@ -1369,9 +1395,9 @@ public static async Task Main(string[] args) timerTick.Start(); } - // keep token revocation state in sync with bans applied directly in the database + // Pick up bans applied directly in the database. { - System.Timers.Timer timerTick = new System.Timers.Timer(60000); // 60s tick + System.Timers.Timer timerTick = new System.Timers.Timer(5000); // 5s tick timerTick.AutoReset = false; timerTick.Elapsed += async (sender, e) => { diff --git a/GenOnlineService/TokenRevocation.cs b/GenOnlineService/TokenRevocation.cs index cdade4b..7370808 100644 --- a/GenOnlineService/TokenRevocation.cs +++ b/GenOnlineService/TokenRevocation.cs @@ -111,7 +111,7 @@ public static async Task> GetBannedUserIDs(AppDbContext db) { Console.WriteLine($"[ERROR] UserTokens.GetBannedUserIDs failed: {ex.Message}"); SentrySdk.CaptureException(ex); - return new List(); + throw; } } } @@ -244,8 +244,7 @@ public static async Task OnTokensIssued(Int64 userID, EUserSessionType sessionTy await Persist(userID, sessionType, newState); } - // Invalidates every token previously issued to this user, across all session types, and drops - // any live websockets they hold. + // Invalidates every token previously issued to this user across all session types. public static async Task RevokeAllTokensForUser(Int64 userID, string reason) { Console.WriteLine($"[TokenRevocation] Revoking all tokens for user {userID} ({reason})."); @@ -260,7 +259,6 @@ public static async Task RevokeAllTokensForUser(Int64 userID, string reason) await Persist(userID, sessionType, newState); } - await DisconnectUser(userID); } // Picks up bans applied directly in the database (there is no in-process ban API). @@ -291,7 +289,9 @@ public static async Task ReconcileBans(AppDbContext db) foreach (Int64 userID in newlyBanned) { + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, userID); await RevokeAllTokensForUser(userID, "user was banned"); + await ModerationManager.DisconnectUser(userID, EModerationAction.Ban, banStatus?.BanReason); } } @@ -314,22 +314,5 @@ private static async Task Persist(Int64 userID, EUserSessionType sessionType, Ca } } - private static async Task DisconnectUser(Int64 userID) - { - try - { - List lstUserSessions = WebSocketManager.GetAllDataFromUser(userID); - foreach (UserSession userSession in lstUserSessions) - { - UserWebSocketInstance? oldWS = WebSocketManager.GetWebSocketForSession(userSession); - await WebSocketManager.DeleteSession(userID, userSession.GetSessionType(), oldWS, true); - } - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] TokenRevocation.DisconnectUser failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - } - } } }