fix: MMO 서버 로직 버그 6건 수정

1. PlayerId 스푸핑 방지: OnTransformPlayer, OnActionPlayer, OnStatePlayer에서
   브로드캐스트 전 packet.PlayerId = hashKey로 강제 교체

2. HP/MP 클라이언트 조작 방지: OnStatePlayer에서 범위 클램핑
   (0 ≤ Hp ≤ MaxHp, 0 ≤ Mp ≤ MaxMp)

3. CreateParty 파티원 등록 누락 수정:
   - memberIds 파라미터 사용 시 모든 멤버를 playerPartyMap에 등록
   - 리더 중복 추가 방지 (Contains 체크)

4. OnIntoChannel 채널 만석 유령 상태 방지:
   이전 채널 제거 후 새 채널 입장 실패 시 이전 채널로 복귀

5. HandleAuth async 경합 방지:
   authenticatingTokens HashSet으로 동일 토큰 동시 인증 차단

6. 레이드 맵 미반환 수정:
   TryReleaseRaidMap 헬퍼 추가, OnChangeMap/OnSessionDisconnected에서
   레이드 맵(1001+) 유저 0명 시 인스턴스 맵 해제

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 00:34:40 +09:00
parent 39ef81d48a
commit 7f2cd281da
2 changed files with 116 additions and 35 deletions

View File

@@ -18,6 +18,9 @@ public class GameServer : ServerBase
private readonly Dictionary<ushort, Action<NetPeer, int, byte[]>> packetHandlers; private readonly Dictionary<ushort, Action<NetPeer, int, byte[]>> packetHandlers;
private readonly UuidGenerator userUuidGenerator; private readonly UuidGenerator userUuidGenerator;
// 동일 토큰 동시 인증 방지
private readonly HashSet<string> authenticatingTokens = new();
public GameServer(int port, string connectionString) : base(port, connectionString) public GameServer(int port, string connectionString) : base(port, connectionString)
{ {
packetHandlers = new Dictionary<ushort, Action<NetPeer, int, byte[]>> packetHandlers = new Dictionary<ushort, Action<NetPeer, int, byte[]>>
@@ -103,49 +106,65 @@ public class GameServer : ServerBase
{ {
AccTokenPacket accTokenPacket = Serializer.Deserialize<AccTokenPacket>(new ReadOnlyMemory<byte>(payload)); AccTokenPacket accTokenPacket = Serializer.Deserialize<AccTokenPacket>(new ReadOnlyMemory<byte>(payload));
string token = accTokenPacket.Token; string token = accTokenPacket.Token;
string username = "";
tokenHash.TryGetValue(token, out int hashKey); // 동일 토큰 동시 인증 방지
if (hashKey <= 1000) if (!authenticatingTokens.Add(token))
{ {
hashKey = userUuidGenerator.Create(); Log.Warning("[Server] 동일 토큰 동시 인증 시도 차단 PeerId={Id}", peer.Id);
peer.Disconnect();
return;
} }
if (sessions.TryGetValue(hashKey, out NetPeer? existing)) try
{ {
// WiFi → LTE 전환 등 재연결: 이전 피어 교체 (토큰 재검증 불필요) string username = "";
existing.Tag = null; tokenHash.TryGetValue(token, out int hashKey);
sessions.Remove(hashKey); if (hashKey <= 1000)
Log.Information("[Server] 재연결 HashKey={Key} Old={Old} New={New}", hashKey, existing.Id, peer.Id);
existing.Disconnect();
}
else
{
// 신규 연결: 웹서버에 JWT 검증 요청
username = await RestApi.Instance.VerifyTokenAsync(token);
if (username == null)
{ {
Log.Warning("[Server] 토큰 검증 실패 - 연결 거부 PeerId={Id}", peer.Id); hashKey = userUuidGenerator.Create();
userUuidGenerator.Release(hashKey);
peer.Disconnect();
return;
} }
Log.Information("[Server] 토큰 검증 성공 Username={Username} PeerId={Id}", username, peer.Id); if (sessions.TryGetValue(hashKey, out NetPeer? existing))
} {
// WiFi → LTE 전환 등 재연결: 이전 피어 교체 (토큰 재검증 불필요)
existing.Tag = null;
sessions.Remove(hashKey);
Log.Information("[Server] 재연결 HashKey={Key} Old={Old} New={New}", hashKey, existing.Id, peer.Id);
existing.Disconnect();
}
else
{
// 신규 연결: 웹서버에 JWT 검증 요청
username = await RestApi.Instance.VerifyTokenAsync(token);
if (username == null)
{
Log.Warning("[Server] 토큰 검증 실패 - 연결 거부 PeerId={Id}", peer.Id);
userUuidGenerator.Release(hashKey);
peer.Disconnect();
return;
}
peer.Tag = new Session(hashKey, peer); Log.Information("[Server] 토큰 검증 성공 Username={Username} PeerId={Id}", username, peer.Id);
((Session)peer.Tag).Token = token; }
if (username.Length > 0)
peer.Tag = new Session(hashKey, peer);
((Session)peer.Tag).Token = token;
if (username.Length > 0)
{
((Session)peer.Tag).UserName = username;
}
sessions[hashKey] = peer;
tokenHash[token] = hashKey;
pendingPeers.Remove(peer.Id);
Log.Information("[Server] 인증 완료 HashKey={Key} PeerId={Id}", hashKey, peer.Id);
OnSessionConnected(peer, hashKey);
}
finally
{ {
((Session)peer.Tag).UserName = username; authenticatingTokens.Remove(token);
} }
sessions[hashKey] = peer;
tokenHash[token] = hashKey;
pendingPeers.Remove(peer.Id);
Log.Information("[Server] 인증 완료 HashKey={Key} PeerId={Id}", hashKey, peer.Id);
OnSessionConnected(peer, hashKey);
} }
protected override void OnSessionConnected(NetPeer peer, int hashKey) protected override void OnSessionConnected(NetPeer peer, int hashKey)
@@ -186,6 +205,9 @@ public class GameServer : ServerBase
// 파티 자동 탈퇴 // 파티 자동 탈퇴
HandlePartyLeaveOnExit(channelId, hashKey); HandlePartyLeaveOnExit(channelId, hashKey);
// 레이드 맵이었으면 해제 체크
TryReleaseRaidMap(cm.GetChannel(channelId), player.CurrentMapId);
// 같은 채널 유저들에게 나갔다고 알림 // 같은 채널 유저들에게 나갔다고 알림
SendExitChannelPacket(peer, hashKey, channelId, player); SendExitChannelPacket(peer, hashKey, channelId, player);
} }
@@ -447,6 +469,20 @@ public class GameServer : ServerBase
{ {
Log.Warning("[GameServer] INTO_CHANNEL 채널 인원 초과 HashKey={Key} ChannelId={ChannelId} UserCount={Count}/{Max}", Log.Warning("[GameServer] INTO_CHANNEL 채널 인원 초과 HashKey={Key} ChannelId={ChannelId} UserCount={Count}/{Max}",
hashKey, packet.ChannelId, newChannel.UserCount, newChannel.UserCountMax); hashKey, packet.ChannelId, newChannel.UserCount, newChannel.UserCountMax);
// 이전 채널에서 이미 제거된 경우 → 이전 채널로 복귀
if (preChannelId >= 0)
{
Player? fallbackPlayer = new()
{
HashKey = hashKey,
PlayerId = hashKey,
Nickname = ((Session)peer.Tag).UserName
};
cm.AddUser(preChannelId, hashKey, fallbackPlayer, peer);
Log.Information("[GameServer] INTO_CHANNEL 만석 → 이전 채널({ChannelId})로 복귀 HashKey={Key}", preChannelId, hashKey);
}
byte[] full = PacketSerializer.Serialize((ushort)PacketCode.INTO_CHANNEL, byte[] full = PacketSerializer.Serialize((ushort)PacketCode.INTO_CHANNEL,
new IntoChannelPacket { ChannelId = -1 }); new IntoChannelPacket { ChannelId = -1 });
SendTo(peer, full); SendTo(peer, full);
@@ -662,6 +698,9 @@ public class GameServer : ServerBase
player.PosZ = packet.Position.Z; player.PosZ = packet.Position.Z;
player.RotY = packet.RotY; player.RotY = packet.RotY;
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// 같은 맵 유저들에게 위치/방향 브로드캐스트 (나 제외) // 같은 맵 유저들에게 위치/방향 브로드캐스트 (나 제외)
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.TRANSFORM_PLAYER, packet); byte[] data = PacketSerializer.Serialize((ushort)PacketCode.TRANSFORM_PLAYER, packet);
BroadcastToMap(channelId, player.CurrentMapId, data, peer, DeliveryMethod.Unreliable); BroadcastToMap(channelId, player.CurrentMapId, data, peer, DeliveryMethod.Unreliable);
@@ -684,6 +723,9 @@ public class GameServer : ServerBase
return; return;
} }
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// 같은 맵 유저들에게 행동 브로드캐스트 (나 제외) // 같은 맵 유저들에게 행동 브로드캐스트 (나 제외)
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.ACTION_PLAYER, packet); byte[] data = PacketSerializer.Serialize((ushort)PacketCode.ACTION_PLAYER, packet);
BroadcastToMap(channelId, player.CurrentMapId, data, peer); BroadcastToMap(channelId, player.CurrentMapId, data, peer);
@@ -707,6 +749,15 @@ public class GameServer : ServerBase
return; return;
} }
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// HP/MP 범위 클램핑 (클라이언트 조작 방지)
if (packet.MaxHp < 0) packet.MaxHp = 0;
if (packet.MaxMp < 0) packet.MaxMp = 0;
packet.Hp = Math.Clamp(packet.Hp, 0, packet.MaxHp);
packet.Mp = Math.Clamp(packet.Mp, 0, packet.MaxMp);
player.Hp = packet.Hp; player.Hp = packet.Hp;
player.MaxHp = packet.MaxHp; player.MaxHp = packet.MaxHp;
player.Mp = packet.Mp; player.Mp = packet.Mp;
@@ -957,6 +1008,10 @@ public class GameServer : ServerBase
} }
SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response)); SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response));
// 레이드 맵(1001+)에서 나갔고 남은 유저가 0이면 인스턴스 맵 해제
TryReleaseRaidMap(channel, oldMapId);
Log.Debug("[GameServer] CHANGE_MAP HashKey={Key} OldMap={OldMapId} NewMap={MapId}", hashKey, oldMapId, packet.MapId); Log.Debug("[GameServer] CHANGE_MAP HashKey={Key} OldMap={OldMapId} NewMap={MapId}", hashKey, oldMapId, packet.MapId);
} }
@@ -1188,6 +1243,22 @@ public class GameServer : ServerBase
BroadcastToChannel(channelId, data); BroadcastToChannel(channelId, data);
} }
// 레이드 맵(1001+)에서 유저가 빠졌을 때, 남은 유저가 0이면 인스턴스 맵 해제
private static void TryReleaseRaidMap(Channel.Channel channel, int mapId)
{
if (mapId < 1001)
{
return;
}
AMap? map = channel.GetMap(mapId);
if (map != null && map.GetUsers().Count == 0)
{
channel.RemoveInstanceMap(mapId);
Log.Debug("[GameServer] 레이드 맵 해제 MapId={MapId}", mapId);
}
}
private void SendError(NetPeer peer, ErrorCode code) private void SendError(NetPeer peer, ErrorCode code)
{ {
ErrorPacket err = new() { Code = code }; ErrorPacket err = new() { Code = code };

View File

@@ -28,6 +28,12 @@ public class PartyManager
memeberIds = new List<int>(); memeberIds = new List<int>();
} }
// 리더 중복 방지: 기존 멤버 목록에 리더가 없을 때만 추가
if (!memeberIds.Contains(leaderId))
{
memeberIds.Add(leaderId);
}
party = new PartyInfo party = new PartyInfo
{ {
PartyId = partyId, PartyId = partyId,
@@ -35,10 +41,14 @@ public class PartyManager
PartyName = partyName, PartyName = partyName,
PartyMemberIds = memeberIds PartyMemberIds = memeberIds
}; };
party.PartyMemberIds.Add(leaderId);
parties[partyId] = party; parties[partyId] = party;
playerPartyMap[leaderId] = partyId;
// 모든 멤버를 playerPartyMap에 등록
foreach (int memberId in memeberIds)
{
playerPartyMap[memberId] = partyId;
}
return true; return true;
} }