Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 105 additions & 6 deletions GenOnlineService/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,30 @@ public static List<UserSession> GetAllDataFromUser(Int64 userID)
return lstRet;
}

public static async Task DisconnectUser(Int64 userID, byte[] finalMessage)
{
List<UserSession> 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<bool> 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
Expand Down Expand Up @@ -819,9 +843,11 @@ public static async Task MarkRoomMemberListAsDirty(int roomID)
}

// NOTE: only one instance for ALL websockets/sessions, and is destroyed when the last one of the former is destroyed
public class SharedUserData
{
private int m_RefCount = 0;
public class SharedUserData
{
private int m_RefCount = 0;
private readonly Queue<Int64> m_chatMessageTimestamps = new();
private Int64? m_lastChatRateLimitNoticeTimestamp;

public void IncrementRefCount()
{
Expand Down Expand Up @@ -849,7 +875,49 @@ public bool NeedsGC()

public UserSocialContainer GetSocialContainer() { return m_socialContainer; }

public bool IsAdmin() { return m_bIsAdmin; }
public bool IsAdmin() { return m_bIsAdmin; }

public bool TryConsumeChatMessage()
{
const int maxMessages = 3;
const Int64 windowMilliseconds = 9000;
Int64 now = Environment.TickCount64;

lock (m_chatMessageTimestamps)
{
while (m_chatMessageTimestamps.TryPeek(out Int64 timestamp)
&& now - timestamp >= windowMilliseconds)
{
m_chatMessageTimestamps.Dequeue();
}

if (m_chatMessageTimestamps.Count >= maxMessages)
{
return false;
}

m_chatMessageTimestamps.Enqueue(now);
return true;
}
}

public bool TryConsumeChatRateLimitNotice()
{
const Int64 intervalMilliseconds = 9000;
Int64 now = Environment.TickCount64;

lock (m_chatMessageTimestamps)
{
if (m_lastChatRateLimitNoticeTimestamp.HasValue
&& now - m_lastChatRateLimitNoticeTimestamp.Value < intervalMilliseconds)
{
return false;
}

m_lastChatRateLimitNoticeTimestamp = now;
return true;
}
}

public SharedUserData(Int64 ownerID, UserSocialContainer socialContainer, string strDisplayName, bool bIsAdmin, PlayerStats userStats)
{
Expand Down Expand Up @@ -2545,7 +2613,10 @@ public enum EWebSocketMessageID
AC_REGISTER_PLAYER = 40,
AC_DEREGISTER_PLAYER = 41,
WS_KEEPALIVE = 42,
WS_KEEPALIVE_CLIENT = 43
WS_KEEPALIVE_CLIENT = 43,
MODERATION_NOTICE = 46,
MODERATION_COMMAND = 47,
MODERATION_COMMAND_RESULT = 48
};

public static class UserPresence
Expand Down Expand Up @@ -2643,6 +2714,34 @@ public class WebSocketMessage_StartMatch : WebSocketMessage
public string screenshot_url { get; set; } = String.Empty;
}

public class WebSocketMessage_ModerationNotice : WebSocketMessage
{
public string action_type { get; set; } = String.Empty;
public string reason { get; set; } = String.Empty;

[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? scope_type { get; set; }
}

public sealed class WebSocketMessage_ModerationCommand : WebSocketMessage
{
public UInt64 request_id { get; set; }
public string action_type { get; set; } = String.Empty;
public Int64? target_user_id { get; set; }
public string reason { get; set; } = String.Empty;
}

public sealed class WebSocketMessage_ModerationCommandResult : WebSocketMessage
{
public UInt64 request_id { get; set; }
public bool success { get; set; }

[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? error_code { get; set; }

public string message { get; set; } = String.Empty;
}

public abstract class WebSocketMessage
{
public int msg_id { get; set; }
Expand Down Expand Up @@ -2829,4 +2928,4 @@ public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage

}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "";
}
Expand Down Expand Up @@ -176,12 +177,14 @@ public async Task<APIResult> Post_InternalHandler(string jsonData, string ipAddr
Int64 user_id = loginEntry.user_id;

// 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "";
}
Expand Down Expand Up @@ -129,13 +130,15 @@ public async Task<APIResult> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,13 +98,15 @@ public async Task<APIResult> 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;
}
Expand Down
69 changes: 57 additions & 12 deletions GenOnlineService/Controllers/WebSocket/WebSocketController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,28 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory<AppDbCon
_dbFactory = dbFactory;
}

private static readonly JsonSerializerOptions JsonOpts = new()
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
AllowOutOfOrderMetadataProperties = true
};
};

private static void QueueChatRateLimited(UserSession session, SharedUserData userData, string scopeType)
{
if (!userData.TryConsumeChatRateLimitNotice())
{
return;
}

WebSocketMessage_ModerationNotice message = new()
{
msg_id = (int)EWebSocketMessageID.MODERATION_NOTICE,
action_type = "rate_limit",
reason = "Rate limit: Please wait before sending another message.",
scope_type = scopeType
};
session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)));
}

// GeoIP DB is designed to be reused; opening per request is expensive.
// It is gitignored and absent in fresh clones, so a failure to open must
Expand Down Expand Up @@ -375,9 +392,25 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession
WebSocketMessage_Social_FriendChatMessage_Inbound? chatMessage =
JsonSerializer.Deserialize<WebSocketMessage_Social_FriendChatMessage_Inbound>(payload, JsonOpts);

if (chatMessage != null)
{
// must be online & friends
if (chatMessage != null)
{
if (!sourceUserData.TryConsumeChatMessage())
{
if (sourceUserData.TryConsumeChatRateLimitNotice())
{
WebSocketMessage_Social_FriendChatMessage_Outbound rateLimitMessage = new()
{
msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_CHAT_MESSAGE_SERVER_TO_CLIENT,
source_user_id = sourceUserSession.m_UserID,
target_user_id = chatMessage.target_user_id,
message = "Rate limit: Please wait before sending another message."
};
sourceUserSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(rateLimitMessage)));
}
return;
}

// must be online & friends

SharedUserData? targetUserData = WebSocketManager.GetSharedDataForUser(chatMessage.target_user_id);

Expand Down Expand Up @@ -426,9 +459,15 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession
WebSocketMessage_NetworkRoomChatMessageInbound? chatMessage =
JsonSerializer.Deserialize<WebSocketMessage_NetworkRoomChatMessageInbound>(payload, JsonOpts);

if (chatMessage != null)
{
// response
if (chatMessage != null)
{
if (!sourceUserData.TryConsumeChatMessage())
{
QueueChatRateLimited(sourceUserSession, sourceUserData, "room");
return;
}

// response
WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound();
outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER;

Expand All @@ -442,7 +481,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession
{
if (sourceUserData.IsAdmin())
{
outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message);
outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message);
outboundMsg.admin = true;
outboundMsg.name_change = false;
}
Expand Down Expand Up @@ -703,9 +742,15 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession
WebSocketMessage_LobbyChatMessageInbound? chatMessage =
JsonSerializer.Deserialize<WebSocketMessage_LobbyChatMessageInbound>(payload, JsonOpts);

if (chatMessage != null)
{
// get lobby
if (chatMessage != null)
{
if (!sourceUserData.TryConsumeChatMessage())
{
QueueChatRateLimited(sourceUserSession, sourceUserData, "lobby");
return;
}

// get lobby
Lobby? playerLobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID);

if (playerLobby != null)
Expand Down
32 changes: 32 additions & 0 deletions GenOnlineService/Database/Database.User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PendingLogin>
{
Expand Down Expand Up @@ -279,6 +285,18 @@ public static class Users
.Select(u => u.IsBanned)
.FirstOrDefault());

private static readonly Func<AppDbContext, long, Task<UserBanStatus?>> _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<AppDbContext, long, Task<string?>> _getDisplayNameQuery =
EF.CompileAsyncQuery((AppDbContext db, long userId) =>
db.Users
Expand Down Expand Up @@ -446,6 +464,20 @@ public static async Task<bool> IsUserBanned(AppDbContext db, long userId)
}
}

public static async Task<UserBanStatus?> 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<string> GetDisplayName(AppDbContext db, long userId)
{
Expand Down
Loading
Loading