diff --git a/ProjectVG.Api/Middleware/WebSocketMiddleware.cs b/ProjectVG.Api/Middleware/WebSocketMiddleware.cs index a21f0f0..b2d95e3 100644 --- a/ProjectVG.Api/Middleware/WebSocketMiddleware.cs +++ b/ProjectVG.Api/Middleware/WebSocketMiddleware.cs @@ -103,28 +103,97 @@ private async Task RegisterConnection(Guid userId, WebSocket socket) await _webSocketService.ConnectAsync(userId.ToString()); } - /// - /// 세션 루프 실행 + /// + /// 세션 루프 실행 /// private async Task RunSessionLoop(WebSocket socket, string userId) { - var buffer = new byte[1024]; + var buffer = new byte[1024 * 4]; // Increase buffer size for better performance + var cancellationTokenSource = new CancellationTokenSource(); + + // Set a reasonable timeout for WebSocket operations + cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(30)); + try { - while (socket.State == WebSocketState.Open) { - var result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + _logger.LogInformation("WebSocket 세션 시작: {UserId}", userId); + + // Send initial connection confirmation without exposing user ID + var welcomeMessage = System.Text.Encoding.UTF8.GetBytes("{\"type\":\"connected\",\"status\":\"success\"}"); + await socket.SendAsync( + new ArraySegment(welcomeMessage), + WebSocketMessageType.Text, + true, + cancellationTokenSource.Token).ConfigureAwait(false); + + while (socket.State == WebSocketState.Open && !cancellationTokenSource.Token.IsCancellationRequested) + { + WebSocketReceiveResult result; + using var ms = new MemoryStream(); + do + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationTokenSource.Token) + .ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("연결 종료 요청: {UserId}", userId); + break; + } + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) break; + + // WebSocket의 기본 제어 메시지들 처리 + if (result.MessageType == WebSocketMessageType.Binary) { + _logger.LogDebug("Binary 메시지 받음: {UserId}", userId); + continue; + } - if (result.MessageType == WebSocketMessageType.Close) { - _logger.LogInformation("연결 종료 요청: {UserId}", userId); - break; + // Handle heartbeat/ping messages + if (result.MessageType == WebSocketMessageType.Text) { + var message = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + // 매우 단순한 ping 판별 → 추후 JSON 파싱으로 교체 권장 + if (string.Equals(message, "ping", StringComparison.OrdinalIgnoreCase) || + message.Contains("\"type\":\"ping\"", StringComparison.OrdinalIgnoreCase)) { + var pongMessage = System.Text.Encoding.UTF8.GetBytes("{\"type\":\"pong\"}"); + await socket.SendAsync( + new ArraySegment(pongMessage), + WebSocketMessageType.Text, + true, + cancellationTokenSource.Token).ConfigureAwait(false); + } } } } + catch (OperationCanceledException) { + _logger.LogWarning("WebSocket 세션 타임아웃: {UserId}", userId); + } + catch (WebSocketException ex) { + _logger.LogWarning(ex, "WebSocket 연결 오류: {UserId}", userId); + } catch (Exception ex) { - _logger.LogError(ex, "세션 루프 오류: {UserId}", userId); + _logger.LogError(ex, "세션 루프 예상치 못한 오류: {UserId}", userId); } finally { - _logger.LogInformation("연결 해제: {UserId}", userId); - await _webSocketService.DisconnectAsync(userId); + _logger.LogInformation("WebSocket 연결 해제: {UserId}", userId); + + try { + await _webSocketService.DisconnectAsync(userId).ConfigureAwait(false); + _connectionRegistry.Unregister(userId); + + if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) { + await socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Connection closed", + CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception ex) { + _logger.LogError(ex, "WebSocket 정리 중 오류: {UserId}", userId); + } + finally { + cancellationTokenSource?.Dispose(); + } } } } diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 63fc38d..a81900e 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -30,7 +30,7 @@ public async Task GuestLoginAsync(string guestId) throw new ValidationException(ErrorCode.GUEST_ID_INVALID); } - var user = await _userService.TryGetByProviderAsync("guest", guestId); + var user = await _userService.TryGetByProviderAsync("guest", guestId).ConfigureAwait(false); if (user == null) { string uuid = GenerateGuestUuid(guestId); @@ -41,23 +41,23 @@ public async Task GuestLoginAsync(string guestId) Provider: "guest" ); - user = await _userService.CreateUserAsync(createCommand); + user = await _userService.CreateUserAsync(createCommand).ConfigureAwait(false); _logger.LogInformation("새 게스트 사용자 생성됨: UserId={UserId}, GuestId={GuestId}", user.Id, guestId); } - return await FinalizeLoginAsync(user, "guest"); + return await FinalizeLoginAsync(user, "guest").ConfigureAwait(false); } private async Task FinalizeLoginAsync(UserDto user, string provider) { // 초기 크레딧 지급 - var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id); + var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id).ConfigureAwait(false); if (tokenGranted) { _logger.LogInformation("사용자 {UserId}에게 최초 크레딧 지급 완료", user.Id); } // 최종 JWT 토큰 발급 - var tokens = await _tokenService.GenerateTokensAsync(user.Id); + var tokens = await _tokenService.GenerateTokensAsync(user.Id).ConfigureAwait(false); return new AuthResult { Tokens = tokens, @@ -71,13 +71,13 @@ public async Task RefreshAccessTokenAsync(string? refreshToken) throw new ValidationException(ErrorCode.TOKEN_MISSING); } - var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken); + var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken).ConfigureAwait(false); if (tokens == null) { throw new ValidationException(ErrorCode.TOKEN_REFRESH_FAILED); } - var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken); - var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value) : null; + var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false); + var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value).ConfigureAwait(false) : null; return new AuthResult { Tokens = tokens, @@ -91,9 +91,9 @@ public async Task LogoutAsync(string? refreshToken) throw new ValidationException(ErrorCode.TOKEN_MISSING); } - var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken); + var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken).ConfigureAwait(false); if (revoked) { - var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken); + var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false); } return revoked; } diff --git a/ProjectVG.Application/Services/Auth/IUserAuthService.cs b/ProjectVG.Application/Services/Auth/IUserAuthService.cs index f4e8977..c4bb6c3 100644 --- a/ProjectVG.Application/Services/Auth/IUserAuthService.cs +++ b/ProjectVG.Application/Services/Auth/IUserAuthService.cs @@ -42,6 +42,6 @@ public class AuthResult public TokenResponse Tokens { get; set; } = null!; /// 사용자 정보 - public UserDto User { get; set; } = null!; + public UserDto? User { get; set; } } } diff --git a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs index a68e10f..0c086b5 100644 --- a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs +++ b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs @@ -34,22 +34,23 @@ public ChatRequestValidator( public async Task ValidateAsync(ChatRequestCommand command) { - // TODO : 세션 검증 + // 세션 검증 - 사용자 활성 세션 확인 + await ValidateUserSessionAsync(command.UserId).ConfigureAwait(false); - var userExists = await _userService.ExistsByIdAsync(command.UserId); + var userExists = await _userService.ExistsByIdAsync(command.UserId).ConfigureAwait(false); if (!userExists) { _logger.LogWarning("사용자 ID 검증 실패: {UserId}", command.UserId); throw new NotFoundException(ErrorCode.USER_NOT_FOUND, command.UserId); } - var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId); + var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId).ConfigureAwait(false); if (!characterExists) { _logger.LogWarning("캐릭터 ID 검증 실패: {CharacterId}", command.CharacterId); throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.CharacterId); } // 토큰 잔액 검증 - 예상 비용으로 미리 확인 - var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId); + var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId).ConfigureAwait(false); var currentBalance = balance.CurrentBalance; if (currentBalance <= 0) { @@ -66,5 +67,35 @@ public async Task ValidateAsync(ChatRequestCommand command) _logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId); } + + /// + /// 사용자 세션 유효성 검증 + /// + private async Task ValidateUserSessionAsync(Guid userId) + { + try { + // 사용자 ID를 기반으로 세션 조회 + var userSessions = (await _sessionStorage + .GetSessionsByUserIdAsync(userId.ToString()) + .ConfigureAwait(false)) + .ToList(); + + if (userSessions.Count == 0) { + _logger.LogWarning("유효하지 않은 사용자 세션: {UserId}", userId); + throw new ValidationException(ErrorCode.SESSION_EXPIRED, "세션이 만료되었습니다. 다시 로그인해 주세요."); + } + + // 세션이 존재하면 로그 기록 + _logger.LogDebug("세션 검증 성공: {UserId}, 활성 세션 수: {SessionCount}", userId, userSessions.Count); + } + catch (ValidationException) { + throw; // 검증 예외는 그대로 전파 + } + catch (Exception ex) { + _logger.LogError(ex, "세션 검증 중 예상치 못한 오류: {UserId}", userId); + // 세션 스토리지 오류 시에는 검증을 통과시키되 로그는 남김 (서비스 가용성 우선) + _logger.LogWarning("세션 스토리지 오류로 인해 세션 검증을 건너뜁니다: {UserId}", userId); + } + } } } diff --git a/ProjectVG.Infrastructure/Auth/JwtProvider.cs b/ProjectVG.Infrastructure/Auth/JwtProvider.cs index 713b1ee..4f76086 100644 --- a/ProjectVG.Infrastructure/Auth/JwtProvider.cs +++ b/ProjectVG.Infrastructure/Auth/JwtProvider.cs @@ -23,14 +23,36 @@ public class JwtProvider : IJwtProvider private readonly string _audience; private readonly int _accessTokenExpirationMinutes; private readonly int _refreshTokenExpirationMinutes; + private readonly ILogger _logger; - public JwtProvider(string jwtKey, string issuer, string audience, int accessTokenExpirationMinutes, int refreshTokenExpirationMinutes) + public JwtProvider(string jwtKey, string issuer, string audience, int accessTokenExpirationMinutes, int refreshTokenExpirationMinutes, ILogger logger) { + ValidateJwtKey(jwtKey); + _jwtKey = jwtKey; _issuer = issuer; _audience = audience; _accessTokenExpirationMinutes = accessTokenExpirationMinutes; _refreshTokenExpirationMinutes = refreshTokenExpirationMinutes; + _logger = logger; + } + + private static void ValidateJwtKey(string jwtKey) + { + if (string.IsNullOrEmpty(jwtKey)) + { + throw new ArgumentException("JWT key cannot be null or empty", nameof(jwtKey)); + } + + if (jwtKey.Length < 32) + { + throw new ArgumentException("JWT key must be at least 32 characters long for security", nameof(jwtKey)); + } + + if (jwtKey.Contains("fallback") || jwtKey.Contains("default") || jwtKey.Contains("sample")) + { + throw new ArgumentException("JWT key appears to be a fallback/default value. Use a secure random key in production", nameof(jwtKey)); + } } /// @@ -120,11 +142,21 @@ public string GenerateRefreshToken(Guid userId) try { var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); - return principal; } + catch (SecurityTokenValidationException ex) + { + _logger.LogWarning("JWT token validation failed: {Error}", ex.Message); + return null; + } + catch (ArgumentException ex) + { + _logger.LogWarning("Invalid JWT token format: {Error}", ex.Message); + return null; + } catch (Exception ex) { + _logger.LogError(ex, "Unexpected error during JWT token validation"); return null; } } diff --git a/ProjectVG.Infrastructure/Auth/TokenService.cs b/ProjectVG.Infrastructure/Auth/TokenService.cs index 1f88a78..fa43192 100644 --- a/ProjectVG.Infrastructure/Auth/TokenService.cs +++ b/ProjectVG.Infrastructure/Auth/TokenService.cs @@ -24,7 +24,7 @@ public async Task GenerateTokensAsync(Guid userId) var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15); var refreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440); - var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt); + var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt).ConfigureAwait(false); if (!stored) { _logger.LogError("Failed to store refresh token for user {UserId}", userId); @@ -56,14 +56,14 @@ public async Task GenerateTokensAsync(Guid userId) return null; } - var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken); + var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false); if (!isValid) { _logger.LogWarning("Refresh token not found in storage"); return null; } - var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken); + var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken).ConfigureAwait(false); if (!userId.HasValue) { _logger.LogWarning("User ID not found for refresh token"); @@ -75,7 +75,7 @@ public async Task GenerateTokensAsync(Guid userId) var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15); // 기존 Refresh Token의 만료 시간 조회 - var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken); + var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken).ConfigureAwait(false); if (!refreshTokenExpiresAt.HasValue) { _logger.LogWarning("Refresh token expiration time not found"); @@ -93,7 +93,7 @@ public async Task GenerateTokensAsync(Guid userId) public async Task RevokeRefreshTokenAsync(string refreshToken) { - return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken); + return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken).ConfigureAwait(false); } public async Task ValidateRefreshTokenAsync(string refreshToken) @@ -110,7 +110,7 @@ public async Task ValidateRefreshTokenAsync(string refreshToken) return false; } - return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken); + return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false); } public Task ValidateAccessTokenAsync(string accessToken) diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 98c9fcd..c297378 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -53,11 +53,26 @@ public static IServiceProvider MigrateDatabase(this IServiceProvider serviceProv private static void AddDatabaseServices(IServiceCollection services, IConfiguration configuration) { services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), - sqlOptions => sqlOptions.EnableRetryOnFailure( - maxRetryCount: 3, - maxRetryDelay: TimeSpan.FromSeconds(10), - errorNumbersToAdd: null))); + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), + sqlOptions => { + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: new int[] { + 2, // System.Data.SqlClient.SqlException: Connection timeout + 20, // The instance of SQL Server you attempted to connect to does not support encryption + 64, // A connection was successfully established with the server, but then an error occurred during the login process + 233, // The client was unable to establish a connection because of an error during connection initialization process before login + 10053, // A transport-level error has occurred when receiving results from the server + 10054, // The connection was forcibly closed by the remote host + 10060, // A network-related or instance-specific error occurred while establishing a connection to SQL Server + 40197, // The service has encountered an error processing your request. Please try again (Azure SQL) + 40501, // The service is currently busy. Retry the request after 10 seconds (Azure SQL) + 40613 // Database is currently unavailable (Azure SQL) + }); + sqlOptions.CommandTimeout(120); + sqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "dbo"); + })); } /// @@ -138,8 +153,10 @@ private static void AddAuthServices(IServiceCollection services, IConfiguration }; services.AddSingleton(jwtSettings); - services.AddScoped(sp => - new JwtProvider(jwtKey, jwtSettings.Issuer, jwtSettings.Audience, jwtSettings.AccessTokenExpirationMinutes, jwtSettings.RefreshTokenExpirationMinutes)); + services.AddScoped(sp => { + var logger = sp.GetRequiredService>(); + return new JwtProvider(jwtKey, jwtSettings.Issuer, jwtSettings.Audience, jwtSettings.AccessTokenExpirationMinutes, jwtSettings.RefreshTokenExpirationMinutes, logger); + }); services.AddScoped(); diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/User/SqlServerUserRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/User/SqlServerUserRepository.cs index fabbf15..3eec249 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/User/SqlServerUserRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/User/SqlServerUserRepository.cs @@ -24,37 +24,37 @@ public async Task> GetAllAsync() return await _context.Users .Where(u => u.Status == AccountStatus.Active) .OrderBy(u => u.Username) - .ToListAsync(); + .ToListAsync().ConfigureAwait(false); } public async Task GetByIdAsync(Guid id) { - return await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task GetByUsernameAsync(string username) { - return await _context.Users.FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task GetByEmailAsync(string email) { - return await _context.Users.FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task GetByProviderIdAsync(string providerId) { - return await _context.Users.FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task GetByProviderAsync(string provider, string providerId) { - return await _context.Users.FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task GetByUIDAsync(string uid) { - return await _context.Users.FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted); + return await _context.Users.FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted).ConfigureAwait(false); } public async Task CreateAsync(User user) @@ -65,14 +65,14 @@ public async Task CreateAsync(User user) user.Status = AccountStatus.Active; _context.Users.Add(user); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync().ConfigureAwait(false); return user; } public async Task UpdateAsync(User user) { - var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == user.Id && u.Status != AccountStatus.Deleted); + var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == user.Id && u.Status != AccountStatus.Deleted).ConfigureAwait(false); if (existingUser == null) { throw new NotFoundException(ErrorCode.USER_NOT_FOUND, "User", user.Id); } @@ -85,14 +85,14 @@ public async Task UpdateAsync(User user) existingUser.Status = user.Status; existingUser.Update(); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync().ConfigureAwait(false); return existingUser; } public async Task DeleteAsync(Guid id) { - var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted); + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted).ConfigureAwait(false); if (user == null) { throw new NotFoundException(ErrorCode.USER_NOT_FOUND, "User", id); @@ -100,7 +100,7 @@ public async Task DeleteAsync(Guid id) user.Status = AccountStatus.Deleted; user.Update(); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync().ConfigureAwait(false); } } } diff --git a/ProjectVG.Tests/Auth/JwtProviderTests.cs b/ProjectVG.Tests/Auth/JwtProviderTests.cs index ccec176..5ac72a8 100644 --- a/ProjectVG.Tests/Auth/JwtProviderTests.cs +++ b/ProjectVG.Tests/Auth/JwtProviderTests.cs @@ -11,6 +11,7 @@ namespace ProjectVG.Tests.Auth public class JwtProviderTests { private readonly JwtProvider _jwtProvider; + private readonly Mock> _mockLogger; private readonly string _testJwtKey = "your-super-secret-jwt-key-here-minimum-32-characters"; private readonly string _testIssuer = "ProjectVG"; private readonly string _testAudience = "ProjectVG"; @@ -19,12 +20,14 @@ public class JwtProviderTests public JwtProviderTests() { + _mockLogger = new Mock>(); _jwtProvider = new JwtProvider( _testJwtKey, _testIssuer, _testAudience, _accessTokenExpirationMinutes, - _refreshTokenExpirationMinutes + _refreshTokenExpirationMinutes, + _mockLogger.Object ); } diff --git a/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs index e3bdf49..b9ae4d6 100644 --- a/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs +++ b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs @@ -50,6 +50,8 @@ public async Task ValidateAsync_ValidRequest_ShouldPassWithoutException() var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, 1000); + SetupValidSession(command.UserId); + _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -61,7 +63,9 @@ public async Task ValidateAsync_ValidRequest_ShouldPassWithoutException() // Act & Assert await _validator.ValidateAsync(command); // Should not throw - + + _mockSessionStorage.Verify(x => x.GetSessionsByUserIdAsync(command.UserId.ToString()), Times.Once); + _mockUserService.Verify(x => x.ExistsByIdAsync(command.UserId), Times.Once); _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); _mockCreditManagementService.Verify(x => x.GetCreditBalanceAsync(command.UserId), Times.Once); } @@ -71,6 +75,7 @@ public async Task ValidateAsync_CharacterNotFound_ShouldThrowValidationException { // Arrange var command = CreateValidChatCommand(); + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -96,6 +101,7 @@ public async Task ValidateAsync_EmptyUserPrompt_ShouldThrowValidationException() requestedAt: DateTime.UtcNow, useTTS: false ); + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -106,7 +112,7 @@ public async Task ValidateAsync_EmptyUserPrompt_ShouldThrowValidationException() // Act & Assert - 현재 ChatRequestValidator에는 빈 prompt 검증이 없으므로 통과해야 함 await _validator.ValidateAsync(command); - + // 검증: 모든 단계가 정상적으로 실행되어야 함 _mockUserService.Verify(x => x.ExistsByIdAsync(command.UserId), Times.Once); _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); @@ -124,6 +130,7 @@ public async Task ValidateAsync_WhitespaceOnlyUserPrompt_ShouldThrowValidationEx requestedAt: DateTime.UtcNow, useTTS: false ); + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -134,7 +141,7 @@ public async Task ValidateAsync_WhitespaceOnlyUserPrompt_ShouldThrowValidationEx // Act & Assert - 현재 ChatRequestValidator에는 whitespace 검증이 없으므로 통과해야 함 await _validator.ValidateAsync(command); - + // 검증: 모든 단계가 정상적으로 실행되어야 함 _mockUserService.Verify(x => x.ExistsByIdAsync(command.UserId), Times.Once); _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); @@ -153,6 +160,7 @@ public async Task ValidateAsync_ZeroCreditBalance_ShouldThrowInsufficientCreditE var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, 0); // Zero balance + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -183,6 +191,7 @@ public async Task ValidateAsync_InsufficientCreditBalance_ShouldThrowInsufficien var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, 5); // Less than required 10 credits + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -213,6 +222,7 @@ public async Task ValidateAsync_ExactlyEnoughCredits_ShouldPassValidation() var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, 10); // Exactly required amount + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -237,6 +247,7 @@ public async Task ValidateAsync_MoreThanEnoughCredits_ShouldPassValidation() var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, 100); // More than enough + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -264,6 +275,7 @@ public async Task ValidateAsync_CreditServiceThrowsException_ShouldPropagateExce var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -286,6 +298,7 @@ public async Task ValidateAsync_CharacterServiceThrowsException_ShouldPropagateE // Arrange var command = CreateValidChatCommand(); + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -307,6 +320,7 @@ public async Task ValidateAsync_NegativeCreditBalance_ShouldThrowInsufficientCre var character = CreateValidCharacterDto(command.CharacterId); var creditBalance = CreateCreditBalance(command.UserId, -5); // Negative balance + SetupValidSession(command.UserId); _mockUserService.Setup(x => x.ExistsByIdAsync(command.UserId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) @@ -413,6 +427,21 @@ private void VerifyDebugLogged(string expectedMessage) Times.Once); } + private void SetupValidSession(Guid userId) + { + var sessionInfos = new List + { + new ProjectVG.Common.Models.Session.SessionInfo + { + SessionId = Guid.NewGuid().ToString(), + UserId = userId.ToString(), + ConnectedAt = DateTime.UtcNow + } + }; + _mockSessionStorage.Setup(x => x.GetSessionsByUserIdAsync(userId.ToString())) + .ReturnsAsync(sessionInfos); + } + #endregion } } \ No newline at end of file diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..2afa2da --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,147 @@ +# Security Guidelines and Best Practices + +## 🔐 Critical Security Requirements + +### JWT Token Security +- **Key Length**: JWT secret key MUST be at least 32 characters long +- **Key Strength**: Avoid default, fallback, or sample keys in production +- **Key Management**: Store JWT keys in secure environment variables only +- **Token Validation**: All JWT validation failures are logged for security monitoring +- **Token Headers**: Multiple headers supported (Authorization, X-Forwarded-Authorization, etc.) + +```bash +# Example: Generate secure JWT key +JWT_SECRET_KEY=$(openssl rand -base64 32) +``` + +### Database Security +- **Connection Resilience**: Enhanced retry policies with exponential backoff +- **Connection Timeout**: 120-second timeout for database operations +- **Retry Logic**: 5 attempts with up to 30-second delays +- **Error Handling**: Specific SQL error codes handled for Azure SQL and on-premises +- **Connection Pooling**: EF Core manages connection pooling automatically + +### Authentication Flow Security +- **OAuth2 PKCE**: Proof Key for Code Exchange mandatory for OAuth2 flows +- **Session Management**: Redis-based session storage with TTL expiration +- **Token Lifecycle**: Short-lived access tokens (15min) + long-lived refresh tokens (30 days) +- **Session Validation**: Real-time session validation for critical operations +- **Logout Security**: Proper token revocation and cleanup + +### Input Validation Security +- **Data Annotations**: Comprehensive validation rules on all DTOs +- **Business Logic Validation**: Domain-specific validation in Application layer +- **SQL Injection Prevention**: Entity Framework parameterized queries +- **XSS Protection**: JSON serialization prevents script injection +- **CSRF Protection**: OAuth2 state parameter validation + +## 🛡️ Production Security Checklist + +### Environment Configuration +- [ ] JWT_SECRET_KEY is at least 32 characters and cryptographically random +- [ ] Database connection strings use secure authentication +- [ ] Redis connection secured with authentication if applicable +- [ ] TLS/HTTPS enforced for all external communications +- [ ] OAuth2 client secrets stored securely +- [ ] API keys for external services (TTS, LLM, Memory) secured + +### Runtime Security Monitoring +- [ ] JWT validation failures monitored and alerted +- [ ] Database connection failures logged and monitored +- [ ] WebSocket connection anomalies tracked +- [ ] Failed authentication attempts rate-limited +- [ ] Session hijacking patterns detected + +### Code Security Standards +- [ ] ConfigureAwait(false) used in all async service methods +- [ ] Exception handling prevents information leakage +- [ ] Logging excludes sensitive information (tokens, passwords, keys) +- [ ] User input sanitized before database operations +- [ ] File uploads (if any) validated for type and size + +## 🚨 Security Incident Response + +### JWT Compromise Response +1. **Immediate**: Rotate JWT secret key +2. **Revoke**: All existing refresh tokens in Redis +3. **Audit**: Review authentication logs for suspicious activity +4. **Monitor**: Enhanced logging for unusual patterns + +### Database Security Incident +1. **Isolate**: Database connections if breach suspected +2. **Audit**: Query logs for unauthorized access patterns +3. **Verify**: Data integrity and unauthorized modifications +4. **Recovery**: Implement additional connection restrictions + +### Session Security Incident +1. **Clear**: All Redis sessions for affected users +2. **Force**: Re-authentication for all users +3. **Monitor**: Session creation patterns +4. **Update**: Session validation logic if needed + +## 📊 Security Monitoring and Logging + +### Critical Security Events to Monitor +- JWT token validation failures +- Database connection retries and failures +- WebSocket connection anomalies +- OAuth2 authentication failures +- Session validation failures +- Credit balance tampering attempts + +### Log Levels for Security Events +```csharp +// Security violations - ERROR level +_logger.LogError("Security violation detected: {Details}", details); + +// Authentication failures - WARNING level +_logger.LogWarning("Authentication failed: {Reason}", reason); + +// Security success events - INFORMATION level +_logger.LogInformation("Secure operation completed: {Operation}", operation); + +// Security debugging - DEBUG level (development only) +_logger.LogDebug("Security check passed: {Check}", check); +``` + +## 🔧 Development Security Guidelines + +### Secure Coding Practices +1. **Never hardcode secrets** in source code +2. **Validate all inputs** at API and business logic layers +3. **Use parameterized queries** exclusively (EF Core handles this) +4. **Handle exceptions securely** without exposing system details +5. **Log security events** appropriately for monitoring + +### Testing Security Features +1. **Authentication Testing**: Valid/invalid tokens, expired tokens, malformed tokens +2. **Authorization Testing**: Role-based access, resource ownership +3. **Input Validation Testing**: Boundary conditions, malformed data +4. **Session Testing**: Session hijacking prevention, timeout handling +5. **Error Handling Testing**: Information leakage prevention + +## 🎯 Recent Security Improvements + +### JWT Security Enhancements +- Added JWT key length validation (minimum 32 characters) +- Implemented fallback key detection and prevention +- Enhanced JWT validation exception handling and logging +- Added structured logging for security event tracking + +### Database Connection Security +- Increased retry attempts from 3 to 5 +- Extended maximum retry delay from 10 to 30 seconds +- Added 10 specific SQL error codes for better failure handling +- Set 120-second command timeout for long operations + +### WebSocket Security Improvements +- Enhanced connection lifecycle management +- Added proper resource cleanup on disconnection +- Implemented heartbeat mechanism for connection health +- Added connection timeout handling (30 minutes) + +### Session Management Security +- Implemented real-time session validation +- Added session activity tracking +- Graceful handling of session storage failures +- Enhanced session-based authentication for critical operations \ No newline at end of file