Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
75 changes: 67 additions & 8 deletions ProjectVG.Api/Middleware/WebSocketMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,28 +103,87 @@ private async Task RegisterConnection(Guid userId, WebSocket socket)
await _webSocketService.ConnectAsync(userId.ToString());
}

/// <summary>
/// 세션 루프 실행
/// <summary>
/// 세션 루프 실행
/// </summary>
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<byte>(buffer), CancellationToken.None);
_logger.LogInformation("WebSocket 세션 시작: {UserId}", userId);

// Send initial connection confirmation
var welcomeMessage = System.Text.Encoding.UTF8.GetBytes($"{{\"type\":\"connected\",\"userId\":\"{userId}\"}}");
await socket.SendAsync(
new ArraySegment<byte>(welcomeMessage),
WebSocketMessageType.Text,
true,
cancellationTokenSource.Token).ConfigureAwait(false);

while (socket.State == WebSocketState.Open && !cancellationTokenSource.Token.IsCancellationRequested) {
var result = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationTokenSource.Token).ConfigureAwait(false);

if (result.MessageType == WebSocketMessageType.Close) {
_logger.LogInformation("연결 종료 요청: {UserId}", userId);
break;
}

// WebSocket의 기본 제어 메시지들 처리
if (result.MessageType == WebSocketMessageType.Binary) {
_logger.LogDebug("Binary 메시지 받음: {UserId}", userId);
continue;
}

// Handle heartbeat/ping messages
if (result.MessageType == WebSocketMessageType.Text) {
var message = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count);
if (message.Contains("ping")) {
var pongMessage = System.Text.Encoding.UTF8.GetBytes("{\"type\":\"pong\"}");
await socket.SendAsync(
new ArraySegment<byte>(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();
}
}
}
}
Expand Down
20 changes: 10 additions & 10 deletions ProjectVG.Application/Services/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<AuthResult> 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);
Expand All @@ -41,23 +41,23 @@ public async Task<AuthResult> 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<AuthResult> 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,
Expand All @@ -71,13 +71,13 @@ public async Task<AuthResult> 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,
Expand All @@ -91,9 +91,9 @@ public async Task<bool> 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;
}
Expand Down
2 changes: 1 addition & 1 deletion ProjectVG.Application/Services/Auth/IUserAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ public class AuthResult
public TokenResponse Tokens { get; set; } = null!;

/// <summary>사용자 정보</summary>
public UserDto User { get; set; } = null!;
public UserDto? User { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -66,5 +67,32 @@ public async Task ValidateAsync(ChatRequestCommand command)

_logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId);
}

/// <summary>
/// 사용자 세션 유효성 검증
/// </summary>
private async Task ValidateUserSessionAsync(Guid userId)
{
try {
// 사용자 ID를 기반으로 세션 조회
var userSessions = await _sessionStorage.GetSessionsByUserIdAsync(userId.ToString()).ConfigureAwait(false);

if (!userSessions.Any()) {
_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);
}
}
}
}
36 changes: 34 additions & 2 deletions ProjectVG.Infrastructure/Auth/JwtProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,36 @@ public class JwtProvider : IJwtProvider
private readonly string _audience;
private readonly int _accessTokenExpirationMinutes;
private readonly int _refreshTokenExpirationMinutes;
private readonly ILogger<JwtProvider> _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<JwtProvider> 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));
}
}

/// <summary>
Expand Down Expand Up @@ -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;
}
}
Expand Down
12 changes: 6 additions & 6 deletions ProjectVG.Infrastructure/Auth/TokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<TokenResponse> 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);
Expand Down Expand Up @@ -56,14 +56,14 @@ public async Task<TokenResponse> 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");
Expand All @@ -75,7 +75,7 @@ public async Task<TokenResponse> 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");
Expand All @@ -93,7 +93,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)

public async Task<bool> RevokeRefreshTokenAsync(string refreshToken)
{
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken);
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken).ConfigureAwait(false);
}

public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
Expand All @@ -110,7 +110,7 @@ public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
return false;
}

return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken);
return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false);
}

public Task<bool> ValidateAccessTokenAsync(string accessToken)
Expand Down
Loading
Loading