Skip to content

Commit 96eeaa9

Browse files
authored
Fix: 보안 취약점 개선
Fix: 보안 취약점 개선
2 parents 81aabed + c82ee79 commit 96eeaa9

File tree

11 files changed

+385
-57
lines changed

11 files changed

+385
-57
lines changed

ProjectVG.Api/Middleware/WebSocketMiddleware.cs

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,28 +103,97 @@ private async Task RegisterConnection(Guid userId, WebSocket socket)
103103
await _webSocketService.ConnectAsync(userId.ToString());
104104
}
105105

106-
/// <summary>
107-
/// 세션 루프 실행
106+
/// <summary>
107+
/// 세션 루프 실행
108108
/// </summary>
109109
private async Task RunSessionLoop(WebSocket socket, string userId)
110110
{
111-
var buffer = new byte[1024];
111+
var buffer = new byte[1024 * 4]; // Increase buffer size for better performance
112+
var cancellationTokenSource = new CancellationTokenSource();
113+
114+
// Set a reasonable timeout for WebSocket operations
115+
cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(30));
116+
112117
try {
113-
while (socket.State == WebSocketState.Open) {
114-
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
118+
_logger.LogInformation("WebSocket 세션 시작: {UserId}", userId);
119+
120+
// Send initial connection confirmation without exposing user ID
121+
var welcomeMessage = System.Text.Encoding.UTF8.GetBytes("{\"type\":\"connected\",\"status\":\"success\"}");
122+
await socket.SendAsync(
123+
new ArraySegment<byte>(welcomeMessage),
124+
WebSocketMessageType.Text,
125+
true,
126+
cancellationTokenSource.Token).ConfigureAwait(false);
127+
128+
while (socket.State == WebSocketState.Open && !cancellationTokenSource.Token.IsCancellationRequested)
129+
{
130+
WebSocketReceiveResult result;
131+
using var ms = new MemoryStream();
132+
do
133+
{
134+
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationTokenSource.Token)
135+
.ConfigureAwait(false);
136+
if (result.MessageType == WebSocketMessageType.Close)
137+
{
138+
_logger.LogInformation("연결 종료 요청: {UserId}", userId);
139+
break;
140+
}
141+
ms.Write(buffer, 0, result.Count);
142+
} while (!result.EndOfMessage);
143+
144+
if (result.MessageType == WebSocketMessageType.Close) break;
145+
146+
// WebSocket의 기본 제어 메시지들 처리
147+
if (result.MessageType == WebSocketMessageType.Binary) {
148+
_logger.LogDebug("Binary 메시지 받음: {UserId}", userId);
149+
continue;
150+
}
115151

116-
if (result.MessageType == WebSocketMessageType.Close) {
117-
_logger.LogInformation("연결 종료 요청: {UserId}", userId);
118-
break;
152+
// Handle heartbeat/ping messages
153+
if (result.MessageType == WebSocketMessageType.Text) {
154+
var message = System.Text.Encoding.UTF8.GetString(ms.ToArray());
155+
// 매우 단순한 ping 판별 → 추후 JSON 파싱으로 교체 권장
156+
if (string.Equals(message, "ping", StringComparison.OrdinalIgnoreCase) ||
157+
message.Contains("\"type\":\"ping\"", StringComparison.OrdinalIgnoreCase)) {
158+
var pongMessage = System.Text.Encoding.UTF8.GetBytes("{\"type\":\"pong\"}");
159+
await socket.SendAsync(
160+
new ArraySegment<byte>(pongMessage),
161+
WebSocketMessageType.Text,
162+
true,
163+
cancellationTokenSource.Token).ConfigureAwait(false);
164+
}
119165
}
120166
}
121167
}
168+
catch (OperationCanceledException) {
169+
_logger.LogWarning("WebSocket 세션 타임아웃: {UserId}", userId);
170+
}
171+
catch (WebSocketException ex) {
172+
_logger.LogWarning(ex, "WebSocket 연결 오류: {UserId}", userId);
173+
}
122174
catch (Exception ex) {
123-
_logger.LogError(ex, "세션 루프 오류: {UserId}", userId);
175+
_logger.LogError(ex, "세션 루프 예상치 못한 오류: {UserId}", userId);
124176
}
125177
finally {
126-
_logger.LogInformation("연결 해제: {UserId}", userId);
127-
await _webSocketService.DisconnectAsync(userId);
178+
_logger.LogInformation("WebSocket 연결 해제: {UserId}", userId);
179+
180+
try {
181+
await _webSocketService.DisconnectAsync(userId).ConfigureAwait(false);
182+
_connectionRegistry.Unregister(userId);
183+
184+
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
185+
await socket.CloseAsync(
186+
WebSocketCloseStatus.NormalClosure,
187+
"Connection closed",
188+
CancellationToken.None).ConfigureAwait(false);
189+
}
190+
}
191+
catch (Exception ex) {
192+
_logger.LogError(ex, "WebSocket 정리 중 오류: {UserId}", userId);
193+
}
194+
finally {
195+
cancellationTokenSource?.Dispose();
196+
}
128197
}
129198
}
130199
}

ProjectVG.Application/Services/Auth/AuthService.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public async Task<AuthResult> GuestLoginAsync(string guestId)
3030
throw new ValidationException(ErrorCode.GUEST_ID_INVALID);
3131
}
3232

33-
var user = await _userService.TryGetByProviderAsync("guest", guestId);
33+
var user = await _userService.TryGetByProviderAsync("guest", guestId).ConfigureAwait(false);
3434

3535
if (user == null) {
3636
string uuid = GenerateGuestUuid(guestId);
@@ -41,23 +41,23 @@ public async Task<AuthResult> GuestLoginAsync(string guestId)
4141
Provider: "guest"
4242
);
4343

44-
user = await _userService.CreateUserAsync(createCommand);
44+
user = await _userService.CreateUserAsync(createCommand).ConfigureAwait(false);
4545
_logger.LogInformation("새 게스트 사용자 생성됨: UserId={UserId}, GuestId={GuestId}", user.Id, guestId);
4646
}
4747

48-
return await FinalizeLoginAsync(user, "guest");
48+
return await FinalizeLoginAsync(user, "guest").ConfigureAwait(false);
4949
}
5050

5151
private async Task<AuthResult> FinalizeLoginAsync(UserDto user, string provider)
5252
{
5353
// 초기 크레딧 지급
54-
var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id);
54+
var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id).ConfigureAwait(false);
5555
if (tokenGranted) {
5656
_logger.LogInformation("사용자 {UserId}에게 최초 크레딧 지급 완료", user.Id);
5757
}
5858

5959
// 최종 JWT 토큰 발급
60-
var tokens = await _tokenService.GenerateTokensAsync(user.Id);
60+
var tokens = await _tokenService.GenerateTokensAsync(user.Id).ConfigureAwait(false);
6161

6262
return new AuthResult {
6363
Tokens = tokens,
@@ -71,13 +71,13 @@ public async Task<AuthResult> RefreshAccessTokenAsync(string? refreshToken)
7171
throw new ValidationException(ErrorCode.TOKEN_MISSING);
7272
}
7373

74-
var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken);
74+
var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken).ConfigureAwait(false);
7575
if (tokens == null) {
7676
throw new ValidationException(ErrorCode.TOKEN_REFRESH_FAILED);
7777
}
7878

79-
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken);
80-
var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value) : null;
79+
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false);
80+
var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value).ConfigureAwait(false) : null;
8181

8282
return new AuthResult {
8383
Tokens = tokens,
@@ -91,9 +91,9 @@ public async Task<bool> LogoutAsync(string? refreshToken)
9191
throw new ValidationException(ErrorCode.TOKEN_MISSING);
9292
}
9393

94-
var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken);
94+
var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken).ConfigureAwait(false);
9595
if (revoked) {
96-
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken);
96+
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false);
9797
}
9898
return revoked;
9999
}

ProjectVG.Application/Services/Auth/IUserAuthService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ public class AuthResult
4242
public TokenResponse Tokens { get; set; } = null!;
4343

4444
/// <summary>사용자 정보</summary>
45-
public UserDto User { get; set; } = null!;
45+
public UserDto? User { get; set; }
4646
}
4747
}

ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,23 @@ public ChatRequestValidator(
3434

3535
public async Task ValidateAsync(ChatRequestCommand command)
3636
{
37-
// TODO : 세션 검증
37+
// 세션 검증 - 사용자 활성 세션 확인
38+
await ValidateUserSessionAsync(command.UserId).ConfigureAwait(false);
3839

39-
var userExists = await _userService.ExistsByIdAsync(command.UserId);
40+
var userExists = await _userService.ExistsByIdAsync(command.UserId).ConfigureAwait(false);
4041
if (!userExists) {
4142
_logger.LogWarning("사용자 ID 검증 실패: {UserId}", command.UserId);
4243
throw new NotFoundException(ErrorCode.USER_NOT_FOUND, command.UserId);
4344
}
4445

45-
var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId);
46+
var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId).ConfigureAwait(false);
4647
if (!characterExists) {
4748
_logger.LogWarning("캐릭터 ID 검증 실패: {CharacterId}", command.CharacterId);
4849
throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.CharacterId);
4950
}
5051

5152
// 토큰 잔액 검증 - 예상 비용으로 미리 확인
52-
var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId);
53+
var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId).ConfigureAwait(false);
5354
var currentBalance = balance.CurrentBalance;
5455

5556
if (currentBalance <= 0) {
@@ -66,5 +67,35 @@ public async Task ValidateAsync(ChatRequestCommand command)
6667

6768
_logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId);
6869
}
70+
71+
/// <summary>
72+
/// 사용자 세션 유효성 검증
73+
/// </summary>
74+
private async Task ValidateUserSessionAsync(Guid userId)
75+
{
76+
try {
77+
// 사용자 ID를 기반으로 세션 조회
78+
var userSessions = (await _sessionStorage
79+
.GetSessionsByUserIdAsync(userId.ToString())
80+
.ConfigureAwait(false))
81+
.ToList();
82+
83+
if (userSessions.Count == 0) {
84+
_logger.LogWarning("유효하지 않은 사용자 세션: {UserId}", userId);
85+
throw new ValidationException(ErrorCode.SESSION_EXPIRED, "세션이 만료되었습니다. 다시 로그인해 주세요.");
86+
}
87+
88+
// 세션이 존재하면 로그 기록
89+
_logger.LogDebug("세션 검증 성공: {UserId}, 활성 세션 수: {SessionCount}", userId, userSessions.Count);
90+
}
91+
catch (ValidationException) {
92+
throw; // 검증 예외는 그대로 전파
93+
}
94+
catch (Exception ex) {
95+
_logger.LogError(ex, "세션 검증 중 예상치 못한 오류: {UserId}", userId);
96+
// 세션 스토리지 오류 시에는 검증을 통과시키되 로그는 남김 (서비스 가용성 우선)
97+
_logger.LogWarning("세션 스토리지 오류로 인해 세션 검증을 건너뜁니다: {UserId}", userId);
98+
}
99+
}
69100
}
70101
}

ProjectVG.Infrastructure/Auth/JwtProvider.cs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,36 @@ public class JwtProvider : IJwtProvider
2323
private readonly string _audience;
2424
private readonly int _accessTokenExpirationMinutes;
2525
private readonly int _refreshTokenExpirationMinutes;
26+
private readonly ILogger<JwtProvider> _logger;
2627

27-
public JwtProvider(string jwtKey, string issuer, string audience, int accessTokenExpirationMinutes, int refreshTokenExpirationMinutes)
28+
public JwtProvider(string jwtKey, string issuer, string audience, int accessTokenExpirationMinutes, int refreshTokenExpirationMinutes, ILogger<JwtProvider> logger)
2829
{
30+
ValidateJwtKey(jwtKey);
31+
2932
_jwtKey = jwtKey;
3033
_issuer = issuer;
3134
_audience = audience;
3235
_accessTokenExpirationMinutes = accessTokenExpirationMinutes;
3336
_refreshTokenExpirationMinutes = refreshTokenExpirationMinutes;
37+
_logger = logger;
38+
}
39+
40+
private static void ValidateJwtKey(string jwtKey)
41+
{
42+
if (string.IsNullOrEmpty(jwtKey))
43+
{
44+
throw new ArgumentException("JWT key cannot be null or empty", nameof(jwtKey));
45+
}
46+
47+
if (jwtKey.Length < 32)
48+
{
49+
throw new ArgumentException("JWT key must be at least 32 characters long for security", nameof(jwtKey));
50+
}
51+
52+
if (jwtKey.Contains("fallback") || jwtKey.Contains("default") || jwtKey.Contains("sample"))
53+
{
54+
throw new ArgumentException("JWT key appears to be a fallback/default value. Use a secure random key in production", nameof(jwtKey));
55+
}
3456
}
3557

3658
/// <summary>
@@ -120,11 +142,21 @@ public string GenerateRefreshToken(Guid userId)
120142
try
121143
{
122144
var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
123-
124145
return principal;
125146
}
147+
catch (SecurityTokenValidationException ex)
148+
{
149+
_logger.LogWarning("JWT token validation failed: {Error}", ex.Message);
150+
return null;
151+
}
152+
catch (ArgumentException ex)
153+
{
154+
_logger.LogWarning("Invalid JWT token format: {Error}", ex.Message);
155+
return null;
156+
}
126157
catch (Exception ex)
127158
{
159+
_logger.LogError(ex, "Unexpected error during JWT token validation");
128160
return null;
129161
}
130162
}

ProjectVG.Infrastructure/Auth/TokenService.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
2424
var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15);
2525
var refreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440);
2626

27-
var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt);
27+
var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt).ConfigureAwait(false);
2828
if (!stored)
2929
{
3030
_logger.LogError("Failed to store refresh token for user {UserId}", userId);
@@ -56,14 +56,14 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
5656
return null;
5757
}
5858

59-
var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken);
59+
var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false);
6060
if (!isValid)
6161
{
6262
_logger.LogWarning("Refresh token not found in storage");
6363
return null;
6464
}
6565

66-
var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken);
66+
var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken).ConfigureAwait(false);
6767
if (!userId.HasValue)
6868
{
6969
_logger.LogWarning("User ID not found for refresh token");
@@ -75,7 +75,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
7575
var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15);
7676

7777
// 기존 Refresh Token의 만료 시간 조회
78-
var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken);
78+
var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken).ConfigureAwait(false);
7979
if (!refreshTokenExpiresAt.HasValue)
8080
{
8181
_logger.LogWarning("Refresh token expiration time not found");
@@ -93,7 +93,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
9393

9494
public async Task<bool> RevokeRefreshTokenAsync(string refreshToken)
9595
{
96-
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken);
96+
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken).ConfigureAwait(false);
9797
}
9898

9999
public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
@@ -110,7 +110,7 @@ public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
110110
return false;
111111
}
112112

113-
return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken);
113+
return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false);
114114
}
115115

116116
public Task<bool> ValidateAccessTokenAsync(string accessToken)

0 commit comments

Comments
 (0)