From b5bf14704f35b21e121eb3d388b930dee5fa7a19 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 11:59:27 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20OAuth2=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Infrastructure/Auth.meta | 8 + Assets/Infrastructure/Auth/Examples.meta | 8 + .../Auth/Examples/ServerOAuth2Example.cs | 372 ++++++++++++++ .../Auth/Examples/ServerOAuth2Example.cs.meta | 2 + Assets/Infrastructure/Auth/Models.meta | 8 + .../Infrastructure/Auth/Models/AccessToken.cs | 149 ++++++ .../Auth/Models/AccessToken.cs.meta | 2 + .../Auth/Models/RefreshToken.cs | 132 +++++ .../Auth/Models/RefreshToken.cs.meta | 2 + Assets/Infrastructure/Auth/Models/TokenSet.cs | 149 ++++++ .../Auth/Models/TokenSet.cs.meta | 2 + Assets/Infrastructure/Auth/OAuth2.meta | 8 + Assets/Infrastructure/Auth/OAuth2/Config.meta | 8 + .../Auth/OAuth2/Config/ServerOAuth2Config.cs | 233 +++++++++ .../OAuth2/Config/ServerOAuth2Config.cs.meta | 2 + .../Infrastructure/Auth/OAuth2/Handlers.meta | 8 + .../OAuth2/Handlers/DesktopCallbackHandler.cs | 240 +++++++++ .../Handlers/DesktopCallbackHandler.cs.meta | 2 + .../OAuth2/Handlers/IOAuth2CallbackHandler.cs | 40 ++ .../Handlers/IOAuth2CallbackHandler.cs.meta | 2 + .../OAuth2/Handlers/MobileCallbackHandler.cs | 134 +++++ .../Handlers/MobileCallbackHandler.cs.meta | 2 + .../Handlers/OAuth2CallbackHandlerFactory.cs | 70 +++ .../OAuth2CallbackHandlerFactory.cs.meta | 2 + .../OAuth2/Handlers/WebGLCallbackHandler.cs | 121 +++++ .../Handlers/WebGLCallbackHandler.cs.meta | 2 + .../Auth/OAuth2/IServerOAuth2Client.cs | 52 ++ .../Auth/OAuth2/IServerOAuth2Client.cs.meta | 2 + Assets/Infrastructure/Auth/OAuth2/Models.meta | 8 + .../Auth/OAuth2/Models/PKCEParameters.cs | 65 +++ .../Auth/OAuth2/Models/PKCEParameters.cs.meta | 2 + .../Auth/OAuth2/Models/ServerOAuth2Models.cs | 200 ++++++++ .../OAuth2/Models/ServerOAuth2Models.cs.meta | 2 + .../Auth/OAuth2/ServerOAuth2Provider.cs | 475 ++++++++++++++++++ .../Auth/OAuth2/ServerOAuth2Provider.cs.meta | 2 + Assets/Infrastructure/Auth/OAuth2/Utils.meta | 8 + .../Auth/OAuth2/Utils/OAuth2CallbackParser.cs | 284 +++++++++++ .../OAuth2/Utils/OAuth2CallbackParser.cs.meta | 2 + .../Auth/OAuth2/Utils/PKCEGenerator.cs | 185 +++++++ .../Auth/OAuth2/Utils/PKCEGenerator.cs.meta | 2 + .../Network/Http/HttpApiClient.cs | 121 ++++- Assets/Resources/ServerOAuth2Config.asset | 25 + .../Resources/ServerOAuth2Config.asset.meta | 8 + 43 files changed, 3150 insertions(+), 1 deletion(-) create mode 100644 Assets/Infrastructure/Auth.meta create mode 100644 Assets/Infrastructure/Auth/Examples.meta create mode 100644 Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs create mode 100644 Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta create mode 100644 Assets/Infrastructure/Auth/Models.meta create mode 100644 Assets/Infrastructure/Auth/Models/AccessToken.cs create mode 100644 Assets/Infrastructure/Auth/Models/AccessToken.cs.meta create mode 100644 Assets/Infrastructure/Auth/Models/RefreshToken.cs create mode 100644 Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta create mode 100644 Assets/Infrastructure/Auth/Models/TokenSet.cs create mode 100644 Assets/Infrastructure/Auth/Models/TokenSet.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Config.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Models.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Utils.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta create mode 100644 Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs create mode 100644 Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta create mode 100644 Assets/Resources/ServerOAuth2Config.asset create mode 100644 Assets/Resources/ServerOAuth2Config.asset.meta diff --git a/Assets/Infrastructure/Auth.meta b/Assets/Infrastructure/Auth.meta new file mode 100644 index 0000000..a4d6684 --- /dev/null +++ b/Assets/Infrastructure/Auth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f5e5e688529fdd4da5b8fb92bcacb36 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Examples.meta b/Assets/Infrastructure/Auth/Examples.meta new file mode 100644 index 0000000..388f3a5 --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 51cf94ce6b94d38468a2ac8b3cae990c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs new file mode 100644 index 0000000..48b0b91 --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs @@ -0,0 +1,372 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.OAuth2; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Auth.Models; +using System; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// 서버 OAuth2 로그인 예제 + /// + public class ServerOAuth2Example : MonoBehaviour + { + [Header("UI 컴포넌트")] + [SerializeField] private Button loginButton; + [SerializeField] private Button logoutButton; + [SerializeField] private TextMeshProUGUI statusText; + [SerializeField] private TextMeshProUGUI userInfoText; + + [Header("설정")] + [SerializeField] private ServerOAuth2Config oauth2Config; + + // OAuth2 설정 자동 로드 + private ServerOAuth2Config OAuth2Config => oauth2Config ?? ServerOAuth2Config.Instance; + + private ServerOAuth2Provider _oauth2Provider; + private TokenSet _currentTokenSet; + private bool _isLoggedIn = false; + + #region Unity Lifecycle + + private void Start() + { + InitializeOAuth2Provider(); + SetupEventHandlers(); + UpdateUI(); + } + + private void OnDestroy() + { + // 이벤트 핸들러 해제 + if (loginButton != null) loginButton.onClick.RemoveAllListeners(); + if (logoutButton != null) logoutButton.onClick.RemoveAllListeners(); + } + + #endregion + + #region Initialization + + private void InitializeOAuth2Provider() + { + var config = OAuth2Config; + + if (config == null) + { + Debug.LogError("[ServerOAuth2Example] OAuth2 설정을 로드할 수 없습니다."); + ShowStatus("OAuth2 설정을 로드할 수 없습니다.", Color.red); + return; + } + + // HttpApiClient 초기화 상태 확인 + var httpClient = ProjectVG.Infrastructure.Network.Http.HttpApiClient.Instance; + if (httpClient == null) + { + Debug.LogError("[ServerOAuth2Example] HttpApiClient.Instance가 null입니다."); + ShowStatus("HttpApiClient가 초기화되지 않았습니다.", Color.red); + return; + } + + Debug.Log($"[ServerOAuth2Example] HttpApiClient 초기화 상태: {httpClient.IsInitialized}"); + + if (!httpClient.IsInitialized) + { + Debug.LogError("[ServerOAuth2Example] HttpApiClient가 초기화되지 않았습니다."); + ShowStatus("HttpApiClient가 초기화되지 않았습니다.", Color.red); + return; + } + + _oauth2Provider = new ServerOAuth2Provider(config); + + if (!_oauth2Provider.IsConfigured) + { + Debug.LogError("[ServerOAuth2Example] OAuth2 설정이 유효하지 않습니다."); + ShowStatus("OAuth2 설정이 유효하지 않습니다.", Color.red); + return; + } + + Debug.Log("[ServerOAuth2Example] OAuth2 Provider 초기화 완료"); + Debug.Log(_oauth2Provider.GetDebugInfo()); + } + + private void SetupEventHandlers() + { + if (loginButton != null) + loginButton.onClick.AddListener(() => _ = OnLoginButtonClicked()); + + if (logoutButton != null) + logoutButton.onClick.AddListener(() => _ = OnLogoutButtonClicked()); + } + + #endregion + + #region UI Event Handlers + + private async UniTaskVoid OnLoginButtonClicked() + { + if (_isLoggedIn) + { + ShowStatus("이미 로그인되어 있습니다.", Color.yellow); + return; + } + + if (_oauth2Provider == null) + { + ShowStatus("OAuth2 Provider가 초기화되지 않았습니다.", Color.red); + return; + } + + try + { + ShowStatus("서버 OAuth2 로그인 시작...", Color.blue); + SetButtonsEnabled(false); + + // 전체 OAuth2 로그인 플로우 실행 + _currentTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(OAuth2Config.Scope); + + if (_currentTokenSet?.IsValid() == true) + { + _isLoggedIn = true; + ShowStatus("서버 OAuth2 로그인 성공!", Color.green); + + // 상세 토큰 정보 로그 + LogDetailedTokenInfo(); + + // 토큰 정보 표시 + DisplayTokenInfo(); + + // 자동 토큰 갱신 시작 + _ = StartAutoTokenRefresh(); + } + else + { + ShowStatus("로그인 실패: 유효하지 않은 토큰", Color.red); + } + } + catch (System.Exception ex) + { + ShowStatus($"로그인 실패: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 로그인 오류: {ex}"); + } + finally + { + SetButtonsEnabled(true); + UpdateUI(); + } + } + + private async UniTaskVoid OnLogoutButtonClicked() + { + if (!_isLoggedIn) + { + ShowStatus("로그인되어 있지 않습니다.", Color.yellow); + return; + } + + try + { + ShowStatus("로그아웃 중...", Color.blue); + + // 토큰 정리 + _currentTokenSet?.Clear(); + _currentTokenSet = null; + _isLoggedIn = false; + + // UI 초기화 + ClearUserInfo(); + ShowStatus("로그아웃 완료", Color.green); + } + catch (System.Exception ex) + { + ShowStatus($"로그아웃 실패: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 로그아웃 오류: {ex}"); + } + finally + { + UpdateUI(); + } + } + + #endregion + + #region Token Management + + private async UniTaskVoid StartAutoTokenRefresh() + { + while (_isLoggedIn && _currentTokenSet?.HasRefreshToken() == true) + { + try + { + // 토큰 갱신이 필요한지 확인 + if (_currentTokenSet.NeedsRefresh()) + { + Debug.Log("[ServerOAuth2Example] 토큰 갱신 시작"); + + // TODO: 서버 OAuth2 토큰 갱신 API 호출 + // 현재는 전체 재로그인 플로우 실행 + var newTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(OAuth2Config.Scope); + + if (newTokenSet?.IsValid() == true) + { + _currentTokenSet = newTokenSet; + Debug.Log("[ServerOAuth2Example] 토큰 갱신 성공"); + DisplayTokenInfo(); + } + else + { + Debug.LogWarning("[ServerOAuth2Example] 토큰 갱신 실패"); + break; + } + } + + // 1분 대기 + await UniTask.Delay(60000); + } + catch (System.Exception ex) + { + Debug.LogError($"[ServerOAuth2Example] 토큰 갱신 오류: {ex.Message}"); + break; + } + } + } + + #endregion + + #region UI Management + + private void UpdateUI() + { + if (loginButton != null) + loginButton.interactable = !_isLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = _isLoggedIn; + } + + private void SetButtonsEnabled(bool enabled) + { + if (loginButton != null) loginButton.interactable = enabled && !_isLoggedIn; + if (logoutButton != null) logoutButton.interactable = enabled && _isLoggedIn; + } + + private void ShowStatus(string message, Color color) + { + if (statusText != null) + { + statusText.text = message; + statusText.color = color; + } + + Debug.Log($"[ServerOAuth2Example] {message}"); + } + + private void DisplayTokenInfo() + { + if (userInfoText == null || _currentTokenSet == null) + return; + + var info = $"토큰 정보:\n"; + info += $"Access Token: {_currentTokenSet.AccessToken?.Token?.Substring(0, Math.Min(20, _currentTokenSet.AccessToken.Token.Length))}...\n"; + info += $"Access Token 만료: {_currentTokenSet.AccessToken?.ExpiresAt:yyyy-MM-dd HH:mm:ss}\n"; + info += $"Refresh Token: {(string.IsNullOrEmpty(_currentTokenSet.RefreshToken?.Token) ? "없음" : "있음")}\n"; + info += $"토큰 유효: {_currentTokenSet.IsValid()}\n"; + info += $"갱신 필요: {_currentTokenSet.NeedsRefresh()}"; + + userInfoText.text = info; + } + + /// + /// 상세 토큰 정보를 콘솔에 로그 + /// + private void LogDetailedTokenInfo() + { + if (_currentTokenSet == null) + { + Debug.LogError("[ServerOAuth2Example] 토큰 세트가 null입니다."); + return; + } + + Debug.Log("=== 🎉 OAuth2 로그인 성공 - 토큰 정보 ==="); + + // Access Token 정보 + if (_currentTokenSet.AccessToken != null) + { + Debug.Log($"✅ Access Token: {_currentTokenSet.AccessToken.Token}"); + Debug.Log($" - 만료 시간: {_currentTokenSet.AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss}"); + Debug.Log($" - 만료까지: {(_currentTokenSet.AccessToken.ExpiresAt - DateTime.UtcNow).TotalMinutes:F1}분"); + Debug.Log($" - 토큰 타입: {_currentTokenSet.AccessToken.TokenType}"); + Debug.Log($" - 스코프: {_currentTokenSet.AccessToken.Scope}"); + } + else + { + Debug.LogError("❌ Access Token이 null입니다."); + } + + // Refresh Token 정보 + if (_currentTokenSet.RefreshToken != null) + { + Debug.Log($"✅ Refresh Token: {_currentTokenSet.RefreshToken.Token}"); + Debug.Log($" - 만료 시간: {_currentTokenSet.RefreshToken.ExpiresAt:yyyy-MM-dd HH:mm:ss}"); + Debug.Log($" - 만료까지: {(_currentTokenSet.RefreshToken.ExpiresAt - DateTime.UtcNow).TotalDays:F1}일"); + Debug.Log($" - 디바이스 ID: {_currentTokenSet.RefreshToken.DeviceId}"); + } + else + { + Debug.LogWarning("⚠️ Refresh Token이 null입니다."); + } + + // 토큰 세트 상태 + Debug.Log($"📊 토큰 세트 상태:"); + Debug.Log($" - 유효성: {_currentTokenSet.IsValid()}"); + Debug.Log($" - 갱신 필요: {_currentTokenSet.NeedsRefresh()}"); + Debug.Log($" - Refresh Token 보유: {_currentTokenSet.HasRefreshToken()}"); + + Debug.Log("=== 토큰 정보 끝 ==="); + } + + private void ClearUserInfo() + { + if (userInfoText != null) + { + userInfoText.text = "로그인하여 토큰 정보를 확인하세요."; + } + } + + #endregion + + #region Public Methods + + /// + /// 현재 토큰 세트 조회 + /// + public TokenSet GetCurrentTokenSet() + { + return _currentTokenSet; + } + + /// + /// 로그인 상태 조회 + /// + public bool IsLoggedIn() + { + return _isLoggedIn && _currentTokenSet?.IsValid() == true; + } + + /// + /// 유효한 Access Token 조회 + /// + public string GetValidAccessToken() + { + if (IsLoggedIn() && _currentTokenSet?.AccessToken?.IsValid() == true) + { + return _currentTokenSet.AccessToken.Token; + } + return null; + } + + #endregion + } +} diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta new file mode 100644 index 0000000..0e296ee --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59d58c6b995b5494abd828406d93edee \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models.meta b/Assets/Infrastructure/Auth/Models.meta new file mode 100644 index 0000000..d6cfb07 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 54cf275a6da6ecd44b44c6ac73a399da +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs b/Assets/Infrastructure/Auth/Models/AccessToken.cs new file mode 100644 index 0000000..e3fbdfa --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs @@ -0,0 +1,149 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + /// + /// Access Token 모델 + /// + [Serializable] + public class AccessToken + { + /// + /// 토큰 문자열 + /// + public string Token { get; set; } + + /// + /// 만료 시간 (초) + /// + public int ExpiresIn { get; set; } + + /// + /// 만료 시간 (DateTime) + /// + public DateTime ExpiresAt { get; set; } + + /// + /// 토큰 타입 (예: Bearer) + /// + public string TokenType { get; set; } + + /// + /// 토큰 스코프 + /// + public string Scope { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public AccessToken() + { + CreatedAt = DateTime.UtcNow; + } + + public AccessToken(string token, int expiresIn, string tokenType = "Bearer", string scope = "") + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresIn = expiresIn; + TokenType = tokenType ?? "Bearer"; + Scope = scope ?? ""; + CreatedAt = DateTime.UtcNow; + ExpiresAt = CreatedAt.AddSeconds(expiresIn); + } + + public AccessToken(string token, DateTime expiresAt, string tokenType = "Bearer", string scope = "") + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresAt = expiresAt; + TokenType = tokenType ?? "Bearer"; + Scope = scope ?? ""; + CreatedAt = DateTime.UtcNow; + ExpiresIn = (int)(expiresAt - CreatedAt).TotalSeconds; + } + + /// + /// 토큰 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(Token) && !IsExpired(); + } + + /// + /// 토큰 만료 여부 확인 + /// + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + + /// + /// 토큰 만료까지 남은 시간 + /// + public TimeSpan TimeUntilExpiry() + { + var timeLeft = ExpiresAt - DateTime.UtcNow; + return timeLeft > TimeSpan.Zero ? timeLeft : TimeSpan.Zero; + } + + /// + /// 토큰 만료까지 남은 시간 (초) + /// + public int SecondsUntilExpiry() + { + return (int)TimeUntilExpiry().TotalSeconds; + } + + /// + /// 토큰이 곧 만료될 예정인지 확인 + /// + /// 버퍼 시간 (분) + /// 곧 만료될 예정인지 여부 + public bool IsExpiringSoon(int bufferMinutes = 5) + { + var timeLeft = TimeUntilExpiry(); + return timeLeft <= TimeSpan.FromMinutes(bufferMinutes); + } + + /// + /// Authorization 헤더 값 생성 + /// + /// Authorization 헤더 값 + public string GetAuthorizationHeader() + { + return $"{TokenType} {Token}"; + } + + /// + /// 토큰 정보 복사 + /// + /// 새로운 AccessToken 인스턴스 + public AccessToken Clone() + { + return new AccessToken(Token, ExpiresAt, TokenType, Scope); + } + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"AccessToken Debug Info:\n"; + info += $"Token: {Token?.Substring(0, Math.Min(20, Token.Length))}...\n"; + info += $"Expires In: {ExpiresIn}초\n"; + info += $"Expires At: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Token Type: {TokenType}\n"; + info += $"Scope: {Scope}\n"; + info += $"Is Valid: {IsValid()}\n"; + info += $"Is Expired: {IsExpired()}\n"; + info += $"Time Until Expiry: {TimeUntilExpiry()}\n"; + info += $"Is Expiring Soon: {IsExpiringSoon()}\n"; + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta b/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta new file mode 100644 index 0000000..ff5456f --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b8e8d468f9dc2346b7637200a36f19a \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/RefreshToken.cs b/Assets/Infrastructure/Auth/Models/RefreshToken.cs new file mode 100644 index 0000000..c153f66 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/RefreshToken.cs @@ -0,0 +1,132 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + /// + /// Refresh Token 모델 + /// + [Serializable] + public class RefreshToken + { + /// + /// 토큰 문자열 + /// + public string Token { get; set; } + + /// + /// 만료 시간 (초) + /// + public int ExpiresIn { get; set; } + + /// + /// 만료 시간 (DateTime) + /// + public DateTime ExpiresAt { get; set; } + + /// + /// 디바이스 ID + /// + public string DeviceId { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public RefreshToken() + { + CreatedAt = DateTime.UtcNow; + } + + public RefreshToken(string token, int expiresIn, string deviceId = null) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresIn = expiresIn; + DeviceId = deviceId ?? ""; + CreatedAt = DateTime.UtcNow; + ExpiresAt = CreatedAt.AddSeconds(expiresIn); + } + + public RefreshToken(string token, DateTime expiresAt, string deviceId = null) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresAt = expiresAt; + DeviceId = deviceId ?? ""; + CreatedAt = DateTime.UtcNow; + ExpiresIn = (int)(expiresAt - CreatedAt).TotalSeconds; + } + + /// + /// 토큰 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(Token) && !IsExpired(); + } + + /// + /// 토큰 만료 여부 확인 + /// + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + + /// + /// 토큰 만료까지 남은 시간 + /// + public TimeSpan TimeUntilExpiry() + { + var timeLeft = ExpiresAt - DateTime.UtcNow; + return timeLeft > TimeSpan.Zero ? timeLeft : TimeSpan.Zero; + } + + /// + /// 토큰 만료까지 남은 시간 (초) + /// + public int SecondsUntilExpiry() + { + return (int)TimeUntilExpiry().TotalSeconds; + } + + /// + /// 토큰이 곧 만료될 예정인지 확인 + /// + /// 버퍼 시간 (일) + /// 곧 만료될 예정인지 여부 + public bool IsExpiringSoon(int bufferDays = 7) + { + var timeLeft = TimeUntilExpiry(); + return timeLeft <= TimeSpan.FromDays(bufferDays); + } + + /// + /// 토큰 정보 복사 + /// + /// 새로운 RefreshToken 인스턴스 + public RefreshToken Clone() + { + return new RefreshToken(Token, ExpiresAt, DeviceId); + } + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"RefreshToken Debug Info:\n"; + info += $"Token: {Token?.Substring(0, Math.Min(20, Token.Length))}...\n"; + info += $"Expires In: {ExpiresIn}초\n"; + info += $"Expires At: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Device ID: {DeviceId}\n"; + info += $"Is Valid: {IsValid()}\n"; + info += $"Is Expired: {IsExpired()}\n"; + info += $"Time Until Expiry: {TimeUntilExpiry()}\n"; + info += $"Is Expiring Soon: {IsExpiringSoon()}\n"; + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta b/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta new file mode 100644 index 0000000..4167094 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d2eacc69bf7b76418550b805b0204bd \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/TokenSet.cs b/Assets/Infrastructure/Auth/Models/TokenSet.cs new file mode 100644 index 0000000..0d786fb --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/TokenSet.cs @@ -0,0 +1,149 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + /// + /// 토큰 세트 (Access Token + Refresh Token) + /// + [Serializable] + public class TokenSet + { + /// + /// Access Token + /// + public AccessToken AccessToken { get; set; } + + /// + /// Refresh Token + /// + public RefreshToken RefreshToken { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public TokenSet() + { + CreatedAt = DateTime.UtcNow; + } + + public TokenSet(AccessToken accessToken, RefreshToken refreshToken = null) + { + AccessToken = accessToken ?? throw new ArgumentNullException(nameof(accessToken)); + RefreshToken = refreshToken; + CreatedAt = DateTime.UtcNow; + } + + /// + /// 토큰 세트 유효성 검사 + /// + public bool IsValid() + { + return AccessToken != null && AccessToken.IsValid(); + } + + /// + /// Access Token이 만료되었는지 확인 + /// + public bool IsAccessTokenExpired() + { + return AccessToken?.IsExpired() ?? true; + } + + /// + /// Refresh Token이 있는지 확인 + /// + public bool HasRefreshToken() + { + return RefreshToken != null && RefreshToken.IsValid(); + } + + /// + /// Refresh Token이 만료되었는지 확인 + /// + public bool IsRefreshTokenExpired() + { + return RefreshToken?.IsExpired() ?? true; + } + + /// + /// 토큰 갱신이 필요한지 확인 + /// + /// 만료 전 버퍼 시간 (분) + /// 갱신 필요 여부 + public bool NeedsRefresh(int bufferMinutes = 5) + { + if (AccessToken == null) + return true; + + if (AccessToken.IsExpired()) + return true; + + // 만료 5분 전에 갱신 + var refreshTime = AccessToken.ExpiresAt.AddMinutes(-bufferMinutes); + return DateTime.UtcNow >= refreshTime; + } + + /// + /// 토큰 세트를 새로 업데이트 + /// + /// 새 Access Token + /// 새 Refresh Token (선택적) + public void UpdateTokens(AccessToken newAccessToken, RefreshToken newRefreshToken = null) + { + AccessToken = newAccessToken ?? throw new ArgumentNullException(nameof(newAccessToken)); + + // Refresh Token이 제공된 경우에만 업데이트 + if (newRefreshToken != null) + { + RefreshToken = newRefreshToken; + } + + CreatedAt = DateTime.UtcNow; + } + + /// + /// 토큰 세트 초기화 + /// + public void Clear() + { + AccessToken = null; + RefreshToken = null; + } + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"TokenSet Debug Info:\n"; + info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Is Valid: {IsValid()}\n"; + info += $"Has Refresh Token: {HasRefreshToken()}\n"; + info += $"Needs Refresh: {NeedsRefresh()}\n"; + + if (AccessToken != null) + { + info += $"Access Token:\n"; + info += $" Token: {AccessToken.Token?.Substring(0, Math.Min(20, AccessToken.Token.Length))}...\n"; + info += $" Expires At: {AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $" Is Expired: {AccessToken.IsExpired()}\n"; + info += $" Token Type: {AccessToken.TokenType}\n"; + info += $" Scope: {AccessToken.Scope}\n"; + } + + if (RefreshToken != null) + { + info += $"Refresh Token:\n"; + info += $" Token: {RefreshToken.Token?.Substring(0, Math.Min(20, RefreshToken.Token.Length))}...\n"; + info += $" Expires At: {RefreshToken.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $" Is Expired: {RefreshToken.IsExpired()}\n"; + info += $" Device ID: {RefreshToken.DeviceId}\n"; + } + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta b/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta new file mode 100644 index 0000000..efbb734 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc2ca688c549a0b4f81b25db48667d3e \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2.meta b/Assets/Infrastructure/Auth/OAuth2.meta new file mode 100644 index 0000000..4a2335d --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: baf49892df204df428512f4cbefd61d1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Config.meta b/Assets/Infrastructure/Auth/OAuth2/Config.meta new file mode 100644 index 0000000..7cb5211 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 895e898dacca990498955ec530983eff +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs new file mode 100644 index 0000000..ee7a673 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs @@ -0,0 +1,233 @@ +using System; +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Config +{ + /// + /// 서버 OAuth2 설정 + /// + [Serializable] + [CreateAssetMenu(fileName = "ServerOAuth2Config", menuName = "Auth/Server OAuth2 Config")] + public class ServerOAuth2Config : ScriptableObject + { + [Header("OAuth2 설정")] + [SerializeField] private string clientId = "test-client-id"; + + [SerializeField] private string scope = "openid profile email"; + + [Header("플랫폼별 리다이렉트 URI")] + [SerializeField] private string webGLRedirectUri = "http://localhost:3000/auth/callback"; + [SerializeField] private string androidRedirectUri = "com.yourgame://auth/callback"; + [SerializeField] private string iosRedirectUri = "com.yourgame://auth/callback"; + [SerializeField] private string windowsRedirectUri = "http://localhost:3000/auth/callback"; + [SerializeField] private string macosRedirectUri = "http://localhost:3000/auth/callback"; + + [Header("고급 설정")] + [SerializeField] private int pkceCodeVerifierLength = 64; // PKCE 표준에 맞게 64바이트 (43자 이상) + [SerializeField] private int stateLength = 16; // JavaScript와 동일하게 16바이트 + [SerializeField] private float timeoutSeconds = 300f; // 5분 + + // Singleton instance + private static ServerOAuth2Config _instance; + public static ServerOAuth2Config Instance + { + get + { + if (_instance == null) + { + _instance = Resources.Load("ServerOAuth2Config"); + if (_instance == null) + { + Debug.LogError("ServerOAuth2Config를 찾을 수 없습니다. Resources 폴더에 ServerOAuth2Config.asset 파일을 생성하세요."); + _instance = CreateDefaultInstance(); + } + } + return _instance; + } + } + + /// + /// 서버 URL (NetworkConfig에서 가져옴) + /// + public string ServerUrl => ProjectVG.Infrastructure.Network.Configs.NetworkConfig.HttpServerAddress; + + /// + /// 클라이언트 ID + /// + public string ClientId => clientId; + + /// + /// OAuth2 스코프 + /// + public string Scope => scope; + + /// + /// 현재 플랫폼의 리다이렉트 URI + /// + public string GetCurrentPlatformRedirectUri() + { +#if UNITY_WEBGL && !UNITY_EDITOR + return webGLRedirectUri; +#elif UNITY_ANDROID && !UNITY_EDITOR + return androidRedirectUri; +#elif UNITY_IOS && !UNITY_EDITOR + return iosRedirectUri; +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + return windowsRedirectUri; +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + return macosRedirectUri; +#else + // 에디터에서는 Windows 설정 사용 + return windowsRedirectUri; +#endif + } + + /// + /// PKCE Code Verifier 길이 + /// + public int PKCECodeVerifierLength => pkceCodeVerifierLength; + + /// + /// State 길이 + /// + public int StateLength => stateLength; + + /// + /// 타임아웃 (초) + /// + public float TimeoutSeconds => timeoutSeconds; + + /// + /// 설정 유효성 검사 + /// + public bool IsValid() + { + // NetworkConfig 유효성 검사 + var networkConfig = ProjectVG.Infrastructure.Network.Configs.NetworkConfig.Instance; + if (networkConfig == null) + { + Debug.LogError("[ServerOAuth2Config] NetworkConfig를 찾을 수 없습니다."); + return false; + } + + // 기본 필수 값 검사 + var hasRequiredFields = !string.IsNullOrEmpty(clientId) && + !string.IsNullOrEmpty(scope) && + !string.IsNullOrEmpty(GetCurrentPlatformRedirectUri()); + + // PKCE 설정 검사 + var hasValidPKCE = pkceCodeVerifierLength >= 43 && pkceCodeVerifierLength <= 128 && + stateLength >= 16 && stateLength <= 64; + + // 타임아웃 검사 + var hasValidTimeout = timeoutSeconds > 0; + + var isValid = hasRequiredFields && hasValidPKCE && hasValidTimeout; + + if (!isValid) + { + Debug.LogError($"[ServerOAuth2Config] 설정 유효성 검사 실패:"); + + if (string.IsNullOrEmpty(clientId)) + Debug.LogError($" - clientId: 비어있음 (실제 클라이언트 ID로 설정 필요)"); + else if (clientId.Contains("your-game-client")) + Debug.LogError($" - clientId: '{clientId}' (기본값입니다. 실제 클라이언트 ID로 변경하세요)"); + else + Debug.LogError($" - clientId: '{clientId}'"); + + if (string.IsNullOrEmpty(scope)) + Debug.LogError($" - scope: 비어있음"); + else + Debug.LogError($" - scope: '{scope}'"); + + if (string.IsNullOrEmpty(GetCurrentPlatformRedirectUri())) + Debug.LogError($" - redirectUri: 비어있음"); + else if (GetCurrentPlatformRedirectUri().Contains("your-domain")) + Debug.LogError($" - redirectUri: '{GetCurrentPlatformRedirectUri()}' (기본값입니다. 실제 도메인으로 변경하세요)"); + else + Debug.LogError($" - redirectUri: '{GetCurrentPlatformRedirectUri()}'"); + + if (!hasValidPKCE) + { + Debug.LogError($" - pkceCodeVerifierLength: {pkceCodeVerifierLength} (43-128 사이여야 함)"); + Debug.LogError($" - stateLength: {stateLength} (16-64 사이여야 함)"); + } + + if (!hasValidTimeout) + Debug.LogError($" - timeoutSeconds: {timeoutSeconds} (0보다 커야 함)"); + } + else + { + Debug.Log($"[ServerOAuth2Config] 설정 유효성 검사 통과"); + Debug.Log($" - 서버: {ServerUrl} (NetworkConfig에서 가져옴)"); + Debug.Log($" - 클라이언트 ID: {clientId}"); + Debug.Log($" - 플랫폼: {GetCurrentPlatformName()}"); + Debug.Log($" - 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); + } + + return isValid; + } + + /// + /// 현재 플랫폼 이름 + /// + public string GetCurrentPlatformName() + { +#if UNITY_WEBGL + return "WebGL"; +#elif UNITY_ANDROID + return "Android"; +#elif UNITY_IOS + return "iOS"; +#elif UNITY_STANDALONE_WIN + return "Windows"; +#elif UNITY_STANDALONE_OSX + return "macOS"; +#else + return "Unknown"; +#endif + } + + /// + /// 기본 인스턴스 생성 + /// + private static ServerOAuth2Config CreateDefaultInstance() + { + var instance = CreateInstance(); + + // JavaScript 클라이언트와 동일한 기본값으로 초기화 + instance.clientId = "test-client-id"; + instance.scope = "openid profile email"; + instance.webGLRedirectUri = "http://localhost:3000/auth/callback"; + instance.androidRedirectUri = "com.yourgame://auth/callback"; + instance.iosRedirectUri = "com.yourgame://auth/callback"; + instance.windowsRedirectUri = "http://localhost:3000/auth/callback"; + instance.macosRedirectUri = "http://localhost:3000/auth/callback"; + instance.pkceCodeVerifierLength = 64; // PKCE 표준에 맞게 64바이트 + instance.stateLength = 16; + instance.timeoutSeconds = 300f; + + Debug.LogWarning("기본 ServerOAuth2Config를 생성했습니다. Resources 폴더에 ServerOAuth2Config.asset 파일을 생성하는 것을 권장합니다."); + + return instance; + } + +#if UNITY_EDITOR + [Header("개발자 도구")] + [SerializeField] private bool showDebugInfo = false; + + private void OnValidate() + { + if (showDebugInfo) + { + Debug.Log($"[ServerOAuth2Config] 현재 플랫폼: {GetCurrentPlatformName()}"); + Debug.Log($"[ServerOAuth2Config] 서버 URL: {ServerUrl}"); + Debug.Log($"[ServerOAuth2Config] 클라이언트 ID: {ClientId}"); + Debug.Log($"[ServerOAuth2Config] 스코프: {Scope}"); + Debug.Log($"[ServerOAuth2Config] 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); + Debug.Log($"[ServerOAuth2Config] 설정 유효: {IsValid()}"); + } + } +#endif + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta new file mode 100644 index 0000000..bb94adb --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a7fa5cf92f687c4aa7ac92837c95546 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers.meta new file mode 100644 index 0000000..514cbca --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67efa4d19606a8c4b81b6b658f047223 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs new file mode 100644 index 0000000..466d8ee --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs @@ -0,0 +1,240 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using System.Threading; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// 데스크톱 OAuth2 콜백 핸들러 + /// 로컬 HTTP 서버를 통해 OAuth2 콜백을 처리 + /// + public class DesktopCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + private HttpListener _listener; + private CancellationTokenSource _cancellationTokenSource; + private string _callbackUrl; + + public string PlatformName => "Desktop"; + public bool IsSupported => true; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + _cancellationTokenSource = new CancellationTokenSource(); + + // 로컬 HTTP 서버 시작 + await StartLocalServerAsync(); + + Debug.Log($"[DesktopCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("데스크톱 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[DesktopCallbackHandler] OAuth2 콜백 대기 시작"); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + if (!string.IsNullOrEmpty(_callbackUrl)) + { + Debug.Log($"[DesktopCallbackHandler] OAuth2 콜백 수신: {_callbackUrl}"); + return _callbackUrl; + } + + // 100ms 대기 + await UniTask.Delay(100); + } + + Debug.LogWarning("[DesktopCallbackHandler] OAuth2 콜백 타임아웃"); + return null; + } + + public void Cleanup() + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _listener?.Stop(); + _listener?.Close(); + Debug.Log("[DesktopCallbackHandler] 정리 완료"); + } + + /// + /// 로컬 HTTP 서버 시작 + /// + private async Task StartLocalServerAsync() + { + try + { + _listener = new HttpListener(); + _listener.Prefixes.Add("http://localhost:3000/"); + _listener.Start(); + + Debug.Log("[DesktopCallbackHandler] 로컬 서버 시작: http://localhost:3000/"); + + // 서버 리스닝 시작 + _ = ListenForCallbackAsync(); + + await UniTask.CompletedTask; + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 로컬 서버 시작 실패: {ex.Message}"); + throw; + } + } + + /// + /// 콜백 리스닝 + /// + private async Task ListenForCallbackAsync() + { + try + { + while (!_isDisposed && _listener.IsListening) + { + var context = await _listener.GetContextAsync(); + _ = ProcessCallbackAsync(context); + } + } + catch (Exception ex) + { + if (!_isDisposed) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 리스닝 중 오류: {ex.Message}"); + } + } + } + + /// + /// 콜백 처리 + /// + private async Task ProcessCallbackAsync(HttpListenerContext context) + { + try + { + var request = context.Request; + var response = context.Response; + + Debug.Log($"[DesktopCallbackHandler] 요청 수신: {request.Url}"); + + // OAuth2 콜백 URL인지 확인 (auth/callback 또는 auth/google/callback) + if (request.Url.AbsolutePath.Contains("auth/callback") || request.Url.AbsolutePath.Contains("auth/google/callback")) + { + var state = request.QueryString["state"]; + var success = request.QueryString["success"]; + + Debug.Log($"[DesktopCallbackHandler] OAuth2 콜백 파라미터 - State: {state}, Success: {success}"); + + if (!string.IsNullOrEmpty(state) && state == _expectedState) + { + // 성공 응답 + var successHtml = @" + + + + + OAuth2 성공 + + + +
✅ OAuth2 로그인 성공!
+
Unity 앱으로 돌아가세요.
+ + + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(successHtml); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + + // 콜백 URL 저장 + _callbackUrl = request.Url.ToString(); + } + else + { + // 실패 응답 + var errorHtml = @" + + + + + OAuth2 실패 + + + +
❌ OAuth2 로그인 실패
+
Unity 앱으로 돌아가세요.
+ + + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(errorHtml); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + } + else + { + // 기본 응답 + var html = @" + + + + + OAuth2 콜백 서버 + + + +

OAuth2 콜백 서버

+

Unity OAuth2 콜백을 처리하는 서버입니다.

+ + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(html); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 처리 중 오류: {ex.Message}"); + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta new file mode 100644 index 0000000..78ec94e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2496e0a056d2a304ea56f51de2a637c5 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs new file mode 100644 index 0000000..3147dbf --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// OAuth2 콜백 핸들러 인터페이스 + /// 플랫폼별 OAuth2 콜백 처리 방법을 정의 + /// + public interface IOAuth2CallbackHandler + { + /// + /// 콜백 핸들러 초기화 + /// + /// 예상되는 State 값 + /// 타임아웃 (초) + Task InitializeAsync(string expectedState, float timeoutSeconds); + + /// + /// 콜백 대기 + /// + /// 콜백 URL (없으면 null) + Task WaitForCallbackAsync(); + + /// + /// 콜백 핸들러 정리 + /// + void Cleanup(); + + /// + /// 현재 플랫폼 이름 + /// + string PlatformName { get; } + + /// + /// 지원 여부 + /// + bool IsSupported { get; } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta new file mode 100644 index 0000000..297aafb --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 107a800c174df5946b80fb8ade2ca9a4 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs new file mode 100644 index 0000000..eefefa1 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs @@ -0,0 +1,134 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// 모바일 OAuth2 콜백 핸들러 + /// 커스텀 스킴을 통해 OAuth2 콜백을 처리 + /// + public class MobileCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + private string _lastCustomUrl; + + public string PlatformName => "Mobile"; + public bool IsSupported => true; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + + Debug.Log($"[MobileCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + await UniTask.CompletedTask; + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("모바일 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[MobileCallbackHandler] OAuth2 콜백 대기 시작"); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + // 커스텀 스킴 URL 확인 + var callbackUrl = CheckCustomSchemeUrl(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + Debug.Log($"[MobileCallbackHandler] OAuth2 콜백 수신: {callbackUrl}"); + return callbackUrl; + } + + // 100ms 대기 + await UniTask.Delay(100); + } + + Debug.LogWarning("[MobileCallbackHandler] OAuth2 콜백 타임아웃"); + return null; + } + + public void Cleanup() + { + _isDisposed = true; + Debug.Log("[MobileCallbackHandler] 정리 완료"); + } + + /// + /// 커스텀 스킴 URL 확인 + /// + private string CheckCustomSchemeUrl() + { + try + { + // Unity에서 커스텀 스킴 URL을 받는 방법 + // 실제로는 플랫폼별 네이티브 플러그인이 필요할 수 있음 + + // 임시로 시뮬레이션 (실제 구현에서는 네이티브 플러그인 사용) + if (Application.isFocused && !string.IsNullOrEmpty(_lastCustomUrl)) + { + var url = _lastCustomUrl; + _lastCustomUrl = null; // 한 번만 사용 + return url; + } + + return null; + } + catch (Exception ex) + { + Debug.LogError($"[MobileCallbackHandler] 커스텀 스킴 URL 확인 중 오류: {ex.Message}"); + return null; + } + } + + /// + /// 커스텀 스킴 URL 설정 (네이티브 플러그인에서 호출) + /// + public void SetCustomSchemeUrl(string url) + { + if (!string.IsNullOrEmpty(url) && url.Contains("auth/callback")) + { + _lastCustomUrl = url; + Debug.Log($"[MobileCallbackHandler] 커스텀 스킴 URL 설정: {url}"); + } + } + + /// + /// 앱 포커스 변경 시 호출 (Unity에서 자동 호출) + /// + public void OnApplicationFocus(bool hasFocus) + { + if (hasFocus) + { + Debug.Log("[MobileCallbackHandler] 앱 포커스 획득"); + // 여기서 커스텀 스킴 URL을 확인할 수 있음 + // 실제로는 네이티브 플러그인을 통해 URL을 받아야 함 + } + } + + /// + /// 앱 일시정지 시 호출 (Unity에서 자동 호출) + /// + public void OnApplicationPause(bool pauseStatus) + { + if (!pauseStatus) // 앱 재개 + { + Debug.Log("[MobileCallbackHandler] 앱 재개"); + // 여기서 커스텀 스킴 URL을 확인할 수 있음 + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta new file mode 100644 index 0000000..174baf9 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca02408ec03f4064aa227b1c43b8b99d \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs new file mode 100644 index 0000000..743a7fe --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs @@ -0,0 +1,70 @@ +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// OAuth2 콜백 핸들러 팩토리 + /// 플랫폼에 맞는 콜백 핸들러를 생성 + /// + public static class OAuth2CallbackHandlerFactory + { + /// + /// 현재 플랫폼에 맞는 콜백 핸들러 생성 + /// + /// 플랫폼별 콜백 핸들러 + public static IOAuth2CallbackHandler CreateHandler() + { +#if UNITY_WEBGL && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] WebGL 콜백 핸들러 생성"); + return new WebGLCallbackHandler(); +#elif UNITY_ANDROID && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] Android 콜백 핸들러 생성"); + return new MobileCallbackHandler(); +#elif UNITY_IOS && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] iOS 콜백 핸들러 생성"); + return new MobileCallbackHandler(); +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] Windows 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] macOS 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#else + // 에디터에서는 데스크톱 핸들러 사용 + Debug.Log("[OAuth2CallbackHandlerFactory] 에디터용 데스크톱 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#endif + } + + /// + /// 현재 플랫폼 이름 반환 + /// + /// 플랫폼 이름 + public static string GetCurrentPlatformName() + { +#if UNITY_WEBGL + return "WebGL"; +#elif UNITY_ANDROID + return "Android"; +#elif UNITY_IOS + return "iOS"; +#elif UNITY_STANDALONE_WIN + return "Windows"; +#elif UNITY_STANDALONE_OSX + return "macOS"; +#else + return "Editor"; +#endif + } + + /// + /// 현재 플랫폼이 지원되는지 확인 + /// + /// 지원 여부 + public static bool IsPlatformSupported() + { + var handler = CreateHandler(); + return handler.IsSupported; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta new file mode 100644 index 0000000..1163ee3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7a4d8435fbbf744aaa12df69d475c39 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs new file mode 100644 index 0000000..2c5b32a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// WebGL OAuth2 콜백 핸들러 + /// URL 파라미터를 통해 OAuth2 콜백을 처리 + /// + public class WebGLCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + + public string PlatformName => "WebGL"; + public bool IsSupported => true; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + + Debug.Log($"[WebGLCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + await UniTask.CompletedTask; + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("WebGL 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[WebGLCallbackHandler] OAuth2 콜백 대기 시작"); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + // WebGL에서 URL 파라미터 확인 + var callbackUrl = CheckUrlParameters(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + Debug.Log($"[WebGLCallbackHandler] OAuth2 콜백 수신: {callbackUrl}"); + return callbackUrl; + } + + // 100ms 대기 + await UniTask.Delay(100); + } + + Debug.LogWarning("[WebGLCallbackHandler] OAuth2 콜백 타임아웃"); + return null; + } + + public void Cleanup() + { + _isDisposed = true; + Debug.Log("[WebGLCallbackHandler] 정리 완료"); + } + + /// + /// URL 파라미터에서 OAuth2 콜백 확인 + /// + private string CheckUrlParameters() + { + try + { + // WebGL에서 현재 URL 가져오기 + var currentUrl = Application.absoluteURL; + + if (string.IsNullOrEmpty(currentUrl)) + return null; + + // URL에 OAuth2 콜백 파라미터가 있는지 확인 + if (currentUrl.Contains("success=") && currentUrl.Contains("state=")) + { + // URL에서 state 파라미터 추출 + var stateParam = ExtractParameter(currentUrl, "state"); + + if (!string.IsNullOrEmpty(stateParam) && stateParam == _expectedState) + { + return currentUrl; + } + } + + return null; + } + catch (Exception ex) + { + Debug.LogError($"[WebGLCallbackHandler] URL 파라미터 확인 중 오류: {ex.Message}"); + return null; + } + } + + /// + /// URL에서 파라미터 추출 + /// + private string ExtractParameter(string url, string parameterName) + { + try + { + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + return query[parameterName]; + } + catch (Exception ex) + { + Debug.LogError($"[WebGLCallbackHandler] 파라미터 추출 중 오류: {ex.Message}"); + return null; + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta new file mode 100644 index 0000000..f93b3fe --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c96d3d3c9d55bd84ab286fcc5b22190b \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs new file mode 100644 index 0000000..3501c49 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2 +{ + /// + /// 서버 OAuth2 클라이언트 인터페이스 + /// + public interface IServerOAuth2Client + { + /// + /// OAuth2 설정이 유효한지 확인 + /// + bool IsConfigured { get; } + + /// + /// PKCE 파라미터 생성 (Code Verifier, Code Challenge, State) + /// + Task GeneratePKCEAsync(); + + /// + /// 서버 OAuth2 인증 시작 + /// + /// PKCE 파라미터 + /// OAuth2 스코프 + /// Google OAuth2 URL + Task StartServerOAuth2Async(PKCEParameters pkce, string scope); + + /// + /// OAuth2 콜백 처리 (redirect URL에서 state 추출) + /// + /// 콜백 URL + /// 성공 여부와 state 값 + Task<(bool success, string state)> HandleOAuth2CallbackAsync(string callbackUrl); + + /// + /// 서버에서 토큰 요청 + /// + /// OAuth2 state 값 + /// JWT 토큰 세트 + Task RequestTokenAsync(string state); + + /// + /// 전체 OAuth2 로그인 플로우 (편의 메서드) + /// + /// OAuth2 스코프 + /// JWT 토큰 세트 + Task LoginWithServerOAuth2Async(string scope); + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta new file mode 100644 index 0000000..dbfcfd1 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c42caa18e3802e449824bef97cd5fedf \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Models.meta b/Assets/Infrastructure/Auth/OAuth2/Models.meta new file mode 100644 index 0000000..3cf1d8e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3bfa6910952dbda4ab175d8487454a5d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs new file mode 100644 index 0000000..ede848a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs @@ -0,0 +1,65 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Models +{ + /// + /// PKCE (Proof Key for Code Exchange) 파라미터 + /// + [Serializable] + public class PKCEParameters + { + /// + /// Code Verifier (43-128자 랜덤 문자열) + /// + public string CodeVerifier { get; set; } + + /// + /// Code Challenge (SHA256 해시된 Code Verifier) + /// + public string CodeChallenge { get; set; } + + /// + /// State 파라미터 (CSRF 방지) + /// + public string State { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public PKCEParameters() + { + CreatedAt = DateTime.UtcNow; + } + + public PKCEParameters(string codeVerifier, string codeChallenge, string state) + { + CodeVerifier = codeVerifier; + CodeChallenge = codeChallenge; + State = state; + CreatedAt = DateTime.UtcNow; + } + + /// + /// PKCE 파라미터 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(CodeVerifier) && + !string.IsNullOrEmpty(CodeChallenge) && + !string.IsNullOrEmpty(State) && + CodeVerifier.Length >= 43 && CodeVerifier.Length <= 128; + } + + /// + /// 만료 시간 검사 (기본 10분) + /// + public bool IsExpired(TimeSpan? maxAge = null) + { + var age = DateTime.UtcNow - CreatedAt; + var maxAgeValue = maxAge ?? TimeSpan.FromMinutes(10); + return age > maxAgeValue; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta new file mode 100644 index 0000000..0154a62 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 297b4b48ad8a852469f2dff27f6e0f14 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs new file mode 100644 index 0000000..2426e6f --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs @@ -0,0 +1,200 @@ +using System; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Models +{ + /// + /// 서버 OAuth2 인증 요청 모델 + /// + [Serializable] + public class ServerOAuth2AuthorizeRequest + { + [JsonProperty("client_id")] + public string ClientId { get; set; } + + [JsonProperty("redirect_uri")] + public string RedirectUri { get; set; } + + [JsonProperty("response_type")] + public string ResponseType { get; set; } = "code"; + + [JsonProperty("scope")] + public string Scope { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("code_challenge")] + public string CodeChallenge { get; set; } + + [JsonProperty("code_challenge_method")] + public string CodeChallengeMethod { get; set; } = "S256"; + + [JsonProperty("code_verifier")] + public string CodeVerifier { get; set; } + + [JsonProperty("client_redirect_uri")] + public string ClientRedirectUri { get; set; } + } + + /// + /// 서버 OAuth2 인증 응답 모델 + /// + [Serializable] + public class ServerOAuth2AuthorizeResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("auth_url")] + public string AuthUrl { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + } + + /// + /// 서버 OAuth2 토큰 응답 모델 + /// + [Serializable] + public class ServerOAuth2TokenResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + // HTTP 헤더에서 추출할 토큰 정보 + [JsonIgnore] + public string AccessToken { get; set; } + + [JsonIgnore] + public string RefreshToken { get; set; } + + [JsonIgnore] + public int ExpiresIn { get; set; } + + [JsonIgnore] + public string UserId { get; set; } + } + + /// + /// OAuth2 콜백 URL 파싱 결과 + /// + [Serializable] + public class OAuth2CallbackResult + { + /// + /// 성공 여부 + /// + public bool Success { get; set; } + + /// + /// State 파라미터 + /// + public string State { get; set; } + + /// + /// 에러 메시지 (실패 시) + /// + public string Error { get; set; } + + /// + /// 원본 URL + /// + public string OriginalUrl { get; set; } + + /// + /// 파싱된 쿼리 파라미터 + /// + public System.Collections.Generic.Dictionary QueryParameters { get; set; } + + public OAuth2CallbackResult() + { + QueryParameters = new System.Collections.Generic.Dictionary(); + } + + /// + /// 성공 결과 생성 + /// + public static OAuth2CallbackResult SuccessResult(string state, string originalUrl = null) + { + return new OAuth2CallbackResult + { + Success = true, + State = state, + OriginalUrl = originalUrl + }; + } + + /// + /// 실패 결과 생성 + /// + public static OAuth2CallbackResult ErrorResult(string error, string originalUrl = null) + { + return new OAuth2CallbackResult + { + Success = false, + Error = error, + OriginalUrl = originalUrl + }; + } + } + + /// + /// OAuth2 브라우저 열기 결과 + /// + [Serializable] + public class OAuth2BrowserResult + { + /// + /// 브라우저 열기 성공 여부 + /// + public bool Success { get; set; } + + /// + /// 열린 URL + /// + public string OpenedUrl { get; set; } + + /// + /// 에러 메시지 + /// + public string Error { get; set; } + + /// + /// 플랫폼 정보 + /// + public string Platform { get; set; } + + /// + /// 브라우저 타입 + /// + public string BrowserType { get; set; } + + public static OAuth2BrowserResult SuccessResult(string url, string platform, string browserType) + { + return new OAuth2BrowserResult + { + Success = true, + OpenedUrl = url, + Platform = platform, + BrowserType = browserType + }; + } + + public static OAuth2BrowserResult ErrorResult(string error, string platform) + { + return new OAuth2BrowserResult + { + Success = false, + Error = error, + Platform = platform + }; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta new file mode 100644 index 0000000..b834a6e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b7600ec9722b884c998af930c66ef22 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs new file mode 100644 index 0000000..c71181a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Utils; +using ProjectVG.Infrastructure.Auth.OAuth2.Handlers; +using ProjectVG.Infrastructure.Network.Http; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth.OAuth2 +{ + /// + /// 서버 OAuth2 제공자 + /// 새로운 서버 OAuth2 정책에 따른 구현 + /// + public class ServerOAuth2Provider : IServerOAuth2Client + { + private readonly ServerOAuth2Config _config; + private readonly HttpApiClient _httpClient; + private readonly PKCEParameters _currentPKCE; + private IOAuth2CallbackHandler _callbackHandler; + + public ServerOAuth2Provider(ServerOAuth2Config config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = HttpApiClient.Instance; + _currentPKCE = null; + + // HttpApiClient 초기화 상태 확인 + if (_httpClient == null) + { + Debug.LogError("[ServerOAuth2Provider] HttpApiClient.Instance가 null입니다."); + throw new InvalidOperationException("HttpApiClient가 초기화되지 않았습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] HttpApiClient 초기화 상태: {_httpClient.IsInitialized}"); + + // 플랫폼별 콜백 핸들러 생성 + _callbackHandler = OAuth2CallbackHandlerFactory.CreateHandler(); + Debug.Log($"[ServerOAuth2Provider] {_callbackHandler.PlatformName} 콜백 핸들러 생성됨"); + } + + #region IServerOAuth2Client Properties + + /// + /// OAuth2 설정이 유효한지 확인 + /// + public bool IsConfigured => _config != null && _config.IsValid(); + + #endregion + + #region IServerOAuth2Client Implementation + + /// + /// PKCE 파라미터 생성 (Code Verifier, Code Challenge, State) + /// + public async Task GeneratePKCEAsync() + { + try + { + Debug.Log("[ServerOAuth2Provider] PKCE 파라미터 생성 시작"); + + var pkce = await PKCEGenerator.GeneratePKCEAsync( + _config.PKCECodeVerifierLength, + _config.StateLength + ); + + if (!PKCEGenerator.ValidatePKCE(pkce)) + { + throw new InvalidOperationException("생성된 PKCE 파라미터가 유효하지 않습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] PKCE 생성 완료 - State: {pkce.State}"); + return pkce; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] PKCE 생성 실패: {ex.Message}"); + throw; + } + } + + /// + /// 서버 OAuth2 인증 시작 + /// + /// PKCE 파라미터 + /// OAuth2 스코프 + /// Google OAuth2 URL + public async Task StartServerOAuth2Async(PKCEParameters pkce, string scope) + { + if (pkce == null || !pkce.IsValid()) + { + throw new ArgumentException("유효하지 않은 PKCE 파라미터입니다.", nameof(pkce)); + } + + if (string.IsNullOrEmpty(scope)) + { + scope = _config.Scope; + } + + try + { + Debug.Log("[ServerOAuth2Provider] 서버 OAuth2 인증 시작"); + Debug.Log($"[ServerOAuth2Provider] HttpApiClient 초기화 상태: {_httpClient.IsInitialized}"); + + // 1. JavaScript와 동일한 방식으로 쿼리 파라미터 생성 + var queryParams = new Dictionary + { + { "client_id", _config.ClientId }, + { "redirect_uri", _config.GetCurrentPlatformRedirectUri() }, + { "response_type", "code" }, + { "scope", scope }, + { "state", pkce.State }, + { "code_challenge", pkce.CodeChallenge }, + { "code_challenge_method", "S256" }, + { "code_verifier", pkce.CodeVerifier }, + { "client_redirect_uri", GetClientRedirectUri() } + }; + + Debug.Log($"[ServerOAuth2Provider] 쿼리 파라미터: {JsonConvert.SerializeObject(queryParams)}"); + + // 2. 쿼리 파라미터를 URL에 추가 + var queryString = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + var authorizeUrl = $"{_config.ServerUrl}/auth/oauth2/authorize?{queryString}"; + + Debug.Log($"[ServerOAuth2Provider] 최종 URL: {authorizeUrl}"); + + // 3. 서버 API 호출 (GET 요청) + var response = await _httpClient.GetAsync(authorizeUrl); + + if (response == null) + { + throw new InvalidOperationException("서버 응답이 null입니다."); + } + + if (!response.Success) + { + throw new InvalidOperationException($"서버 OAuth2 인증 실패: {response.Message}"); + } + + if (string.IsNullOrEmpty(response.AuthUrl)) + { + throw new InvalidOperationException("서버에서 반환된 인증 URL이 비어있습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] Google OAuth2 URL 생성 완료: {response.AuthUrl}"); + return response.AuthUrl; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 서버 OAuth2 인증 시작 실패: {ex.Message}"); + throw; + } + } + + /// + /// OAuth2 콜백 처리 (redirect URL에서 state 추출) + /// + /// 콜백 URL + /// 성공 여부와 state 값 + public async Task<(bool success, string state)> HandleOAuth2CallbackAsync(string callbackUrl) + { + if (string.IsNullOrEmpty(callbackUrl)) + { + Debug.LogError("[ServerOAuth2Provider] 콜백 URL이 비어있습니다."); + return (false, null); + } + + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 콜백 처리: {callbackUrl}"); + + // URL 파싱 + OAuth2CallbackResult callbackResult; + + if (OAuth2CallbackParser.IsCustomScheme(callbackUrl)) + { + // 커스텀 스킴 URL (모바일 앱) + callbackResult = OAuth2CallbackParser.ParseSchemeUrl(callbackUrl); + } + else + { + // 일반 URL (WebGL, 데스크톱) + callbackResult = OAuth2CallbackParser.ParseCallbackUrl(callbackUrl); + } + + if (!callbackResult.Success) + { + Debug.LogError($"[ServerOAuth2Provider] 콜백 파싱 실패: {callbackResult.Error}"); + return (false, null); + } + + if (string.IsNullOrEmpty(callbackResult.State)) + { + Debug.LogError("[ServerOAuth2Provider] State 파라미터가 비어있습니다."); + return (false, null); + } + + Debug.Log($"[ServerOAuth2Provider] 콜백 처리 성공 - State: {callbackResult.State}"); + return (true, callbackResult.State); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 콜백 처리 실패: {ex.Message}"); + return (false, null); + } + } + + /// + /// 서버에서 토큰 요청 + /// + /// OAuth2 state 값 + /// JWT 토큰 세트 + public async Task RequestTokenAsync(string state) + { + if (string.IsNullOrEmpty(state)) + { + throw new ArgumentException("State 파라미터가 비어있습니다.", nameof(state)); + } + + try + { + Debug.Log($"[ServerOAuth2Provider] 토큰 요청 시작 - State: {state}"); + + // 1. 서버 토큰 API 호출 (HTTP 헤더 포함) + var tokenUrl = $"{_config.ServerUrl}/auth/oauth2/token?state={Uri.EscapeDataString(state)}"; + var (response, headers) = await _httpClient.GetWithHeadersAsync(tokenUrl); + + if (response == null) + { + throw new InvalidOperationException("토큰 응답이 null입니다."); + } + + if (!response.Success) + { + throw new InvalidOperationException($"토큰 요청 실패: {response.Message}"); + } + + // 2. HTTP 헤더에서 토큰 정보 추출 (JavaScript와 동일한 방식) + var accessToken = headers.ContainsKey("X-Access-Token") ? headers["X-Access-Token"] : null; + var refreshToken = headers.ContainsKey("X-Refresh-Token") ? headers["X-Refresh-Token"] : null; + var expiresInStr = headers.ContainsKey("X-Expires-In") ? headers["X-Expires-In"] : null; + var userId = headers.ContainsKey("X-User-Id") ? headers["X-User-Id"] : null; + + // 헤더에서 값을 가져올 수 없는 경우 응답 본문에서 가져오기 (폴백) + if (string.IsNullOrEmpty(accessToken)) + accessToken = response.AccessToken; + if (string.IsNullOrEmpty(refreshToken)) + refreshToken = response.RefreshToken; + if (string.IsNullOrEmpty(userId)) + userId = response.UserId; + + var expiresIn = 3600; // 기본값 1시간 + if (!string.IsNullOrEmpty(expiresInStr) && int.TryParse(expiresInStr, out var parsedExpiresIn)) + expiresIn = parsedExpiresIn; + else if (response.ExpiresIn > 0) + expiresIn = response.ExpiresIn; + + if (string.IsNullOrEmpty(accessToken)) + { + throw new InvalidOperationException("Access Token이 비어있습니다."); + } + + // 3. TokenSet 생성 + var accessTokenModel = new AccessToken(accessToken, expiresIn, "Bearer", "oauth2"); + var refreshTokenModel = !string.IsNullOrEmpty(refreshToken) + ? new RefreshToken(refreshToken, expiresIn * 2, userId) + : null; + + var tokenSet = new TokenSet(accessTokenModel, refreshTokenModel); + + Debug.Log($"[ServerOAuth2Provider] 토큰 요청 성공 - UserId: {userId}"); + + // 토큰 수신 확인 로그 + Debug.Log("=== 🔐 서버에서 토큰 수신 완료 ==="); + Debug.Log($"Access Token: {accessToken?.Substring(0, Math.Min(20, accessToken?.Length ?? 0))}..."); + Debug.Log($"Refresh Token: {(string.IsNullOrEmpty(refreshToken) ? "없음" : refreshToken.Substring(0, Math.Min(20, refreshToken.Length)) + "...")}"); + Debug.Log($"Expires In: {expiresIn}초"); + Debug.Log($"User ID: {userId}"); + Debug.Log("=== 토큰 수신 완료 ==="); + + return tokenSet; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 토큰 요청 실패: {ex.Message}"); + throw; + } + } + + /// + /// 전체 OAuth2 로그인 플로우 (편의 메서드) + /// + /// OAuth2 스코프 + /// JWT 토큰 세트 + public async Task LoginWithServerOAuth2Async(string scope) + { + try + { + Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 시작"); + + // 1. PKCE 파라미터 생성 + var pkce = await GeneratePKCEAsync(); + + // 2. 서버 OAuth2 인증 시작 + var authUrl = await StartServerOAuth2Async(pkce, scope); + + // 3. 브라우저에서 OAuth2 로그인 진행 + var browserResult = await OpenOAuth2BrowserAsync(authUrl); + if (!browserResult.Success) + { + throw new InvalidOperationException($"브라우저 열기 실패: {browserResult.Error}"); + } + + // 4. 콜백 대기 및 처리 + var callbackResult = await WaitForOAuth2CallbackAsync(pkce.State); + if (!callbackResult.success) + { + throw new InvalidOperationException("OAuth2 콜백 처리 실패"); + } + + // 5. 토큰 요청 + var tokenSet = await RequestTokenAsync(callbackResult.state); + + Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 완료"); + return tokenSet; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 실패: {ex.Message}"); + throw; + } + } + + #endregion + + #region Private Methods + + /// + /// 클라이언트 리다이렉트 URI 생성 + /// + /// 클라이언트 리다이렉트 URI + private string GetClientRedirectUri() + { +#if UNITY_WEBGL && !UNITY_EDITOR + // WebGL에서는 현재 페이지 URL 사용 + return Application.absoluteURL; +#elif UNITY_ANDROID && !UNITY_EDITOR + // Android에서는 커스텀 스킴 사용 + return _config.GetCurrentPlatformRedirectUri(); +#elif UNITY_IOS && !UNITY_EDITOR + // iOS에서는 커스텀 스킴 사용 + return _config.GetCurrentPlatformRedirectUri(); +#else + // 데스크톱에서는 로컬 서버 URL 사용 + return _config.GetCurrentPlatformRedirectUri(); +#endif + } + + /// + /// OAuth2 브라우저 열기 + /// + /// 인증 URL + /// 브라우저 열기 결과 + private async Task OpenOAuth2BrowserAsync(string authUrl) + { + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 브라우저 열기: {authUrl}"); + + var platform = _config.GetCurrentPlatformName(); + string browserType; + +#if UNITY_WEBGL && !UNITY_EDITOR + // WebGL에서는 현재 탭에서 리다이렉트 + Application.ExternalEval($"window.location.href = '{authUrl}';"); + browserType = "Current Tab"; +#elif UNITY_ANDROID && !UNITY_EDITOR + // Android에서는 기본 브라우저 사용 + Application.OpenURL(authUrl); + browserType = "Default Browser"; +#elif UNITY_IOS && !UNITY_EDITOR + // iOS에서는 기본 브라우저 사용 + Application.OpenURL(authUrl); + browserType = "Default Browser"; +#else + // 데스크톱에서는 기본 브라우저 사용 + Application.OpenURL(authUrl); + browserType = "Default Browser"; +#endif + + // 브라우저 열기 대기 + await UniTask.Delay(1000); + + return OAuth2BrowserResult.SuccessResult(authUrl, platform, browserType); + } + catch (Exception ex) + { + var platform = _config.GetCurrentPlatformName(); + return OAuth2BrowserResult.ErrorResult(ex.Message, platform); + } + } + + /// + /// OAuth2 콜백 대기 + /// + /// 예상되는 State 값 + /// 콜백 처리 결과 + private async Task<(bool success, string state)> WaitForOAuth2CallbackAsync(string expectedState) + { + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 콜백 대기 시작 - State: {expectedState}"); + + // 콜백 핸들러 초기화 + await _callbackHandler.InitializeAsync(expectedState, _config.TimeoutSeconds); + + // 콜백 대기 + var callbackUrl = await _callbackHandler.WaitForCallbackAsync(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + var result = await HandleOAuth2CallbackAsync(callbackUrl); + if (result.success && result.state == expectedState) + { + Debug.Log("[ServerOAuth2Provider] OAuth2 콜백 수신 완료"); + return result; + } + } + + Debug.LogError("[ServerOAuth2Provider] OAuth2 콜백 타임아웃 또는 실패"); + return (false, null); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] OAuth2 콜백 대기 중 오류: {ex.Message}"); + return (false, null); + } + finally + { + // 콜백 핸들러 정리 + _callbackHandler?.Cleanup(); + } + } + + #endregion + + #region Public Utility Methods + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"ServerOAuth2Provider Debug Info:\n"; + info += $"Is Configured: {IsConfigured}\n"; + info += $"Server URL: {_config?.ServerUrl}\n"; + info += $"Client ID: {_config?.ClientId}\n"; + info += $"Scope: {_config?.Scope}\n"; + info += $"Platform: {_config?.GetCurrentPlatformName()}\n"; + info += $"Redirect URI: {_config?.GetCurrentPlatformRedirectUri()}\n"; + info += $"Client Redirect URI: {GetClientRedirectUri()}\n"; + + return info; + } + + #endregion + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta new file mode 100644 index 0000000..9b6383a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9031eac5ad4a73c4e85b2f68ac1f0d10 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils.meta b/Assets/Infrastructure/Auth/OAuth2/Utils.meta new file mode 100644 index 0000000..643258e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f34035e6795c99428c22479b367fe5e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs new file mode 100644 index 0000000..34b58b9 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Utils +{ + /// + /// OAuth2 콜백 URL 파싱 유틸리티 + /// + public static class OAuth2CallbackParser + { + /// + /// OAuth2 콜백 URL 파싱 + /// + /// 콜백 URL + /// 파싱 결과 + public static OAuth2CallbackResult ParseCallbackUrl(string callbackUrl) + { + if (string.IsNullOrEmpty(callbackUrl)) + { + return OAuth2CallbackResult.ErrorResult("콜백 URL이 비어있습니다."); + } + + try + { + // URL 파싱 + var uri = new Uri(callbackUrl); + var query = HttpUtility.ParseQueryString(uri.Query); + + // 쿼리 파라미터를 Dictionary로 변환 + var queryParams = new Dictionary(); + foreach (string key in query.AllKeys) + { + if (!string.IsNullOrEmpty(key)) + { + queryParams[key] = query[key]; + } + } + + // success 파라미터 확인 + if (!queryParams.ContainsKey("success")) + { + return OAuth2CallbackResult.ErrorResult("success 파라미터가 없습니다.", callbackUrl); + } + + var success = queryParams["success"].ToLower(); + + if (success == "true") + { + // 성공 케이스: state 파라미터 확인 + if (!queryParams.ContainsKey("state")) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 없습니다.", callbackUrl); + } + + var state = queryParams["state"]; + if (string.IsNullOrEmpty(state)) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 비어있습니다.", callbackUrl); + } + + var result = OAuth2CallbackResult.SuccessResult(state, callbackUrl); + result.QueryParameters = queryParams; + return result; + } + else + { + // 실패 케이스: error 파라미터 확인 + var error = queryParams.ContainsKey("error") ? queryParams["error"] : "알 수 없는 오류"; + + var result = OAuth2CallbackResult.ErrorResult(error, callbackUrl); + result.QueryParameters = queryParams; + return result; + } + } + catch (UriFormatException ex) + { + return OAuth2CallbackResult.ErrorResult($"잘못된 URL 형식: {ex.Message}", callbackUrl); + } + catch (Exception ex) + { + return OAuth2CallbackResult.ErrorResult($"콜백 URL 파싱 실패: {ex.Message}", callbackUrl); + } + } + + /// + /// 커스텀 스킴 URL 파싱 (모바일 앱) + /// + /// 스킴 URL (예: myapp://oauth/callback?success=true&state=...) + /// 파싱 결과 + public static OAuth2CallbackResult ParseSchemeUrl(string schemeUrl) + { + if (string.IsNullOrEmpty(schemeUrl)) + { + return OAuth2CallbackResult.ErrorResult("스킴 URL이 비어있습니다."); + } + + try + { + // 스킴 URL에서 쿼리 부분 추출 + var queryStartIndex = schemeUrl.IndexOf('?'); + if (queryStartIndex == -1) + { + return OAuth2CallbackResult.ErrorResult("쿼리 파라미터가 없습니다.", schemeUrl); + } + + var queryString = schemeUrl.Substring(queryStartIndex + 1); + var query = HttpUtility.ParseQueryString(queryString); + + // 쿼리 파라미터를 Dictionary로 변환 + var queryParams = new Dictionary(); + foreach (string key in query.AllKeys) + { + if (!string.IsNullOrEmpty(key)) + { + queryParams[key] = query[key]; + } + } + + // success 파라미터 확인 + if (!queryParams.ContainsKey("success")) + { + return OAuth2CallbackResult.ErrorResult("success 파라미터가 없습니다.", schemeUrl); + } + + var success = queryParams["success"].ToLower(); + + if (success == "true") + { + // 성공 케이스: state 파라미터 확인 + if (!queryParams.ContainsKey("state")) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 없습니다.", schemeUrl); + } + + var state = queryParams["state"]; + if (string.IsNullOrEmpty(state)) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 비어있습니다.", schemeUrl); + } + + var result = OAuth2CallbackResult.SuccessResult(state, schemeUrl); + result.QueryParameters = queryParams; + return result; + } + else + { + // 실패 케이스: error 파라미터 확인 + var error = queryParams.ContainsKey("error") ? queryParams["error"] : "알 수 없는 오류"; + + var result = OAuth2CallbackResult.ErrorResult(error, schemeUrl); + result.QueryParameters = queryParams; + return result; + } + } + catch (Exception ex) + { + return OAuth2CallbackResult.ErrorResult($"스킴 URL 파싱 실패: {ex.Message}", schemeUrl); + } + } + + /// + /// URL에서 특정 파라미터 추출 + /// + /// URL + /// 파라미터 이름 + /// 파라미터 값 + public static string ExtractParameter(string url, string parameterName) + { + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(parameterName)) + { + return null; + } + + try + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + return query[parameterName]; + } + catch + { + return null; + } + } + + /// + /// URL이 OAuth2 콜백인지 확인 + /// + /// 확인할 URL + /// OAuth2 콜백 여부 + public static bool IsOAuth2Callback(string url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + try + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + // success 파라미터가 있으면 OAuth2 콜백으로 간주 + return query.AllKeys.Contains("success"); + } + catch + { + return false; + } + } + + /// + /// URL이 커스텀 스킴인지 확인 + /// + /// 확인할 URL + /// 예상 스킴 + /// 커스텀 스킴 여부 + public static bool IsCustomScheme(string url, string expectedScheme = null) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + try + { + var uri = new Uri(url); + + // 스킴이 http, https가 아니면 커스텀 스킴으로 간주 + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // 특정 스킴을 기대하는 경우 + if (!string.IsNullOrEmpty(expectedScheme)) + { + return uri.Scheme.Equals(expectedScheme, StringComparison.OrdinalIgnoreCase); + } + + return true; + } + catch + { + return false; + } + } + + /// + /// 디버그 정보 생성 + /// + /// 콜백 결과 + /// 디버그 정보 + public static string GetDebugInfo(OAuth2CallbackResult callbackResult) + { + if (callbackResult == null) + { + return "콜백 결과가 null입니다."; + } + + var info = $"OAuth2 Callback Debug Info:\n"; + info += $"Success: {callbackResult.Success}\n"; + info += $"State: {callbackResult.State}\n"; + info += $"Error: {callbackResult.Error}\n"; + info += $"Original URL: {callbackResult.OriginalUrl}\n"; + info += $"Query Parameters Count: {callbackResult.QueryParameters?.Count ?? 0}\n"; + + if (callbackResult.QueryParameters != null && callbackResult.QueryParameters.Count > 0) + { + info += "Query Parameters:\n"; + foreach (var kvp in callbackResult.QueryParameters) + { + info += $" {kvp.Key}: {kvp.Value}\n"; + } + } + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta new file mode 100644 index 0000000..3866ba3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f711e0bc0a0086743b170059d5e421c7 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs new file mode 100644 index 0000000..910170a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs @@ -0,0 +1,185 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using UnityEngine; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Utils +{ + /// + /// PKCE (Proof Key for Code Exchange) 생성 유틸리티 + /// 서버 권장사항에 따른 구현 + /// + public static class PKCEGenerator + { + /// + /// PKCE 파라미터 생성 (서버 권장사항 준수) + /// + /// Code Verifier 길이 (43-128) + /// State 길이 (16-64) + /// PKCE 파라미터 + public static async Task GeneratePKCEAsync(int codeVerifierLength = 64, int stateLength = 16) + { + try + { + // 1. Code Verifier 생성 (43-128자 랜덤 문자열) + var codeVerifier = GenerateRandomString(codeVerifierLength); + + // 2. Code Challenge 생성 (SHA256 해시) + var codeChallenge = await GenerateCodeChallengeAsync(codeVerifier); + + // 3. State 생성 + var state = GenerateRandomString(stateLength); + + return new PKCEParameters(codeVerifier, codeChallenge, state); + } + catch (Exception ex) + { + throw new InvalidOperationException($"PKCE 생성 실패: {ex.Message}", ex); + } + } + + /// + /// 랜덤 문자열 생성 (Base64Url 인코딩) + /// + /// 생성할 길이 + /// Base64Url 인코딩된 랜덤 문자열 + private static string GenerateRandomString(int length) + { + if (length < 1) + throw new ArgumentException("길이는 1 이상이어야 합니다.", nameof(length)); + + // 랜덤 바이트 생성 + var randomBytes = new byte[length]; + using (var rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(randomBytes); + } + + // Base64 인코딩 후 URL 안전하게 변환 + var base64String = Convert.ToBase64String(randomBytes); + return base64String + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", "") + .Substring(0, Math.Min(length, base64String.Length)); + } + + /// + /// Code Challenge 생성 (SHA256 해시) + /// + /// Code Verifier + /// Base64Url 인코딩된 Code Challenge + private static async Task GenerateCodeChallengeAsync(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentException("Code Verifier가 비어있습니다.", nameof(codeVerifier)); + + try + { + // SHA256 해시 계산 + using (var sha256 = SHA256.Create()) + { + var codeVerifierBytes = Encoding.UTF8.GetBytes(codeVerifier); + var hashBytes = await Task.Run(() => sha256.ComputeHash(codeVerifierBytes)); + + // Base64 인코딩 후 URL 안전하게 변환 + var base64String = Convert.ToBase64String(hashBytes); + return base64String + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Code Challenge 생성 실패: {ex.Message}", ex); + } + } + + /// + /// PKCE 파라미터 유효성 검사 + /// + /// 검사할 PKCE 파라미터 + /// 유효성 여부 + public static bool ValidatePKCE(PKCEParameters pkce) + { + if (pkce == null) + return false; + + // 기본 유효성 검사 + if (!pkce.IsValid()) + return false; + + // 만료 검사 + if (pkce.IsExpired()) + return false; + + // Code Verifier 길이 검사 (43-128자) + if (pkce.CodeVerifier.Length < 43 || pkce.CodeVerifier.Length > 128) + { + Debug.LogError($"[PKCEGenerator] Code Verifier 길이 검사 실패: {pkce.CodeVerifier.Length} (43-128 사이여야 함)"); + return false; + } + + // Base64Url 형식 검사 + if (!IsBase64UrlSafe(pkce.CodeVerifier) || !IsBase64UrlSafe(pkce.CodeChallenge) || !IsBase64UrlSafe(pkce.State)) + { + Debug.LogError("[PKCEGenerator] Base64Url 형식 검사 실패"); + return false; + } + + return true; + } + + /// + /// Base64Url 안전 문자열 검사 + /// + /// 검사할 문자열 + /// Base64Url 안전 여부 + private static bool IsBase64UrlSafe(string input) + { + if (string.IsNullOrEmpty(input)) + return false; + + // Base64Url 안전 문자만 포함하는지 검사 + foreach (char c in input) + { + if (!((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_')) + { + return false; + } + } + + return true; + } + + /// + /// PKCE 파라미터 디버그 정보 출력 + /// + /// PKCE 파라미터 + /// 디버그 정보 문자열 + public static string GetDebugInfo(PKCEParameters pkce) + { + if (pkce == null) + return "PKCE 파라미터가 null입니다."; + + var info = $"PKCE Debug Info:\n"; + info += $"Code Verifier: {pkce.CodeVerifier}\n"; + info += $"Code Verifier Length: {pkce.CodeVerifier?.Length ?? 0}\n"; + info += $"Code Challenge: {pkce.CodeChallenge}\n"; + info += $"Code Challenge Length: {pkce.CodeChallenge?.Length ?? 0}\n"; + info += $"State: {pkce.State}\n"; + info += $"State Length: {pkce.State?.Length ?? 0}\n"; + info += $"Created At: {pkce.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Is Valid: {pkce.IsValid()}\n"; + info += $"Is Expired: {pkce.IsExpired()}\n"; + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta new file mode 100644 index 0000000..54351c3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e0fe0663eefd6e488b09677b6a724f1 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index d9028e9..2cd3294 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -73,10 +73,28 @@ public void SetAuthToken(string token) public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { + // 자동 초기화 + if (!IsInitialized) + { + Debug.LogWarning("[HttpApiClient] 자동 초기화 수행"); + Initialize(null); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + Debug.Log($"[HttpApiClient] GET 요청 시작: {url}"); + Debug.Log($"[HttpApiClient] 헤더: {JsonConvert.SerializeObject(headers)}"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } + /// + /// GET 요청 (HTTP 헤더 포함 응답) + /// + public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + { + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); + } + public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) { var url = GetFullUrl(endpoint); @@ -205,6 +223,14 @@ private string SerializeData(object data, bool requiresSession = false) private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { + Debug.Log($"[HttpApiClient] SendJsonRequestAsync 시작"); + Debug.Log($"[HttpApiClient] URL: {url}"); + Debug.Log($"[HttpApiClient] Method: {method}"); + Debug.Log($"[HttpApiClient] JSON Data: {jsonData}"); + Debug.Log($"[HttpApiClient] Headers: {JsonConvert.SerializeObject(headers)}"); + Debug.Log($"[HttpApiClient] IsInitialized: {IsInitialized}"); + Debug.Log($"[HttpApiClient] cancellationTokenSource null: {cancellationTokenSource == null}"); + var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) @@ -238,6 +264,43 @@ private async UniTask SendJsonRequestAsync(string url, string method, stri throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } + private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + { + var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); + + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + { + try + { + using var request = CreateJsonRequest(url, method, jsonData, headers); + + var operation = request.SendWebRequest(); + await operation.WithCancellation(combinedCancellationToken); + + if (request.result == UnityWebRequest.Result.Success) + { + var data = ParseResponse(request); + var responseHeaders = ExtractResponseHeaders(request); + return (data, responseHeaders); + } + else + { + await HandleRequestFailure(request, attempt, combinedCancellationToken); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is not ApiException) + { + await HandleRequestException(ex, attempt, combinedCancellationToken); + } + } + + throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); + } + @@ -304,7 +367,21 @@ private async UniTask SendFormDataRequestAsync(string url, Dictionary(string responseText, long responseCode, Exception } } + /// + /// HTTP 응답 헤더 추출 + /// + private Dictionary ExtractResponseHeaders(UnityWebRequest request) + { + var headers = new Dictionary(); + + // UnityWebRequest에서 사용 가능한 헤더들 + var responseHeaders = request.GetResponseHeader("X-Access-Token"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Access-Token"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-Refresh-Token"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Refresh-Token"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-Expires-In"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Expires-In"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-User-Id"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-User-Id"] = responseHeaders; + + // 기타 헤더들도 추가 가능 + responseHeaders = request.GetResponseHeader("Content-Type"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["Content-Type"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("Authorization"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["Authorization"] = responseHeaders; + + Debug.Log($"[HttpApiClient] 응답 헤더 추출: {headers.Count}개 헤더"); + foreach (var header in headers) + { + Debug.Log($"[HttpApiClient] 헤더: {header.Key} = {header.Value}"); + } + + return headers; + } + private bool ShouldRetry(long responseCode) { return responseCode >= 500 || responseCode == 429; diff --git a/Assets/Resources/ServerOAuth2Config.asset b/Assets/Resources/ServerOAuth2Config.asset new file mode 100644 index 0000000..9fb104b --- /dev/null +++ b/Assets/Resources/ServerOAuth2Config.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5a7fa5cf92f687c4aa7ac92837c95546, type: 3} + m_Name: ServerOAuth2Config + m_EditorClassIdentifier: + clientId: test-client-id + scope: openid profile email + webGLRedirectUri: http://localhost:3000/auth/callback + androidRedirectUri: com.yourgame://auth/callback + iosRedirectUri: com.yourgame://auth/callback + windowsRedirectUri: http://localhost:3000/auth/callback + macosRedirectUri: http://localhost:3000/auth/callback + pkceCodeVerifierLength: 64 + stateLength: 16 + timeoutSeconds: 300 + showDebugInfo: 0 diff --git a/Assets/Resources/ServerOAuth2Config.asset.meta b/Assets/Resources/ServerOAuth2Config.asset.meta new file mode 100644 index 0000000..e42006e --- /dev/null +++ b/Assets/Resources/ServerOAuth2Config.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e3e96e5220c979744a485156e5e314cd +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: From 015b2be046971e768081cb7eafb6f1cd0962a68a Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 14:46:10 +0900 Subject: [PATCH 02/14] =?UTF-8?q?crone:=20=EB=B9=8C=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/App/Scenes/MainScene.unity | 292 +++++++++++++++++- .../DebugConsole/GameDebugConsoleManager.cs | 58 +++- .../Model/Chikuwa/Character-Zero.asset | 8 +- .../Model/Model.fadeMotionList.asset | 17 - .../Model/Model.fadeMotionList.asset.meta | 8 - .../Character/Model/Sample/natoriConfig.asset | 2 +- .../Character/Script/CharacterManager.cs | 7 - .../Component/CameraResolutionScaler.cs | 10 +- .../Script/Component/ResolutionManager.cs | 10 +- .../Live2DModelRegistry.asset | 0 .../Live2DModelRegistry.asset.meta | 0 ProjectSettings/EditorBuildSettings.asset | 3 - ProjectSettings/GraphicsSettings.asset | 2 +- ProjectSettings/ProjectSettings.asset | 76 +---- 14 files changed, 364 insertions(+), 129 deletions(-) delete mode 100644 Assets/Domain/Character/Model/Model.fadeMotionList.asset delete mode 100644 Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta rename Assets/{Resources => Domain}/Live2DModelRegistry.asset (100%) rename Assets/{Resources => Domain}/Live2DModelRegistry.asset.meta (100%) diff --git a/Assets/App/Scenes/MainScene.unity b/Assets/App/Scenes/MainScene.unity index c64d012..163e514 100644 --- a/Assets/App/Scenes/MainScene.unity +++ b/Assets/App/Scenes/MainScene.unity @@ -632,6 +632,127 @@ Transform: - {fileID: 920517455} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &642672887 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 642672888} + - component: {fileID: 642672891} + - component: {fileID: 642672890} + - component: {fileID: 642672889} + m_Layer: 5 + m_Name: Button + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &642672888 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 642672887} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1485874732} + m_Father: {fileID: 1145326587} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: -104.28076} + m_SizeDelta: {x: 0, y: 208.5618} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &642672889 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 642672887} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 642672890} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &642672890 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 642672887} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!222 &642672891 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 642672887} + m_CullTransparentMesh: 1 --- !u!1 &709008643 GameObject: m_ObjectHideFlags: 0 @@ -965,12 +1086,12 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1087467994} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 1104447313} + m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1087467996 MonoBehaviour: @@ -989,7 +1110,7 @@ MonoBehaviour: _logContentParent: {fileID: 2076648994} _logEntryPrefab: {fileID: 6280972447933324029, guid: d74792089ff38e04a9a7c41def8766bb, type: 3} _clearButton: {fileID: 0} - _toggleButton: {fileID: 0} + _toggleButton: {fileID: 642672889} _filterInput: {fileID: 0} _settings: {fileID: 11400000, guid: 8e5ce280818bafb45bd699331eb688e4, type: 2} --- !u!1 &1104447311 @@ -1021,8 +1142,7 @@ Transform: m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1087467995} + m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1104447314 @@ -1145,6 +1265,7 @@ RectTransform: - {fileID: 1274474669} - {fileID: 981746805} - {fileID: 396605757} + - {fileID: 642672888} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1242,7 +1363,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.y - value: 0 + value: -1260.7114 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_LocalPosition.x @@ -1278,7 +1399,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchoredPosition.y - value: 0 + value: 630.3557 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_LocalEulerAnglesHint.x @@ -1298,15 +1419,15 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.x - value: 1 + value: 0 objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.y - value: 1 + value: 0 objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.x - value: -17 + value: 0 objectReference: {fileID: 0} - target: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_Name @@ -1314,15 +1435,15 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_IsActive - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 8283427695325121285, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.x - value: 1 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8283427695325121285, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.y - value: 1 + value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] m_RemovedGameObjects: [] @@ -1340,6 +1461,142 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &1485874731 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1485874732} + - component: {fileID: 1485874734} + - component: {fileID: 1485874733} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1485874732 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1485874731} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 642672888} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1485874733 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1485874731} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Button + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 24 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1485874734 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1485874731} + m_CullTransparentMesh: 1 --- !u!1001 &1636555666 PrefabInstance: m_ObjectHideFlags: 0 @@ -1491,7 +1748,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMax.x - value: 0.5 + value: 1 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMax.y @@ -1499,7 +1756,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMin.x - value: 0.5 + value: 0 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMin.y @@ -1507,7 +1764,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_SizeDelta.x - value: 1000 + value: -440 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_SizeDelta.y @@ -1575,6 +1832,7 @@ SceneRoots: m_ObjectHideFlags: 0 m_Roots: - {fileID: 322172998} + - {fileID: 1087467995} - {fileID: 1104447313} - {fileID: 1145326587} - {fileID: 829067253} diff --git a/Assets/Core/DebugConsole/GameDebugConsoleManager.cs b/Assets/Core/DebugConsole/GameDebugConsoleManager.cs index 8078a99..0997522 100644 --- a/Assets/Core/DebugConsole/GameDebugConsoleManager.cs +++ b/Assets/Core/DebugConsole/GameDebugConsoleManager.cs @@ -56,6 +56,7 @@ void Start() { Application.logMessageReceived += OnLogMessageReceived; SetupUI(); + ValidateButtonSetup(); } void OnDestroy() @@ -102,8 +103,8 @@ private void InitializeConsole() return; } - _consolePanel.SetActive(false); - _isConsoleVisible = false; + _consolePanel.SetActive(true); + _isConsoleVisible = true; SetupLayoutGroup(); @@ -115,19 +116,36 @@ private void InitializeConsole() private void SetupUI() { + Debug.Log("[DEBUG_CONSOLE] SetupUI called"); + if (_clearButton != null) { _clearButton.onClick.AddListener(ClearLogs); + Debug.Log("[DEBUG_CONSOLE] Clear button listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _clearButton is null!"); } if (_toggleButton != null) { _toggleButton.onClick.AddListener(ToggleConsole); + Debug.Log("[DEBUG_CONSOLE] Toggle button listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _toggleButton is null!"); } if (_filterInput != null) { _filterInput.onValueChanged.AddListener(OnFilterChanged); + Debug.Log("[DEBUG_CONSOLE] Filter input listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _filterInput is null!"); } } @@ -341,11 +359,18 @@ private System.Collections.IEnumerator ScrollToBottomCoroutine() public void ToggleConsole() { + Debug.Log($"[DEBUG_CONSOLE] ToggleConsole called. Current state: {_isConsoleVisible}"); + _isConsoleVisible = !_isConsoleVisible; if (_consolePanel != null) { _consolePanel.SetActive(_isConsoleVisible); + Debug.Log($"[DEBUG_CONSOLE] Console panel set to: {_isConsoleVisible}"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _consolePanel is null!"); } if (_isConsoleVisible) @@ -517,5 +542,34 @@ public void SetupLayoutGroup() contentSizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; contentSizeFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; } + + public void ValidateButtonSetup() + { + Debug.Log("[DEBUG_CONSOLE] === Button Setup Validation ==="); + + if (_toggleButton == null) + { + Debug.LogError("[DEBUG_CONSOLE] _toggleButton is null! Please assign it in the inspector."); + return; + } + + Debug.Log($"[DEBUG_CONSOLE] Toggle button found: {_toggleButton.name}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button active: {_toggleButton.gameObject.activeInHierarchy}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button interactable: {_toggleButton.interactable}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button onClick event count: {_toggleButton.onClick.GetPersistentEventCount()}"); + + if (_consolePanel == null) + { + Debug.LogError("[DEBUG_CONSOLE] _consolePanel is null! Please assign it in the inspector."); + } + else + { + Debug.Log($"[DEBUG_CONSOLE] Console panel found: {_consolePanel.name}"); + Debug.Log($"[DEBUG_CONSOLE] Console panel active: {_consolePanel.activeInHierarchy}"); + } + + Debug.Log($"[DEBUG_CONSOLE] Console visible state: {_isConsoleVisible}"); + Debug.Log("[DEBUG_CONSOLE] === End Validation ==="); + } } } \ No newline at end of file diff --git a/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset b/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset index d5b8bda..0ae975a 100644 --- a/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset +++ b/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset @@ -14,8 +14,8 @@ MonoBehaviour: m_EditorClassIdentifier: characterId: zero characterName: "\uC81C\uB85C" - characterPrefab: {fileID: 8051974178353762967, guid: 9be43d9f8e24d634186c522ebce74618, type: 3} - thumbnail: {fileID: 2800000, guid: e029dae0a0b46124ba7b0b6b2ac00a76, type: 3} + characterPrefab: {fileID: 7609475132481125670, guid: 9e7b6e32e30a8e144aac535afecd9340, type: 3} + thumbnail: {fileID: 2800000, guid: a443448c8422da640825449c667a9378, type: 3} characterDescription: "\uCE58\uC640\uC9F1 \uBAA8\uB378 \uAE30\uBC18" emotionMappings: - emotionKey: @@ -26,9 +26,9 @@ MonoBehaviour: - actionKey: motionGroup: motionName: - isLockAtActive: 0 + isLookAtActive: 1 lookSensitivity: 1 - lockAtDamping: 0 + lookAtDamping: 0 useLipSync: 1 gain: 10 smoothing: 1 diff --git a/Assets/Domain/Character/Model/Model.fadeMotionList.asset b/Assets/Domain/Character/Model/Model.fadeMotionList.asset deleted file mode 100644 index 798e9bd..0000000 --- a/Assets/Domain/Character/Model/Model.fadeMotionList.asset +++ /dev/null @@ -1,17 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 403ae2dd693bb1d4b924f6b8d206b053, type: 3} - m_Name: Model.fadeMotionList - m_EditorClassIdentifier: - MotionInstanceIds: - CubismFadeMotionObjects: - - {fileID: 0} diff --git a/Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta b/Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta deleted file mode 100644 index 9b3ec5d..0000000 --- a/Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b1083908ab87d904a95806ad80fc75eb -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Domain/Character/Model/Sample/natoriConfig.asset b/Assets/Domain/Character/Model/Sample/natoriConfig.asset index 7c8e318..f5efe6d 100644 --- a/Assets/Domain/Character/Model/Sample/natoriConfig.asset +++ b/Assets/Domain/Character/Model/Sample/natoriConfig.asset @@ -20,4 +20,4 @@ MonoBehaviour: isLockAtActive: 1 gain: 10 smoothing: 1 - modelPrefab: {fileID: 6181935751025943507, guid: 43e77a085e1072e4dbc6393f20643f3b, type: 3} + modelPrefab: {fileID: 7609475132481125670, guid: 9e7b6e32e30a8e144aac535afecd9340, type: 3} diff --git a/Assets/Domain/Character/Script/CharacterManager.cs b/Assets/Domain/Character/Script/CharacterManager.cs index d1066ca..d47de1d 100644 --- a/Assets/Domain/Character/Script/CharacterManager.cs +++ b/Assets/Domain/Character/Script/CharacterManager.cs @@ -53,13 +53,6 @@ public void Initialize() // 임시로 zero 캐릭터 로드 LoadCharacter("zero"); - if (_modelTransform != null) { - var scaler = _modelTransform.GetComponent(); - if (scaler == null) { - scaler = _modelTransform.gameObject.AddComponent(); - Debug.Log($"[CharacterManager] Live2DModelScaler가 자동으로 추가되었습니다: {_modelTransform.name}"); - } - } } public void Shutdown() { diff --git a/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs b/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs index 78918b3..9f13e7f 100644 --- a/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs +++ b/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs @@ -26,15 +26,15 @@ public class CameraResolutionScaler : MonoBehaviour private void Start() { - Initialize(); + // Initialize(); } private void Update() { - if (applyOnResolutionChange && HasResolutionChanged()) - { - ApplyCameraScale(); - } + // if (applyOnResolutionChange && HasResolutionChanged()) + // { + // ApplyCameraScale(); + // } } #endregion diff --git a/Assets/Domain/Character/Script/Component/ResolutionManager.cs b/Assets/Domain/Character/Script/Component/ResolutionManager.cs index 3c29307..2a641a0 100644 --- a/Assets/Domain/Character/Script/Component/ResolutionManager.cs +++ b/Assets/Domain/Character/Script/Component/ResolutionManager.cs @@ -26,15 +26,15 @@ public class ResolutionManager : MonoBehaviour private void Start() { - Initialize(); + // Initialize(); } private void Update() { - if (applyOnResolutionChange && HasResolutionChanged()) - { - ApplyScaleToAllModels(); - } + // if (applyOnResolutionChange && HasResolutionChanged()) + // { + // ApplyScaleToAllModels(); + // } } #endregion diff --git a/Assets/Resources/Live2DModelRegistry.asset b/Assets/Domain/Live2DModelRegistry.asset similarity index 100% rename from Assets/Resources/Live2DModelRegistry.asset rename to Assets/Domain/Live2DModelRegistry.asset diff --git a/Assets/Resources/Live2DModelRegistry.asset.meta b/Assets/Domain/Live2DModelRegistry.asset.meta similarity index 100% rename from Assets/Resources/Live2DModelRegistry.asset.meta rename to Assets/Domain/Live2DModelRegistry.asset.meta diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index aae99c7..768d44a 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -8,9 +8,6 @@ EditorBuildSettings: - enabled: 1 path: Assets/App/Scenes/MainScene.unity guid: 0845d716b4db3d745a759f81d547dea6 - - enabled: 1 - path: Assets/App/Scenes/StartSence.unity - guid: 45dd910a68ba96943b623bd5a5e542ce m_configObjects: com.unity.adaptiveperformance.google.android.provider_settings: {fileID: 11400000, guid: e83dfb33c7f04304db0a9f4b231aa925, type: 2} com.unity.adaptiveperformance.loader_settings: {fileID: 11400000, guid: 5daa2912411094b4abb351f7b36c0b50, type: 2} diff --git a/ProjectSettings/GraphicsSettings.asset b/ProjectSettings/GraphicsSettings.asset index 2e05892..c1d387d 100644 --- a/ProjectSettings/GraphicsSettings.asset +++ b/ProjectSettings/GraphicsSettings.asset @@ -59,7 +59,7 @@ GraphicsSettings: m_AlbedoSwatchInfos: [] m_RenderPipelineGlobalSettingsMap: UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 93b439a37f63240aca3dd4e01d978a9f, type: 2} - m_LightsUseLinearIntensity: 0 + m_LightsUseLinearIntensity: 1 m_LightsUseColorTemperature: 1 m_LogWhenShaderIsCompiled: 0 m_LightProbeOutsideHullStrategy: 0 diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index 1966f32..1828ab4 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -4,7 +4,7 @@ PlayerSettings: m_ObjectHideFlags: 0 serializedVersion: 28 - productGUID: c788582c51c747e4cb5346503a1c7457 + productGUID: d4a8d6b8bc019a34d8c8e808530b78de AndroidProfiler: 0 AndroidFilterTouchesWhenObscured: 0 AndroidEnableSustainedPerformanceMode: 0 @@ -13,7 +13,7 @@ PlayerSettings: useOnDemandResources: 0 accelerometerFrequency: 60 companyName: DefaultCompany - productName: ProjectVG_Client + productName: ProjectVG-Client defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} @@ -47,7 +47,7 @@ PlayerSettings: defaultScreenWidthWeb: 960 defaultScreenHeightWeb: 600 m_StereoRenderingPath: 0 - m_ActiveColorSpace: 0 + m_ActiveColorSpace: 1 unsupportedMSAAFallback: 0 m_SpriteBatchMaxVertexCount: 65535 m_SpriteBatchVertexThreshold: 300 @@ -55,7 +55,7 @@ PlayerSettings: mipStripping: 0 numberOfMipsStripped: 0 numberOfMipsStrippedPerMipmapLimitGroup: {} - m_StackTraceTypes: 020000000200000002000000020000000200000001000000 + m_StackTraceTypes: 010000000100000001000000010000000100000001000000 iosShowActivityIndicatorOnLoading: -1 androidShowActivityIndicatorOnLoading: -1 iosUseCustomAppBackgroundBehavior: 0 @@ -70,7 +70,7 @@ PlayerSettings: androidStartInFullscreen: 1 androidRenderOutsideSafeArea: 1 androidUseSwappy: 1 - androidBlitType: 2 + androidBlitType: 0 androidResizeableActivity: 1 androidDefaultWindowWidth: 1920 androidDefaultWindowHeight: 1080 @@ -82,7 +82,7 @@ PlayerSettings: androidApplicationEntry: 2 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 1 + runInBackground: 0 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 @@ -90,7 +90,7 @@ PlayerSettings: hideHomeButton: 0 submitAnalytics: 1 usePlayerLog: 1 - dedicatedServerOptimizations: 0 + dedicatedServerOptimizations: 1 bakeCollisionMeshes: 0 forceSingleInstance: 0 useFlipModelSwapchain: 1 @@ -140,12 +140,9 @@ PlayerSettings: loadStoreDebugModeEnabled: 0 visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 - bundleVersion: 1.0.2 + bundleVersion: 1.0 preloadedAssets: - {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3} - - {fileID: -5189789383737813234, guid: 5daa2912411094b4abb351f7b36c0b50, type: 2} - - {fileID: 11400000, guid: e83dfb33c7f04304db0a9f4b231aa925, type: 2} - - {fileID: 11400000, guid: 08834a4b1dd61094587329f7b37c3aae, type: 2} metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 @@ -167,11 +164,7 @@ PlayerSettings: androidMaxAspectRatio: 2.4 androidMinAspectRatio: 1 applicationIdentifier: - Android: com.DefaultCompany.com.unity.template.mobile2D - Lumin: com.DefaultCompany.com.unity.template.mobile2D - Standalone: com.DefaultCompany.com.unity.template.mobile2D - iPhone: com.DefaultCompany.com.unity.template.mobile2D - tvOS: com.DefaultCompany.com.unity.template.mobile2D + Standalone: com.DefaultCompany.2D-URP buildNumber: Standalone: 0 VisionOS: 0 @@ -258,8 +251,8 @@ PlayerSettings: iOSAutomaticallyDetectAndAddCapabilities: 1 appleEnableProMotion: 0 shaderPrecisionModel: 0 - clonedFromGUID: f918f204a65eb664ba9e5b39d533ff8e - templatePackageId: com.unity.template.mobile2d@6.0.0 + clonedFromGUID: c19f32bac17ee4170b3bf8a6a0333fb9 + templatePackageId: com.unity.template.universal-2d@5.1.0 templateDefaultScene: Assets/Scenes/SampleScene.unity useCustomMainManifest: 0 useCustomLauncherManifest: 0 @@ -390,41 +383,9 @@ PlayerSettings: m_SubKind: m_BuildTargetBatching: [] m_BuildTargetShaderSettings: [] - m_BuildTargetGraphicsJobs: - - m_BuildTarget: MacStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: Switch - m_GraphicsJobs: 0 - - m_BuildTarget: MetroSupport - m_GraphicsJobs: 0 - - m_BuildTarget: AppleTVSupport - m_GraphicsJobs: 0 - - m_BuildTarget: BJMSupport - m_GraphicsJobs: 0 - - m_BuildTarget: LinuxStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: PS4Player - m_GraphicsJobs: 0 - - m_BuildTarget: iOSSupport - m_GraphicsJobs: 0 - - m_BuildTarget: WindowsStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: XboxOnePlayer - m_GraphicsJobs: 0 - - m_BuildTarget: LuminSupport - m_GraphicsJobs: 0 - - m_BuildTarget: AndroidPlayer - m_GraphicsJobs: 0 - - m_BuildTarget: WebGLSupport - m_GraphicsJobs: 0 + m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobMode: [] - m_BuildTargetGraphicsAPIs: - - m_BuildTarget: AndroidPlayer - m_APIs: 150000000b000000 - m_Automatic: 1 - - m_BuildTarget: iOSSupport - m_APIs: 10000000 - m_Automatic: 1 + m_BuildTargetGraphicsAPIs: [] m_BuildTargetVRSettings: [] m_DefaultShaderChunkSizeInMB: 16 m_DefaultShaderChunkCount: 0 @@ -444,10 +405,7 @@ PlayerSettings: m_BuildTargetDefaultTextureCompressionFormat: - serializedVersion: 3 m_BuildTarget: Android - m_Formats: 03000000 - - serializedVersion: 3 - m_BuildTarget: WebGL - m_Formats: 03000000 + m_Formats: 01000000 playModeTestRunnerEnabled: 0 runPlayModeTestAsEditModeTest: 0 actionOnDotNetUnhandledException: 1 @@ -645,7 +603,7 @@ PlayerSettings: ps4GarlicHeapSize: 2048 ps4ProGarlicHeapSize: 2560 playerPrefsMaxSize: 32768 - ps4Passcode: D25bFOIlcWmANChRvUT1ao3Ud48vHsjc + ps4Passcode: frAQBc8Wsa1xVPfvJcrgRYwTiizs2trQ ps4pnSessions: 1 ps4pnPresence: 1 ps4pnFriends: 1 @@ -734,14 +692,14 @@ PlayerSettings: editorAssembliesCompatibilityLevel: 1 m_RenderingPath: 1 m_MobileRenderingPath: 1 - metroPackageName: ProjectVG_Client + metroPackageName: ProjectVG-Client metroPackageVersion: metroCertificatePath: metroCertificatePassword: metroCertificateSubject: metroCertificateIssuer: metroCertificateNotAfter: 0000000000000000 - metroApplicationDescription: ProjectVG_Client + metroApplicationDescription: ProjectVG-Client wsaImages: {} metroTileShortName: metroTileShowName: 0 From 79e4aa00bf5e745d457f43915c1d716c806e587f Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 15:06:41 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20http=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Infrastructure/Bridge.meta | 8 - .../Network/Http/HttpApiClient.cs | 456 +++++++----------- 2 files changed, 174 insertions(+), 290 deletions(-) delete mode 100644 Assets/Infrastructure/Bridge.meta diff --git a/Assets/Infrastructure/Bridge.meta b/Assets/Infrastructure/Bridge.meta deleted file mode 100644 index e6b4956..0000000 --- a/Assets/Infrastructure/Bridge.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 8e7bce2422064f948a9595706ec79f62 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index 2cd3294..327ee44 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -14,8 +14,6 @@ namespace ProjectVG.Infrastructure.Network.Http { public class HttpApiClient : Singleton { - [Header("API Configuration")] - private const string ACCEPT_HEADER = "application/json"; private const string AUTHORIZATION_HEADER = "Authorization"; private const string BEARER_PREFIX = "Bearer "; @@ -24,9 +22,8 @@ public class HttpApiClient : Singleton private CancellationTokenSource cancellationTokenSource; private SessionManager _sessionManager; public bool IsInitialized { get; private set; } - + #region Unity Lifecycle - protected override void Awake() { base.Awake(); @@ -36,67 +33,52 @@ private void OnDestroy() { Shutdown(); } - - #endregion - - #region Public Methods - - /// - /// 초기화 실행 - /// + public void Initialize(SessionManager sessionManager) { + if (sessionManager == null) { + throw new ArgumentNullException(nameof(sessionManager), "[HttpApiClient] SessionManager는 null일 수 없습니다."); + } + _sessionManager = sessionManager; cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); - ApplyNetworkConfig(); SetupDefaultHeaders(); IsInitialized = true; } - - /// - /// 기본 헤더 추가 - /// + public void AddDefaultHeader(string key, string value) { defaultHeaders[key] = value; } - /// - /// 인증 토큰 설정 - /// public void SetAuthToken(string token) { AddDefaultHeader(AUTHORIZATION_HEADER, $"{BEARER_PREFIX}{token}"); } + #endregion + + #region HTTP Methods + public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { - // 자동 초기화 - if (!IsInitialized) - { - Debug.LogWarning("[HttpApiClient] 자동 초기화 수행"); - Initialize(null); - } - + ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] GET 요청 시작: {url}"); - Debug.Log($"[HttpApiClient] 헤더: {JsonConvert.SerializeObject(headers)}"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } - /// - /// GET 요청 (HTTP 헤더 포함 응답) - /// public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); @@ -104,6 +86,7 @@ public async UniTask PostAsync(string endpoint, object data = null, Dictio public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPUT, jsonData, headers, cancellationToken); @@ -111,70 +94,63 @@ public async UniTask PutAsync(string endpoint, object data = null, Diction public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbDELETE, null, headers, cancellationToken); } public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); - var formData = new Dictionary - { - { fieldName, fileData } - }; - var fileNames = new Dictionary - { - { fieldName, fileName } - }; + var formData = new Dictionary { { fieldName, fileData } }; + var fileNames = new Dictionary { { fieldName, fileName } }; return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } - + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers = null, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendFormDataRequestAsync(url, formData, null, headers, cancellationToken); } public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers = null, CancellationToken cancellationToken = default) { + ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - - // 파일 크기 검사 - if (NetworkConfig.EnableFileSizeCheck) - { - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { - if (byteData.Length > NetworkConfig.MaxFileSize) - { - var fileSizeMB = byteData.Length / 1024.0 / 1024.0; - var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; - throw new FileSizeExceededException(fileSizeMB, maxSizeMB); - } - } - } - } - + ValidateFileSizes(formData); return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } - /// - /// 종료 처리 - /// public void Shutdown() { cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); IsInitialized = false; } - #endregion - + #region Private Methods - private void ApplyNetworkConfig() + private void ThrowIfNotInitialized() + { + if (!IsInitialized) { + throw new InvalidOperationException("[HttpApiClient] HttpApiClient가 초기화되지 않았습니다. Initialize(SessionManager) 메서드를 먼저 호출하세요."); + } + } + + private void ValidateFileSizes(Dictionary formData) { + if (!NetworkConfig.EnableFileSizeCheck) return; + + foreach (var kvp in formData) { + if (kvp.Value is byte[] byteData && byteData.Length > NetworkConfig.MaxFileSize) { + var fileSizeMB = byteData.Length / 1024.0 / 1024.0; + var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; + throw new FileSizeExceededException(fileSizeMB, maxSizeMB); + } + } } private void SetupDefaultHeaders() @@ -189,7 +165,7 @@ private string GetFullUrl(string endpoint) { return NetworkConfig.GetFullApiUrl(endpoint); } - + private bool IsFullUrl(string url) { return url.StartsWith("http://") || url.StartsWith("https://"); @@ -198,102 +174,64 @@ private bool IsFullUrl(string url) private string SerializeData(object data, bool requiresSession = false) { if (data == null) return null; - + var jsonData = JsonConvert.SerializeObject(data); - - if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) - { - var sessionId = _sessionManager?.SessionId ?? ""; - if (!string.IsNullOrEmpty(sessionId)) - { + + if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) { + var sessionId = _sessionManager.SessionId; + if (!string.IsNullOrEmpty(sessionId)) { var jsonObject = JsonConvert.DeserializeObject>(jsonData); jsonObject["session_id"] = sessionId; jsonData = JsonConvert.SerializeObject(jsonObject); } - else - { + else { Debug.LogWarning("[HttpApiClient] 세션 연결이 필요한 요청이지만 세션 ID를 획득할 수 없습니다."); } } - + return jsonData; } - - private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { - Debug.Log($"[HttpApiClient] SendJsonRequestAsync 시작"); - Debug.Log($"[HttpApiClient] URL: {url}"); - Debug.Log($"[HttpApiClient] Method: {method}"); - Debug.Log($"[HttpApiClient] JSON Data: {jsonData}"); - Debug.Log($"[HttpApiClient] Headers: {JsonConvert.SerializeObject(headers)}"); - Debug.Log($"[HttpApiClient] IsInitialized: {IsInitialized}"); - Debug.Log($"[HttpApiClient] cancellationTokenSource null: {cancellationTokenSource == null}"); - - var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) - { - try - { - using var request = CreateJsonRequest(url, method, jsonData, headers); - - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - return ParseResponse(request); - } - else - { - await HandleRequestFailure(request, attempt, combinedCancellationToken); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ex is not ApiException) - { - await HandleRequestException(ex, attempt, combinedCancellationToken); - } - } - - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); + LogRequestInfo(url, method, jsonData, headers); + return await SendJsonRequestCoreAsync(url, method, jsonData, headers, cancellationToken, false); } private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + { + return await SendJsonRequestCoreAsync<(T Data, Dictionary Headers)>(url, method, jsonData, headers, cancellationToken, true); + } + + private async UniTask SendJsonRequestCoreAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken, bool includeHeaders) { var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) - { - try - { + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { + try { using var request = CreateJsonRequest(url, method, jsonData, headers); - var operation = request.SendWebRequest(); await operation.WithCancellation(combinedCancellationToken); - if (request.result == UnityWebRequest.Result.Success) - { - var data = ParseResponse(request); - var responseHeaders = ExtractResponseHeaders(request); - return (data, responseHeaders); + if (request.result == UnityWebRequest.Result.Success) { + if (includeHeaders) { + var data = ParseResponse(request); + var responseHeaders = ExtractResponseHeaders(request); + return (T)(object)(data, responseHeaders); + } + else { + return ParseResponse(request); + } } - else - { - await HandleRequestFailure(request, attempt, combinedCancellationToken); + else { + Debug.LogWarning($"[HttpApiClient] request.result != Success: {request.result}, StatusCode: {request.responseCode}"); + throw new ApiException(request.error, request.responseCode, request.downloadHandler?.text); } } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is not ApiException) - { + catch (Exception ex) when (ex is not ApiException) { await HandleRequestException(ex, attempt, combinedCancellationToken); } } @@ -301,63 +239,43 @@ private async UniTask SendJsonRequestAsync(string url, string method, stri throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } - - - - - - private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) { fileNames = fileNames ?? new Dictionary(); var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) - { - try - { + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { + try { var form = new WWWForm(); - Debug.Log($"[HttpApiClient] 폼 데이터 전송 시작 - URL: {url}"); - Debug.Log($"[HttpApiClient] 실제 전송 URL: {url}"); - - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { + + foreach (var kvp in formData) { + if (kvp.Value is byte[] byteData) { string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : "file.wav"; form.AddBinaryData(kvp.Key, byteData, fileName); - Debug.Log($"[HttpApiClient] 바이너리 데이터 추가 - 필드: {kvp.Key}, 파일명: {fileName}, 크기: {byteData.Length} bytes"); } - else - { + else { form.AddField(kvp.Key, kvp.Value.ToString()); - Debug.Log($"[HttpApiClient] 필드 추가 - {kvp.Key}: {kvp.Value}"); } } - + using var request = UnityWebRequest.Post(url, form); - // 파일 업로드 시 Content-Type은 UnityWebRequest가 자동으로 설정하도록 함 SetupRequest(request, headers); - request.timeout = (int)NetworkConfig.UploadTimeout; // Use UploadTimeout - + request.timeout = (int)NetworkConfig.UploadTimeout; + var operation = request.SendWebRequest(); await operation.WithCancellation(combinedCancellationToken); - if (request.result == UnityWebRequest.Result.Success) - { + if (request.result == UnityWebRequest.Result.Success) { return ParseResponse(request); } - else - { + else { await HandleFileUploadFailure(request, attempt, combinedCancellationToken); } } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is not ApiException) - { + catch (Exception ex) when (ex is not ApiException) { await HandleFileUploadException(ex, attempt, combinedCancellationToken); } } @@ -367,198 +285,170 @@ private async UniTask SendFormDataRequestAsync(string url, Dictionary headers) { var request = new UnityWebRequest(url, method); - - if (!string.IsNullOrEmpty(jsonData)) - { + + if (!string.IsNullOrEmpty(jsonData)) { var bodyRaw = Encoding.UTF8.GetBytes(jsonData); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.SetRequestHeader("Content-Type", "application/json"); } - + request.downloadHandler = new DownloadHandlerBuffer(); SetupRequest(request, headers); request.timeout = (int)NetworkConfig.HttpTimeout; - + return request; } private void SetupRequest(UnityWebRequest request, Dictionary headers) { - // UnityWebRequest.Post로 생성된 요청은 무조건 파일 업로드로 처리 - bool isFileUpload = request.method == UnityWebRequest.kHttpVerbPOST && - request.uploadHandler != null; - - foreach (var header in defaultHeaders) - { - // 파일 업로드 시에는 Content-Type 헤더를 제외 (UnityWebRequest가 자동 설정) - if (isFileUpload && header.Key.ToLower() == "content-type") - { + bool isFileUpload = request.method == UnityWebRequest.kHttpVerbPOST && request.uploadHandler != null; + + foreach (var header in defaultHeaders) { + if (isFileUpload && header.Key.ToLower() == "content-type") { continue; } - + request.SetRequestHeader(header.Key, header.Value); } - if (headers != null) - { - foreach (var header in headers) - { + if (headers != null) { + foreach (var header in headers) { request.SetRequestHeader(header.Key, header.Value); } } - - // 디버깅: Content-Type 헤더 확인 - string contentType = request.GetRequestHeader("Content-Type"); - Debug.Log($"[HttpApiClient] 요청 헤더 설정 완료 - Content-Type: {contentType}"); + } + + private void LogRequestInfo(string url, string method, string jsonData, Dictionary headers) + { + Debug.Log($"[HttpApiClient] HTTP 요청 - URL: {url}, Method: {method}, Data: {jsonData}, Headers: {JsonConvert.SerializeObject(headers)}"); } private T ParseResponse(UnityWebRequest request) { var responseText = request.downloadHandler?.text; - - Debug.Log($"[HttpApiClient] 응답 파싱 - Status: {request.responseCode}, Content-Length: {request.downloadHandler?.data?.Length ?? 0}"); - Debug.Log($"[HttpApiClient] 응답 텍스트: '{responseText}'"); - - if (string.IsNullOrEmpty(responseText)) - { + + if (string.IsNullOrEmpty(responseText)) { Debug.LogWarning("[HttpApiClient] 응답 텍스트가 비어있습니다."); return default(T); } - try - { + try { return JsonConvert.DeserializeObject(responseText); } - catch (Exception ex) - { + catch (Exception ex) { Debug.LogError($"[HttpApiClient] JSON 파싱 실패: {ex.Message}"); + + if (IsErrorResponse(responseText)) { + Debug.LogWarning("[HttpApiClient] 서버에서 에러 응답을 받았습니다."); + throw new ApiException("서버 에러 응답", request.responseCode, responseText); + } + return TryFallbackParse(responseText, request.responseCode, ex); } } + private bool IsErrorResponse(string responseText) + { + try { + var jsonObject = JsonConvert.DeserializeObject>(responseText); + return jsonObject.ContainsKey("errorCode") && jsonObject.ContainsKey("message"); + } + catch { + return false; + } + } + private T TryFallbackParse(string responseText, long responseCode, Exception originalException) { - try - { + try { return JsonUtility.FromJson(responseText); } - catch (Exception fallbackEx) - { + catch (Exception fallbackEx) { throw new ApiException($"응답 파싱 실패: {originalException.Message} (폴백도 실패: {fallbackEx.Message})", responseCode, responseText); } } - /// - /// HTTP 응답 헤더 추출 - /// private Dictionary ExtractResponseHeaders(UnityWebRequest request) { var headers = new Dictionary(); - - // UnityWebRequest에서 사용 가능한 헤더들 - var responseHeaders = request.GetResponseHeader("X-Access-Token"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Access-Token"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-Refresh-Token"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Refresh-Token"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-Expires-In"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Expires-In"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-User-Id"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-User-Id"] = responseHeaders; - - // 기타 헤더들도 추가 가능 - responseHeaders = request.GetResponseHeader("Content-Type"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["Content-Type"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("Authorization"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["Authorization"] = responseHeaders; - - Debug.Log($"[HttpApiClient] 응답 헤더 추출: {headers.Count}개 헤더"); - foreach (var header in headers) - { - Debug.Log($"[HttpApiClient] 헤더: {header.Key} = {header.Value}"); + var headerNames = new[] { "X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-User-Id", "Content-Type", "Authorization" }; + + foreach (var headerName in headerNames) { + var value = request.GetResponseHeader(headerName); + if (!string.IsNullOrEmpty(value)) { + headers[headerName] = value; + } } - + return headers; } @@ -566,16 +456,17 @@ private bool ShouldRetry(long responseCode) { return responseCode >= 500 || responseCode == 429; } - #endregion } + #region Exceptions + public class ApiException : Exception { public long StatusCode { get; } public string ResponseBody { get; } - public ApiException(string message, long statusCode, string responseBody) + public ApiException(string message, long statusCode, string responseBody) : base(message) { StatusCode = statusCode; @@ -585,14 +476,15 @@ public ApiException(string message, long statusCode, string responseBody) public class FileSizeExceededException : ApiException { - public FileSizeExceededException(double fileSizeMB, double maxSizeMB) + public FileSizeExceededException(double fileSizeMB, double maxSizeMB) : base($"File size exceeds limit: {fileSizeMB:F2}MB (limit: {maxSizeMB:F2}MB)", 413, null) { FileSizeMB = fileSizeMB; MaxSizeMB = maxSizeMB; } - + public double FileSizeMB { get; } public double MaxSizeMB { get; } } -} \ No newline at end of file + #endregion +} \ No newline at end of file From 75c913dc7190fe30bca981d75efbd49dc5eaa504 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 15:50:44 +0900 Subject: [PATCH 04/14] =?UTF-8?q?Revert=20"feat:=20http=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 79e4aa00bf5e745d457f43915c1d716c806e587f. --- Assets/Infrastructure/Bridge.meta | 8 + .../Network/Http/HttpApiClient.cs | 456 +++++++++++------- 2 files changed, 290 insertions(+), 174 deletions(-) create mode 100644 Assets/Infrastructure/Bridge.meta diff --git a/Assets/Infrastructure/Bridge.meta b/Assets/Infrastructure/Bridge.meta new file mode 100644 index 0000000..e6b4956 --- /dev/null +++ b/Assets/Infrastructure/Bridge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8e7bce2422064f948a9595706ec79f62 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index 327ee44..2cd3294 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -14,6 +14,8 @@ namespace ProjectVG.Infrastructure.Network.Http { public class HttpApiClient : Singleton { + [Header("API Configuration")] + private const string ACCEPT_HEADER = "application/json"; private const string AUTHORIZATION_HEADER = "Authorization"; private const string BEARER_PREFIX = "Bearer "; @@ -22,8 +24,9 @@ public class HttpApiClient : Singleton private CancellationTokenSource cancellationTokenSource; private SessionManager _sessionManager; public bool IsInitialized { get; private set; } - + #region Unity Lifecycle + protected override void Awake() { base.Awake(); @@ -33,52 +36,67 @@ private void OnDestroy() { Shutdown(); } - + + #endregion + + #region Public Methods + + /// + /// 초기화 실행 + /// public void Initialize(SessionManager sessionManager) { - if (sessionManager == null) { - throw new ArgumentNullException(nameof(sessionManager), "[HttpApiClient] SessionManager는 null일 수 없습니다."); - } - _sessionManager = sessionManager; cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); + ApplyNetworkConfig(); SetupDefaultHeaders(); IsInitialized = true; } - + + /// + /// 기본 헤더 추가 + /// public void AddDefaultHeader(string key, string value) { defaultHeaders[key] = value; } + /// + /// 인증 토큰 설정 + /// public void SetAuthToken(string token) { AddDefaultHeader(AUTHORIZATION_HEADER, $"{BEARER_PREFIX}{token}"); } - #endregion - - #region HTTP Methods - public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); + // 자동 초기화 + if (!IsInitialized) + { + Debug.LogWarning("[HttpApiClient] 자동 초기화 수행"); + Initialize(null); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + Debug.Log($"[HttpApiClient] GET 요청 시작: {url}"); + Debug.Log($"[HttpApiClient] 헤더: {JsonConvert.SerializeObject(headers)}"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } + /// + /// GET 요청 (HTTP 헤더 포함 응답) + /// public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); @@ -86,7 +104,6 @@ public async UniTask PostAsync(string endpoint, object data = null, Dictio public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPUT, jsonData, headers, cancellationToken); @@ -94,63 +111,70 @@ public async UniTask PutAsync(string endpoint, object data = null, Diction public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbDELETE, null, headers, cancellationToken); } public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = GetFullUrl(endpoint); - var formData = new Dictionary { { fieldName, fileData } }; - var fileNames = new Dictionary { { fieldName, fileName } }; + var formData = new Dictionary + { + { fieldName, fileData } + }; + var fileNames = new Dictionary + { + { fieldName, fileName } + }; return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } - + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendFormDataRequestAsync(url, formData, null, headers, cancellationToken); } public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers = null, CancellationToken cancellationToken = default) { - ThrowIfNotInitialized(); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - ValidateFileSizes(formData); + + // 파일 크기 검사 + if (NetworkConfig.EnableFileSizeCheck) + { + foreach (var kvp in formData) + { + if (kvp.Value is byte[] byteData) + { + if (byteData.Length > NetworkConfig.MaxFileSize) + { + var fileSizeMB = byteData.Length / 1024.0 / 1024.0; + var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; + throw new FileSizeExceededException(fileSizeMB, maxSizeMB); + } + } + } + } + return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } + /// + /// 종료 처리 + /// public void Shutdown() { cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); IsInitialized = false; } + #endregion - + #region Private Methods - private void ThrowIfNotInitialized() - { - if (!IsInitialized) { - throw new InvalidOperationException("[HttpApiClient] HttpApiClient가 초기화되지 않았습니다. Initialize(SessionManager) 메서드를 먼저 호출하세요."); - } - } - - private void ValidateFileSizes(Dictionary formData) + private void ApplyNetworkConfig() { - if (!NetworkConfig.EnableFileSizeCheck) return; - - foreach (var kvp in formData) { - if (kvp.Value is byte[] byteData && byteData.Length > NetworkConfig.MaxFileSize) { - var fileSizeMB = byteData.Length / 1024.0 / 1024.0; - var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; - throw new FileSizeExceededException(fileSizeMB, maxSizeMB); - } - } } private void SetupDefaultHeaders() @@ -165,7 +189,7 @@ private string GetFullUrl(string endpoint) { return NetworkConfig.GetFullApiUrl(endpoint); } - + private bool IsFullUrl(string url) { return url.StartsWith("http://") || url.StartsWith("https://"); @@ -174,64 +198,102 @@ private bool IsFullUrl(string url) private string SerializeData(object data, bool requiresSession = false) { if (data == null) return null; - + var jsonData = JsonConvert.SerializeObject(data); - - if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) { - var sessionId = _sessionManager.SessionId; - if (!string.IsNullOrEmpty(sessionId)) { + + if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) + { + var sessionId = _sessionManager?.SessionId ?? ""; + if (!string.IsNullOrEmpty(sessionId)) + { var jsonObject = JsonConvert.DeserializeObject>(jsonData); jsonObject["session_id"] = sessionId; jsonData = JsonConvert.SerializeObject(jsonObject); } - else { + else + { Debug.LogWarning("[HttpApiClient] 세션 연결이 필요한 요청이지만 세션 ID를 획득할 수 없습니다."); } } - + return jsonData; } + + private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { - LogRequestInfo(url, method, jsonData, headers); - return await SendJsonRequestCoreAsync(url, method, jsonData, headers, cancellationToken, false); - } + Debug.Log($"[HttpApiClient] SendJsonRequestAsync 시작"); + Debug.Log($"[HttpApiClient] URL: {url}"); + Debug.Log($"[HttpApiClient] Method: {method}"); + Debug.Log($"[HttpApiClient] JSON Data: {jsonData}"); + Debug.Log($"[HttpApiClient] Headers: {JsonConvert.SerializeObject(headers)}"); + Debug.Log($"[HttpApiClient] IsInitialized: {IsInitialized}"); + Debug.Log($"[HttpApiClient] cancellationTokenSource null: {cancellationTokenSource == null}"); + + var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) - { - return await SendJsonRequestCoreAsync<(T Data, Dictionary Headers)>(url, method, jsonData, headers, cancellationToken, true); + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + { + try + { + using var request = CreateJsonRequest(url, method, jsonData, headers); + + var operation = request.SendWebRequest(); + await operation.WithCancellation(combinedCancellationToken); + + if (request.result == UnityWebRequest.Result.Success) + { + return ParseResponse(request); + } + else + { + await HandleRequestFailure(request, attempt, combinedCancellationToken); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is not ApiException) + { + await HandleRequestException(ex, attempt, combinedCancellationToken); + } + } + + throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } - private async UniTask SendJsonRequestCoreAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken, bool includeHeaders) + private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { - try { + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + { + try + { using var request = CreateJsonRequest(url, method, jsonData, headers); + var operation = request.SendWebRequest(); await operation.WithCancellation(combinedCancellationToken); - if (request.result == UnityWebRequest.Result.Success) { - if (includeHeaders) { - var data = ParseResponse(request); - var responseHeaders = ExtractResponseHeaders(request); - return (T)(object)(data, responseHeaders); - } - else { - return ParseResponse(request); - } + if (request.result == UnityWebRequest.Result.Success) + { + var data = ParseResponse(request); + var responseHeaders = ExtractResponseHeaders(request); + return (data, responseHeaders); } - else { - Debug.LogWarning($"[HttpApiClient] request.result != Success: {request.result}, StatusCode: {request.responseCode}"); - throw new ApiException(request.error, request.responseCode, request.downloadHandler?.text); + else + { + await HandleRequestFailure(request, attempt, combinedCancellationToken); } } - catch (OperationCanceledException) { + catch (OperationCanceledException) + { throw; } - catch (Exception ex) when (ex is not ApiException) { + catch (Exception ex) when (ex is not ApiException) + { await HandleRequestException(ex, attempt, combinedCancellationToken); } } @@ -239,43 +301,63 @@ private async UniTask SendJsonRequestCoreAsync(string url, string method, throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } + + + + + + private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) { fileNames = fileNames ?? new Dictionary(); var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { - try { + for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + { + try + { var form = new WWWForm(); - - foreach (var kvp in formData) { - if (kvp.Value is byte[] byteData) { + Debug.Log($"[HttpApiClient] 폼 데이터 전송 시작 - URL: {url}"); + Debug.Log($"[HttpApiClient] 실제 전송 URL: {url}"); + + foreach (var kvp in formData) + { + if (kvp.Value is byte[] byteData) + { string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : "file.wav"; form.AddBinaryData(kvp.Key, byteData, fileName); + Debug.Log($"[HttpApiClient] 바이너리 데이터 추가 - 필드: {kvp.Key}, 파일명: {fileName}, 크기: {byteData.Length} bytes"); } - else { + else + { form.AddField(kvp.Key, kvp.Value.ToString()); + Debug.Log($"[HttpApiClient] 필드 추가 - {kvp.Key}: {kvp.Value}"); } } - + using var request = UnityWebRequest.Post(url, form); + // 파일 업로드 시 Content-Type은 UnityWebRequest가 자동으로 설정하도록 함 SetupRequest(request, headers); - request.timeout = (int)NetworkConfig.UploadTimeout; - + request.timeout = (int)NetworkConfig.UploadTimeout; // Use UploadTimeout + var operation = request.SendWebRequest(); await operation.WithCancellation(combinedCancellationToken); - if (request.result == UnityWebRequest.Result.Success) { + if (request.result == UnityWebRequest.Result.Success) + { return ParseResponse(request); } - else { + else + { await HandleFileUploadFailure(request, attempt, combinedCancellationToken); } } - catch (OperationCanceledException) { + catch (OperationCanceledException) + { throw; } - catch (Exception ex) when (ex is not ApiException) { + catch (Exception ex) when (ex is not ApiException) + { await HandleFileUploadException(ex, attempt, combinedCancellationToken); } } @@ -285,170 +367,198 @@ private async UniTask SendFormDataRequestAsync(string url, Dictionary headers) { var request = new UnityWebRequest(url, method); - - if (!string.IsNullOrEmpty(jsonData)) { + + if (!string.IsNullOrEmpty(jsonData)) + { var bodyRaw = Encoding.UTF8.GetBytes(jsonData); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.SetRequestHeader("Content-Type", "application/json"); } - + request.downloadHandler = new DownloadHandlerBuffer(); SetupRequest(request, headers); request.timeout = (int)NetworkConfig.HttpTimeout; - + return request; } private void SetupRequest(UnityWebRequest request, Dictionary headers) { - bool isFileUpload = request.method == UnityWebRequest.kHttpVerbPOST && request.uploadHandler != null; - - foreach (var header in defaultHeaders) { - if (isFileUpload && header.Key.ToLower() == "content-type") { + // UnityWebRequest.Post로 생성된 요청은 무조건 파일 업로드로 처리 + bool isFileUpload = request.method == UnityWebRequest.kHttpVerbPOST && + request.uploadHandler != null; + + foreach (var header in defaultHeaders) + { + // 파일 업로드 시에는 Content-Type 헤더를 제외 (UnityWebRequest가 자동 설정) + if (isFileUpload && header.Key.ToLower() == "content-type") + { continue; } - + request.SetRequestHeader(header.Key, header.Value); } - if (headers != null) { - foreach (var header in headers) { + if (headers != null) + { + foreach (var header in headers) + { request.SetRequestHeader(header.Key, header.Value); } } - } - - private void LogRequestInfo(string url, string method, string jsonData, Dictionary headers) - { - Debug.Log($"[HttpApiClient] HTTP 요청 - URL: {url}, Method: {method}, Data: {jsonData}, Headers: {JsonConvert.SerializeObject(headers)}"); + + // 디버깅: Content-Type 헤더 확인 + string contentType = request.GetRequestHeader("Content-Type"); + Debug.Log($"[HttpApiClient] 요청 헤더 설정 완료 - Content-Type: {contentType}"); } private T ParseResponse(UnityWebRequest request) { var responseText = request.downloadHandler?.text; - - if (string.IsNullOrEmpty(responseText)) { + + Debug.Log($"[HttpApiClient] 응답 파싱 - Status: {request.responseCode}, Content-Length: {request.downloadHandler?.data?.Length ?? 0}"); + Debug.Log($"[HttpApiClient] 응답 텍스트: '{responseText}'"); + + if (string.IsNullOrEmpty(responseText)) + { Debug.LogWarning("[HttpApiClient] 응답 텍스트가 비어있습니다."); return default(T); } - try { + try + { return JsonConvert.DeserializeObject(responseText); } - catch (Exception ex) { + catch (Exception ex) + { Debug.LogError($"[HttpApiClient] JSON 파싱 실패: {ex.Message}"); - - if (IsErrorResponse(responseText)) { - Debug.LogWarning("[HttpApiClient] 서버에서 에러 응답을 받았습니다."); - throw new ApiException("서버 에러 응답", request.responseCode, responseText); - } - return TryFallbackParse(responseText, request.responseCode, ex); } } - private bool IsErrorResponse(string responseText) - { - try { - var jsonObject = JsonConvert.DeserializeObject>(responseText); - return jsonObject.ContainsKey("errorCode") && jsonObject.ContainsKey("message"); - } - catch { - return false; - } - } - private T TryFallbackParse(string responseText, long responseCode, Exception originalException) { - try { + try + { return JsonUtility.FromJson(responseText); } - catch (Exception fallbackEx) { + catch (Exception fallbackEx) + { throw new ApiException($"응답 파싱 실패: {originalException.Message} (폴백도 실패: {fallbackEx.Message})", responseCode, responseText); } } + /// + /// HTTP 응답 헤더 추출 + /// private Dictionary ExtractResponseHeaders(UnityWebRequest request) { var headers = new Dictionary(); - var headerNames = new[] { "X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-User-Id", "Content-Type", "Authorization" }; - - foreach (var headerName in headerNames) { - var value = request.GetResponseHeader(headerName); - if (!string.IsNullOrEmpty(value)) { - headers[headerName] = value; - } + + // UnityWebRequest에서 사용 가능한 헤더들 + var responseHeaders = request.GetResponseHeader("X-Access-Token"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Access-Token"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-Refresh-Token"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Refresh-Token"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-Expires-In"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-Expires-In"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("X-User-Id"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["X-User-Id"] = responseHeaders; + + // 기타 헤더들도 추가 가능 + responseHeaders = request.GetResponseHeader("Content-Type"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["Content-Type"] = responseHeaders; + + responseHeaders = request.GetResponseHeader("Authorization"); + if (!string.IsNullOrEmpty(responseHeaders)) + headers["Authorization"] = responseHeaders; + + Debug.Log($"[HttpApiClient] 응답 헤더 추출: {headers.Count}개 헤더"); + foreach (var header in headers) + { + Debug.Log($"[HttpApiClient] 헤더: {header.Key} = {header.Value}"); } - + return headers; } @@ -456,17 +566,16 @@ private bool ShouldRetry(long responseCode) { return responseCode >= 500 || responseCode == 429; } + #endregion } - #region Exceptions - public class ApiException : Exception { public long StatusCode { get; } public string ResponseBody { get; } - public ApiException(string message, long statusCode, string responseBody) + public ApiException(string message, long statusCode, string responseBody) : base(message) { StatusCode = statusCode; @@ -476,15 +585,14 @@ public ApiException(string message, long statusCode, string responseBody) public class FileSizeExceededException : ApiException { - public FileSizeExceededException(double fileSizeMB, double maxSizeMB) + public FileSizeExceededException(double fileSizeMB, double maxSizeMB) : base($"File size exceeds limit: {fileSizeMB:F2}MB (limit: {maxSizeMB:F2}MB)", 413, null) { FileSizeMB = fileSizeMB; MaxSizeMB = maxSizeMB; } - + public double FileSizeMB { get; } public double MaxSizeMB { get; } } - #endregion -} \ No newline at end of file +} \ No newline at end of file From b5b83427af2555f2343e341b5097d4f84ded3861 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 16:10:29 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 79e4aa00bf5e745d457f43915c1d716c806e587f. --- .../Auth/Examples/ServerOAuth2Example.cs | 196 ++++++++ .../Auth/OAuth2/ServerOAuth2Provider.cs | 17 +- Assets/Infrastructure/Auth/TokenManager.cs | 462 ++++++++++++++++++ .../Infrastructure/Auth/TokenManager.cs.meta | 2 + .../Auth/TokenRefreshService.cs | 247 ++++++++++ .../Auth/TokenRefreshService.cs.meta | 2 + .../Network/Http/HttpApiClient.cs | 26 + 7 files changed, 951 insertions(+), 1 deletion(-) create mode 100644 Assets/Infrastructure/Auth/TokenManager.cs create mode 100644 Assets/Infrastructure/Auth/TokenManager.cs.meta create mode 100644 Assets/Infrastructure/Auth/TokenRefreshService.cs create mode 100644 Assets/Infrastructure/Auth/TokenRefreshService.cs.meta diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs index 48b0b91..42b931a 100644 --- a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs @@ -17,8 +17,12 @@ public class ServerOAuth2Example : MonoBehaviour [Header("UI 컴포넌트")] [SerializeField] private Button loginButton; [SerializeField] private Button logoutButton; + [SerializeField] private Button refreshButton; + [SerializeField] private Button clearButton; + [SerializeField] private Button checkButton; [SerializeField] private TextMeshProUGUI statusText; [SerializeField] private TextMeshProUGUI userInfoText; + [SerializeField] private TextMeshProUGUI debugText; [Header("설정")] [SerializeField] private ServerOAuth2Config oauth2Config; @@ -27,6 +31,8 @@ public class ServerOAuth2Example : MonoBehaviour private ServerOAuth2Config OAuth2Config => oauth2Config ?? ServerOAuth2Config.Instance; private ServerOAuth2Provider _oauth2Provider; + private TokenManager _tokenManager; + private TokenRefreshService _refreshService; private TokenSet _currentTokenSet; private bool _isLoggedIn = false; @@ -35,6 +41,7 @@ public class ServerOAuth2Example : MonoBehaviour private void Start() { InitializeOAuth2Provider(); + InitializeTokenServices(); SetupEventHandlers(); UpdateUI(); } @@ -44,6 +51,23 @@ private void OnDestroy() // 이벤트 핸들러 해제 if (loginButton != null) loginButton.onClick.RemoveAllListeners(); if (logoutButton != null) logoutButton.onClick.RemoveAllListeners(); + if (refreshButton != null) refreshButton.onClick.RemoveAllListeners(); + if (clearButton != null) clearButton.onClick.RemoveAllListeners(); + if (checkButton != null) checkButton.onClick.RemoveAllListeners(); + + // Token 서비스 이벤트 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= OnTokensUpdated; + _tokenManager.OnTokensExpired -= OnTokensExpired; + _tokenManager.OnTokensCleared -= OnTokensCleared; + } + + if (_refreshService != null) + { + _refreshService.OnTokenRefreshed -= OnTokenRefreshed; + _refreshService.OnTokenRefreshFailed -= OnTokenRefreshFailed; + } } #endregion @@ -92,6 +116,21 @@ private void InitializeOAuth2Provider() Debug.Log(_oauth2Provider.GetDebugInfo()); } + private void InitializeTokenServices() + { + _tokenManager = TokenManager.Instance; + _refreshService = TokenRefreshService.Instance; + + // 이벤트 구독 + _tokenManager.OnTokensUpdated += OnTokensUpdated; + _tokenManager.OnTokensExpired += OnTokensExpired; + _tokenManager.OnTokensCleared += OnTokensCleared; + _refreshService.OnTokenRefreshed += OnTokenRefreshed; + _refreshService.OnTokenRefreshFailed += OnTokenRefreshFailed; + + Debug.Log("[ServerOAuth2Example] Token 서비스 초기화 완료"); + } + private void SetupEventHandlers() { if (loginButton != null) @@ -99,6 +138,15 @@ private void SetupEventHandlers() if (logoutButton != null) logoutButton.onClick.AddListener(() => _ = OnLogoutButtonClicked()); + + if (refreshButton != null) + refreshButton.onClick.AddListener(() => _ = OnRefreshButtonClicked()); + + if (clearButton != null) + clearButton.onClick.AddListener(OnClearButtonClicked); + + if (checkButton != null) + checkButton.onClick.AddListener(() => _ = OnCheckButtonClicked()); } #endregion @@ -170,6 +218,9 @@ private async UniTaskVoid OnLogoutButtonClicked() { ShowStatus("로그아웃 중...", Color.blue); + // TokenManager에서 토큰 정리 + _tokenManager.ClearTokens(); + // 토큰 정리 _currentTokenSet?.Clear(); _currentTokenSet = null; @@ -235,6 +286,124 @@ private async UniTaskVoid StartAutoTokenRefresh() #endregion + #region Token Management Buttons + + private async UniTaskVoid OnRefreshButtonClicked() + { + try + { + ShowStatus("토큰 갱신 시작...", Color.yellow); + + var success = await _refreshService.ForceRefreshAsync(); + + if (success) + { + ShowStatus("토큰 갱신 성공!", Color.green); + // 갱신된 토큰으로 현재 토큰 세트 업데이트 + _currentTokenSet = _tokenManager.LoadTokens(); + } + else + { + ShowStatus("토큰 갱신 실패", Color.red); + } + + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 갱신 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 갱신 실패: {ex.Message}"); + } + } + + private void OnClearButtonClicked() + { + try + { + _tokenManager.ClearTokens(); + _currentTokenSet = null; + _isLoggedIn = false; + ShowStatus("토큰 삭제 완료", Color.blue); + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 삭제 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 삭제 실패: {ex.Message}"); + } + } + + private async UniTaskVoid OnCheckButtonClicked() + { + try + { + ShowStatus("토큰 상태 확인 중...", Color.yellow); + + var hasValidToken = await _refreshService.EnsureValidTokenAsync(); + + if (hasValidToken) + { + ShowStatus("유효한 토큰이 있습니다.", Color.green); + // 유효한 토큰으로 현재 토큰 세트 업데이트 + _currentTokenSet = _tokenManager.LoadTokens(); + } + else + { + ShowStatus("유효한 토큰이 없습니다.", Color.red); + } + + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 확인 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 확인 실패: {ex.Message}"); + } + } + + #endregion + + #region Token Events + + private void OnTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[ServerOAuth2Example] 토큰 업데이트됨"); + _currentTokenSet = tokenSet; + UpdateUI(); + } + + private void OnTokensExpired() + { + Debug.Log("[ServerOAuth2Example] 토큰 만료됨"); + ShowStatus("토큰이 만료되었습니다.", Color.red); + UpdateUI(); + } + + private void OnTokensCleared() + { + Debug.Log("[ServerOAuth2Example] 토큰 삭제됨"); + _currentTokenSet = null; + _isLoggedIn = false; + ShowStatus("토큰이 삭제되었습니다.", Color.blue); + UpdateUI(); + } + + private void OnTokenRefreshed(string newAccessToken) + { + Debug.Log("[ServerOAuth2Example] 토큰 갱신 성공"); + ShowStatus("토큰 갱신 성공!", Color.green); + UpdateUI(); + } + + private void OnTokenRefreshFailed(string error) + { + Debug.Log($"[ServerOAuth2Example] 토큰 갱신 실패: {error}"); + ShowStatus($"토큰 갱신 실패: {error}", Color.red); + UpdateUI(); + } + + #endregion + #region UI Management private void UpdateUI() @@ -244,12 +413,24 @@ private void UpdateUI() if (logoutButton != null) logoutButton.interactable = _isLoggedIn; + + if (refreshButton != null) + refreshButton.interactable = _isLoggedIn; + + if (clearButton != null) + clearButton.interactable = _isLoggedIn; + + if (checkButton != null) + checkButton.interactable = true; // 항상 활성화 } private void SetButtonsEnabled(bool enabled) { if (loginButton != null) loginButton.interactable = enabled && !_isLoggedIn; if (logoutButton != null) logoutButton.interactable = enabled && _isLoggedIn; + if (refreshButton != null) refreshButton.interactable = enabled && _isLoggedIn; + if (clearButton != null) clearButton.interactable = enabled && _isLoggedIn; + if (checkButton != null) checkButton.interactable = enabled; } private void ShowStatus(string message, Color color) @@ -276,6 +457,16 @@ private void DisplayTokenInfo() info += $"갱신 필요: {_currentTokenSet.NeedsRefresh()}"; userInfoText.text = info; + + // 디버그 정보도 업데이트 + if (debugText != null) + { + var debugInfo = "=== TokenManager 상태 ===\n"; + debugInfo += _tokenManager.GetDebugInfo(); + debugInfo += "\n=== TokenRefreshService 상태 ===\n"; + debugInfo += _refreshService.GetDebugInfo(); + debugText.text = debugInfo; + } } /// @@ -333,6 +524,11 @@ private void ClearUserInfo() { userInfoText.text = "로그인하여 토큰 정보를 확인하세요."; } + + if (debugText != null) + { + debugText.text = "토큰 정보가 없습니다."; + } } #endregion diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs index c71181a..fdae560 100644 --- a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -269,7 +269,7 @@ public async Task RequestTokenAsync(string state) // 3. TokenSet 생성 var accessTokenModel = new AccessToken(accessToken, expiresIn, "Bearer", "oauth2"); var refreshTokenModel = !string.IsNullOrEmpty(refreshToken) - ? new RefreshToken(refreshToken, expiresIn * 2, userId) + ? new RefreshToken(refreshToken, expiresIn * 2, userId) // userId를 DeviceId로 사용 : null; var tokenSet = new TokenSet(accessTokenModel, refreshTokenModel); @@ -284,6 +284,21 @@ public async Task RequestTokenAsync(string state) Debug.Log($"User ID: {userId}"); Debug.Log("=== 토큰 수신 완료 ==="); + // TokenManager에 토큰 저장 + try + { + Debug.Log("=== 🔐 ServerOAuth2Provider TokenManager 저장 시작 ==="); + var tokenManager = TokenManager.Instance; + tokenManager.SaveTokens(tokenSet); + Debug.Log("[ServerOAuth2Provider] TokenManager에 토큰 저장 완료"); + Debug.Log("=== TokenManager 저장 완료 ==="); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] TokenManager 토큰 저장 실패: {ex.Message}"); + // 토큰 저장 실패해도 토큰은 반환 (사용자가 직접 저장할 수 있도록) + } + return tokenSet; } catch (Exception ex) diff --git a/Assets/Infrastructure/Auth/TokenManager.cs b/Assets/Infrastructure/Auth/TokenManager.cs new file mode 100644 index 0000000..d3a82e3 --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenManager.cs @@ -0,0 +1,462 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; +using ProjectVG.Infrastructure.Auth.Models; +using Newtonsoft.Json; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Auth +{ + /// + /// OAuth2 토큰 관리자 + /// Access 토큰과 Refresh 토큰을 안전하게 저장하고 관리 + /// + public class TokenManager : MonoBehaviour + { + private const string ACCESS_TOKEN_KEY = "oauth2_access_token"; + private const string REFRESH_TOKEN_KEY = "oauth2_refresh_token"; + private const string TOKEN_EXPIRY_KEY = "oauth2_token_expiry"; + private const string USER_ID_KEY = "oauth2_user_id"; + private const string ENCRYPTION_KEY = "ProjectVG_OAuth2_Secure_Key_2024"; + + private static TokenManager _instance; + public static TokenManager Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("TokenManager"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private AccessToken _currentAccessToken; + private RefreshToken _currentRefreshToken; + private string _currentUserId; + + public event Action OnTokensUpdated; + public event Action OnTokensExpired; + public event Action OnTokensCleared; + + public bool HasValidTokens => _currentAccessToken != null && !_currentAccessToken.IsExpired(); + public bool HasRefreshToken => _currentRefreshToken != null; + public string CurrentUserId => _currentUserId; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + LoadTokensFromStorage(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + /// + /// 토큰 세트 저장 + /// + public void SaveTokens(TokenSet tokenSet) + { + if (tokenSet?.AccessToken == null) + { + Debug.LogWarning("[TokenManager] 저장할 Access Token이 없습니다."); + return; + } + + try + { + _currentAccessToken = tokenSet.AccessToken; + _currentRefreshToken = tokenSet.RefreshToken; + _currentUserId = tokenSet.RefreshToken?.DeviceId; + + // Access Token 저장 (암호화) + var accessTokenData = new TokenStorageData + { + Token = _currentAccessToken.Token, + ExpiresAt = _currentAccessToken.ExpiresAt, + TokenType = _currentAccessToken.TokenType, + Scope = _currentAccessToken.Scope + }; + var encryptedAccessToken = EncryptData(JsonConvert.SerializeObject(accessTokenData)); + PlayerPrefs.SetString(ACCESS_TOKEN_KEY, encryptedAccessToken); + + // Refresh Token 저장 (강력한 암호화) + string encryptedRefreshToken = null; + if (_currentRefreshToken != null) + { + var refreshTokenData = new TokenStorageData + { + Token = _currentRefreshToken.Token, + ExpiresAt = _currentRefreshToken.ExpiresAt, + TokenType = "Refresh", + Scope = "oauth2", + UserId = _currentRefreshToken.DeviceId + }; + encryptedRefreshToken = EncryptData(JsonConvert.SerializeObject(refreshTokenData)); + PlayerPrefs.SetString(REFRESH_TOKEN_KEY, encryptedRefreshToken); + } + else + { + PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); + } + + // 만료 시간 저장 + PlayerPrefs.SetString(TOKEN_EXPIRY_KEY, _currentAccessToken.ExpiresAt.ToString("O")); + + // User ID 저장 + if (!string.IsNullOrEmpty(_currentUserId)) + { + PlayerPrefs.SetString(USER_ID_KEY, _currentUserId); + } + + PlayerPrefs.Save(); + + Debug.Log("=== 🔐 TokenManager 토큰 저장 완료 ==="); + Debug.Log($"[TokenManager] Access Token 저장 위치: PlayerPrefs['{ACCESS_TOKEN_KEY}']"); + Debug.Log($"[TokenManager] Access Token 만료: {_currentAccessToken.ExpiresAt}"); + Debug.Log($"[TokenManager] Access Token 암호화: {!string.IsNullOrEmpty(encryptedAccessToken)}"); + + if (_currentRefreshToken != null) + { + Debug.Log($"[TokenManager] Refresh Token 저장 위치: PlayerPrefs['{REFRESH_TOKEN_KEY}']"); + Debug.Log($"[TokenManager] Refresh Token 만료: {_currentRefreshToken.ExpiresAt}"); + Debug.Log($"[TokenManager] Refresh Token 암호화: {!string.IsNullOrEmpty(encryptedRefreshToken)}"); + } + else + { + Debug.Log("[TokenManager] Refresh Token: 없음"); + } + + Debug.Log($"[TokenManager] User ID 저장 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); + Debug.Log($"[TokenManager] 만료 시간 저장 위치: PlayerPrefs['{TOKEN_EXPIRY_KEY}'] = {_currentAccessToken.ExpiresAt:O}"); + Debug.Log("=== 토큰 저장 완료 ==="); + + OnTokensUpdated?.Invoke(tokenSet); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 저장 실패: {ex.Message}"); + throw; + } + } + + /// + /// 저장된 토큰 로드 + /// + public TokenSet LoadTokens() + { + if (_currentAccessToken != null && _currentRefreshToken != null) + { + return new TokenSet(_currentAccessToken, _currentRefreshToken); + } + + LoadTokensFromStorage(); + + if (_currentAccessToken != null && _currentRefreshToken != null) + { + return new TokenSet(_currentAccessToken, _currentRefreshToken); + } + + return null; + } + + /// + /// Access Token만 반환 + /// + public string GetAccessToken() + { + if (_currentAccessToken != null && !_currentAccessToken.IsExpired()) + { + return _currentAccessToken.Token; + } + + // 만료된 경우 Refresh Token으로 갱신 시도 + if (_currentRefreshToken != null && !_currentRefreshToken.IsExpired()) + { + Debug.Log("[TokenManager] Access Token이 만료되었습니다. Refresh Token으로 갱신을 시도하세요."); + OnTokensExpired?.Invoke(); + } + + return null; + } + + /// + /// Refresh Token 반환 + /// + public string GetRefreshToken() + { + return _currentRefreshToken?.Token; + } + + /// + /// 토큰 만료 여부 확인 + /// + public bool IsAccessTokenExpired() + { + return _currentAccessToken?.IsExpired() ?? true; + } + + /// + /// Refresh Token 만료 여부 확인 + /// + public bool IsRefreshTokenExpired() + { + return _currentRefreshToken?.IsExpired() ?? true; + } + + /// + /// 토큰 갱신 + /// + public void UpdateAccessToken(string newAccessToken, int expiresInSeconds) + { + if (string.IsNullOrEmpty(newAccessToken)) + { + Debug.LogError("[TokenManager] 새로운 Access Token이 비어있습니다."); + return; + } + + try + { + _currentAccessToken = new AccessToken(newAccessToken, expiresInSeconds, "Bearer", "oauth2"); + + // Access Token만 업데이트 + var accessTokenData = new TokenStorageData + { + Token = _currentAccessToken.Token, + ExpiresAt = _currentAccessToken.ExpiresAt, + TokenType = _currentAccessToken.TokenType, + Scope = _currentAccessToken.Scope + }; + var encryptedAccessToken = EncryptData(JsonConvert.SerializeObject(accessTokenData)); + PlayerPrefs.SetString(ACCESS_TOKEN_KEY, encryptedAccessToken); + PlayerPrefs.SetString(TOKEN_EXPIRY_KEY, _currentAccessToken.ExpiresAt.ToString("O")); + PlayerPrefs.Save(); + + Debug.Log("[TokenManager] Access Token 갱신 완료"); + Debug.Log($"[TokenManager] 새로운 만료 시간: {_currentAccessToken.ExpiresAt}"); + + var tokenSet = new TokenSet(_currentAccessToken, _currentRefreshToken); + OnTokensUpdated?.Invoke(tokenSet); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] Access Token 갱신 실패: {ex.Message}"); + throw; + } + } + + /// + /// 모든 토큰 삭제 + /// + public void ClearTokens() + { + try + { + _currentAccessToken = null; + _currentRefreshToken = null; + _currentUserId = null; + + PlayerPrefs.DeleteKey(ACCESS_TOKEN_KEY); + PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); + PlayerPrefs.DeleteKey(TOKEN_EXPIRY_KEY); + PlayerPrefs.DeleteKey(USER_ID_KEY); + PlayerPrefs.Save(); + + Debug.Log("[TokenManager] 모든 토큰 삭제 완료"); + OnTokensCleared?.Invoke(); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 삭제 실패: {ex.Message}"); + throw; + } + } + + /// + /// 저장소에서 토큰 로드 + /// + private void LoadTokensFromStorage() + { + try + { + // Access Token 로드 + if (PlayerPrefs.HasKey(ACCESS_TOKEN_KEY)) + { + var encryptedAccessToken = PlayerPrefs.GetString(ACCESS_TOKEN_KEY); + var decryptedAccessToken = DecryptData(encryptedAccessToken); + var accessTokenData = JsonConvert.DeserializeObject(decryptedAccessToken); + + _currentAccessToken = new AccessToken( + accessTokenData.Token, + accessTokenData.ExpiresAt, + accessTokenData.TokenType, + accessTokenData.Scope + ); + } + + // Refresh Token 로드 + if (PlayerPrefs.HasKey(REFRESH_TOKEN_KEY)) + { + var encryptedRefreshToken = PlayerPrefs.GetString(REFRESH_TOKEN_KEY); + var decryptedRefreshToken = DecryptData(encryptedRefreshToken); + var refreshTokenData = JsonConvert.DeserializeObject(decryptedRefreshToken); + + _currentRefreshToken = new RefreshToken( + refreshTokenData.Token, + refreshTokenData.ExpiresAt, + refreshTokenData.UserId // UserId 필드에 DeviceId가 저장되어 있음 + ); + } + + // User ID 로드 + if (PlayerPrefs.HasKey(USER_ID_KEY)) + { + _currentUserId = PlayerPrefs.GetString(USER_ID_KEY); + } + + Debug.Log("=== 🔍 TokenManager 저장소에서 토큰 로드 완료 ==="); + Debug.Log($"[TokenManager] Access Token 로드 위치: PlayerPrefs['{ACCESS_TOKEN_KEY}']"); + Debug.Log($"[TokenManager] Access Token 존재: {_currentAccessToken != null}"); + if (_currentAccessToken != null) + { + Debug.Log($"[TokenManager] Access Token 만료: {_currentAccessToken.ExpiresAt}"); + Debug.Log($"[TokenManager] Access Token 유효: {!_currentAccessToken.IsExpired()}"); + } + + Debug.Log($"[TokenManager] Refresh Token 로드 위치: PlayerPrefs['{REFRESH_TOKEN_KEY}']"); + Debug.Log($"[TokenManager] Refresh Token 존재: {_currentRefreshToken != null}"); + if (_currentRefreshToken != null) + { + Debug.Log($"[TokenManager] Refresh Token 만료: {_currentRefreshToken.ExpiresAt}"); + Debug.Log($"[TokenManager] Refresh Token 유효: {!_currentRefreshToken.IsExpired()}"); + } + + Debug.Log($"[TokenManager] User ID 로드 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); + Debug.Log($"[TokenManager] 만료 시간 로드 위치: PlayerPrefs['{TOKEN_EXPIRY_KEY}']"); + Debug.Log("=== 토큰 로드 완료 ==="); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 로드 실패: {ex.Message}"); + // 로드 실패 시 모든 토큰 삭제 + ClearTokens(); + } + } + + /// + /// 데이터 암호화 + /// + private string EncryptData(string data) + { + try + { + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(ENCRYPTION_KEY.PadRight(32, '0').Substring(0, 32)); + aes.IV = new byte[16]; + + using (var encryptor = aes.CreateEncryptor()) + using (var ms = new System.IO.MemoryStream()) + { + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + using (var sw = new System.IO.StreamWriter(cs)) + { + sw.Write(data); + } + return Convert.ToBase64String(ms.ToArray()); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 암호화 실패: {ex.Message}"); + throw; + } + } + + /// + /// 데이터 복호화 + /// + private string DecryptData(string encryptedData) + { + try + { + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(ENCRYPTION_KEY.PadRight(32, '0').Substring(0, 32)); + aes.IV = new byte[16]; + + using (var decryptor = aes.CreateDecryptor()) + using (var ms = new System.IO.MemoryStream(Convert.FromBase64String(encryptedData))) + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + using (var sr = new System.IO.StreamReader(cs)) + { + return sr.ReadToEnd(); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 복호화 실패: {ex.Message}"); + throw; + } + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = "TokenManager Debug Info:\n"; + info += $"=== 저장 위치 정보 ===\n"; + info += $"Access Token 저장: PlayerPrefs['{ACCESS_TOKEN_KEY}']\n"; + info += $"Refresh Token 저장: PlayerPrefs['{REFRESH_TOKEN_KEY}']\n"; + info += $"User ID 저장: PlayerPrefs['{USER_ID_KEY}']\n"; + info += $"만료 시간 저장: PlayerPrefs['{TOKEN_EXPIRY_KEY}']\n"; + info += $"=== 토큰 상태 ===\n"; + info += $"Has Valid Tokens: {HasValidTokens}\n"; + info += $"Has Refresh Token: {HasRefreshToken}\n"; + info += $"Access Token Expired: {IsAccessTokenExpired()}\n"; + info += $"Refresh Token Expired: {IsRefreshTokenExpired()}\n"; + info += $"User ID: {_currentUserId}\n"; + + if (_currentAccessToken != null) + { + info += $"Access Token Expires: {_currentAccessToken.ExpiresAt}\n"; + info += $"Access Token Type: {_currentAccessToken.TokenType}\n"; + info += $"Access Token 유효: {!_currentAccessToken.IsExpired()}\n"; + } + + if (_currentRefreshToken != null) + { + info += $"Refresh Token Expires: {_currentRefreshToken.ExpiresAt}\n"; + info += $"Refresh Token Device ID: {_currentRefreshToken.DeviceId}\n"; + info += $"Refresh Token 유효: {!_currentRefreshToken.IsExpired()}\n"; + } + + return info; + } + } + + /// + /// 토큰 저장용 데이터 구조 + /// + [Serializable] + public class TokenStorageData + { + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public string TokenType { get; set; } + public string Scope { get; set; } + public string UserId { get; set; } + } +} diff --git a/Assets/Infrastructure/Auth/TokenManager.cs.meta b/Assets/Infrastructure/Auth/TokenManager.cs.meta new file mode 100644 index 0000000..de7c45e --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af60fb15aa1dc0947aae32b10c6583e6 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs b/Assets/Infrastructure/Auth/TokenRefreshService.cs new file mode 100644 index 0000000..dfc5f43 --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs @@ -0,0 +1,247 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.OAuth2; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Network.Http; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth +{ + /// + /// 토큰 갱신 서비스 + /// Refresh Token을 사용하여 Access Token을 자동으로 갱신 + /// + public class TokenRefreshService : MonoBehaviour + { + private static TokenRefreshService _instance; + public static TokenRefreshService Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("TokenRefreshService"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private TokenManager _tokenManager; + private ServerOAuth2Provider _oauth2Provider; + private bool _isRefreshing = false; + + public event Action OnTokenRefreshed; + public event Action OnTokenRefreshFailed; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + Initialize(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private void Initialize() + { + _tokenManager = TokenManager.Instance; + + // TokenManager 이벤트 구독 + _tokenManager.OnTokensExpired += HandleTokensExpired; + + Debug.Log("[TokenRefreshService] 초기화 완료"); + } + + /// + /// 토큰 만료 시 자동 갱신 시도 + /// + private async void HandleTokensExpired() + { + Debug.Log("[TokenRefreshService] 토큰 만료 감지 - 갱신 시도"); + await RefreshAccessTokenAsync(); + } + + /// + /// Access Token 갱신 + /// + public async UniTask RefreshAccessTokenAsync() + { + if (_isRefreshing) + { + Debug.Log("[TokenRefreshService] 이미 토큰 갱신 중입니다."); + return false; + } + + if (_tokenManager.IsRefreshTokenExpired()) + { + Debug.LogError("[TokenRefreshService] Refresh Token이 만료되었습니다. 재로그인이 필요합니다."); + OnTokenRefreshFailed?.Invoke("Refresh Token이 만료되었습니다."); + return false; + } + + _isRefreshing = true; + + try + { + Debug.Log("[TokenRefreshService] Access Token 갱신 시작"); + + // OAuth2 Provider 초기화 (필요한 경우) + if (_oauth2Provider == null) + { + var config = ServerOAuth2Config.Instance; + if (config == null) + { + throw new InvalidOperationException("ServerOAuth2Config를 찾을 수 없습니다."); + } + _oauth2Provider = new ServerOAuth2Provider(config); + } + + // Refresh Token으로 새로운 Access Token 요청 + var refreshToken = _tokenManager.GetRefreshToken(); + if (string.IsNullOrEmpty(refreshToken)) + { + throw new InvalidOperationException("Refresh Token이 없습니다."); + } + + // 서버에 토큰 갱신 요청 + var newTokenSet = await RequestTokenRefreshAsync(refreshToken); + + if (newTokenSet?.AccessToken != null) + { + // 새로운 토큰 저장 + _tokenManager.SaveTokens(newTokenSet); + + Debug.Log("[TokenRefreshService] Access Token 갱신 성공"); + OnTokenRefreshed?.Invoke(newTokenSet.AccessToken.Token); + return true; + } + else + { + throw new InvalidOperationException("서버에서 새로운 토큰을 받지 못했습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenRefreshService] Access Token 갱신 실패: {ex.Message}"); + OnTokenRefreshFailed?.Invoke(ex.Message); + return false; + } + finally + { + _isRefreshing = false; + } + } + + /// + /// 서버에 토큰 갱신 요청 + /// + private async UniTask RequestTokenRefreshAsync(string refreshToken) + { + try + { + var httpClient = HttpApiClient.Instance; + + // 서버 토큰 갱신 엔드포인트 호출 + var refreshRequest = new + { + refresh_token = refreshToken, + grant_type = "refresh_token" + }; + + var response = await httpClient.PostAsync( + "/auth/oauth2/refresh", + refreshRequest + ); + + if (response == null || !response.Success) + { + throw new InvalidOperationException($"토큰 갱신 실패: {response?.Message ?? "응답이 null입니다."}"); + } + + // 새로운 토큰 생성 + var accessToken = new AccessToken( + response.AccessToken, + response.ExpiresIn, + "Bearer", + "oauth2" + ); + + var newRefreshToken = !string.IsNullOrEmpty(response.RefreshToken) + ? new RefreshToken(response.RefreshToken, response.ExpiresIn * 2, response.UserId) // UserId를 DeviceId로 사용 + : null; + + return new TokenSet(accessToken, newRefreshToken); + } + catch (Exception ex) + { + Debug.LogError($"[TokenRefreshService] 서버 토큰 갱신 요청 실패: {ex.Message}"); + throw; + } + } + + /// + /// 토큰 갱신 상태 확인 + /// + public bool IsRefreshing => _isRefreshing; + + /// + /// 강제 토큰 갱신 (사용자가 직접 호출) + /// + public async UniTask ForceRefreshAsync() + { + Debug.Log("[TokenRefreshService] 강제 토큰 갱신 시작"); + return await RefreshAccessTokenAsync(); + } + + /// + /// 토큰 상태 확인 및 필요시 갱신 + /// + public async UniTask EnsureValidTokenAsync() + { + if (_tokenManager.HasValidTokens) + { + return true; + } + + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + return await RefreshAccessTokenAsync(); + } + + Debug.LogWarning("[TokenRefreshService] 유효한 토큰이 없고 Refresh Token도 만료되었습니다."); + return false; + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = "TokenRefreshService Debug Info:\n"; + info += $"Is Refreshing: {_isRefreshing}\n"; + info += $"Has Valid Tokens: {_tokenManager.HasValidTokens}\n"; + info += $"Has Refresh Token: {_tokenManager.HasRefreshToken}\n"; + info += $"Access Token Expired: {_tokenManager.IsAccessTokenExpired()}\n"; + info += $"Refresh Token Expired: {_tokenManager.IsRefreshTokenExpired()}\n"; + return info; + } + + private void OnDestroy() + { + if (_tokenManager != null) + { + _tokenManager.OnTokensExpired -= HandleTokensExpired; + } + } + } +} diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta b/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta new file mode 100644 index 0000000..056bb3e --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 19627176d6dfc384a8a5a4f76bdda34f \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index 2cd3294..d9abbb9 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -71,6 +71,32 @@ public void SetAuthToken(string token) AddDefaultHeader(AUTHORIZATION_HEADER, $"{BEARER_PREFIX}{token}"); } + /// + /// TokenManager에서 자동으로 토큰 설정 + /// + public void SetAuthTokenFromManager() + { + try + { + var tokenManager = ProjectVG.Infrastructure.Auth.TokenManager.Instance; + var accessToken = tokenManager.GetAccessToken(); + + if (!string.IsNullOrEmpty(accessToken)) + { + SetAuthToken(accessToken); + Debug.Log("[HttpApiClient] TokenManager에서 Access Token 설정 완료"); + } + else + { + Debug.LogWarning("[HttpApiClient] TokenManager에서 유효한 Access Token을 찾을 수 없습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[HttpApiClient] TokenManager에서 토큰 설정 실패: {ex.Message}"); + } + } + public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) { // 자동 초기화 From 10efc01198533065960632f0657b0bf789b05365 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 24 Aug 2025 16:17:01 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20HTTP=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=86=A0=ED=81=B0=20=ED=8F=AC=ED=95=A8=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/OAuth2/ServerOAuth2Provider.cs | 8 +- .../Auth/TokenRefreshService.cs | 3 +- .../Network/Http/HttpApiClient.cs | 92 +++++++++++++++++-- .../Network/Services/ChatApiService.cs | 2 +- .../Network/Services/STTService.cs | 2 +- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs index fdae560..967ad28 100644 --- a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -130,8 +130,8 @@ public async Task StartServerOAuth2Async(PKCEParameters pkce, string sco Debug.Log($"[ServerOAuth2Provider] 최종 URL: {authorizeUrl}"); - // 3. 서버 API 호출 (GET 요청) - var response = await _httpClient.GetAsync(authorizeUrl); + // 3. 서버 API 호출 (GET 요청) - 인증 불필요 + var response = await _httpClient.GetAsync(authorizeUrl, requiresAuth: false); if (response == null) { @@ -227,9 +227,9 @@ public async Task RequestTokenAsync(string state) { Debug.Log($"[ServerOAuth2Provider] 토큰 요청 시작 - State: {state}"); - // 1. 서버 토큰 API 호출 (HTTP 헤더 포함) + // 1. 서버 토큰 API 호출 (HTTP 헤더 포함) - 인증 불필요 var tokenUrl = $"{_config.ServerUrl}/auth/oauth2/token?state={Uri.EscapeDataString(state)}"; - var (response, headers) = await _httpClient.GetWithHeadersAsync(tokenUrl); + var (response, headers) = await _httpClient.GetWithHeadersAsync(tokenUrl, requiresAuth: false); if (response == null) { diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs b/Assets/Infrastructure/Auth/TokenRefreshService.cs index dfc5f43..7a04985 100644 --- a/Assets/Infrastructure/Auth/TokenRefreshService.cs +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs @@ -160,7 +160,8 @@ private async UniTask RequestTokenRefreshAsync(string refreshToken) var response = await httpClient.PostAsync( "/auth/oauth2/refresh", - refreshRequest + refreshRequest, + requiresAuth: false // 토큰 갱신은 인증 불필요 ); if (response == null || !response.Success) diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index d9abbb9..5d60e0d 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -63,6 +63,18 @@ public void AddDefaultHeader(string key, string value) defaultHeaders[key] = value; } + /// + /// 기본 헤더 제거 + /// + public void RemoveDefaultHeader(string key) + { + if (defaultHeaders.ContainsKey(key)) + { + defaultHeaders.Remove(key); + Debug.Log($"[HttpApiClient] 기본 헤더 제거: {key}"); + } + } + /// /// 인증 토큰 설정 /// @@ -78,26 +90,31 @@ public void SetAuthTokenFromManager() { try { + Debug.Log("[HttpApiClient] TokenManager에서 Access Token 요청 중..."); var tokenManager = ProjectVG.Infrastructure.Auth.TokenManager.Instance; var accessToken = tokenManager.GetAccessToken(); if (!string.IsNullOrEmpty(accessToken)) { SetAuthToken(accessToken); - Debug.Log("[HttpApiClient] TokenManager에서 Access Token 설정 완료"); + Debug.Log($"[HttpApiClient] TokenManager에서 Access Token 설정 완료 (토큰 길이: {accessToken.Length})"); } else { Debug.LogWarning("[HttpApiClient] TokenManager에서 유효한 Access Token을 찾을 수 없습니다."); + // Authorization 헤더 제거 + RemoveDefaultHeader(AUTHORIZATION_HEADER); } } catch (Exception ex) { Debug.LogError($"[HttpApiClient] TokenManager에서 토큰 설정 실패: {ex.Message}"); + // Authorization 헤더 제거 + RemoveDefaultHeader(AUTHORIZATION_HEADER); } } - public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask GetAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { // 자동 초기화 if (!IsInitialized) @@ -106,8 +123,14 @@ public async UniTask GetAsync(string endpoint, Dictionary Initialize(null); } + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] GET 요청 시작: {url}"); + Debug.Log($"[HttpApiClient] GET 요청 시작: {url} (인증 필요: {requiresAuth})"); Debug.Log($"[HttpApiClient] 헤더: {JsonConvert.SerializeObject(headers)}"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } @@ -115,34 +138,68 @@ public async UniTask GetAsync(string endpoint, Dictionary /// /// GET 요청 (HTTP 헤더 포함 응답) /// - public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + Debug.Log($"[HttpApiClient] GET With Headers 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } - public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) + public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); + Debug.Log($"[HttpApiClient] POST 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); } - public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) + public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = GetFullUrl(endpoint); var jsonData = SerializeData(data, requiresSession); + Debug.Log($"[HttpApiClient] PUT 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPUT, jsonData, headers, cancellationToken); } - public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = GetFullUrl(endpoint); + Debug.Log($"[HttpApiClient] DELETE 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbDELETE, null, headers, cancellationToken); } - public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = GetFullUrl(endpoint); var formData = new Dictionary { @@ -152,17 +209,31 @@ public async UniTask UploadFileAsync(string endpoint, byte[] fileData, str { { fieldName, fileName } }; + Debug.Log($"[HttpApiClient] Upload File 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } - public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + Debug.Log($"[HttpApiClient] Post Form Data 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendFormDataRequestAsync(url, formData, null, headers, cancellationToken); } - public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { + // 인증이 필요한 경우 TokenManager에서 토큰 설정 + if (requiresAuth) + { + SetAuthTokenFromManager(); + } + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); // 파일 크기 검사 @@ -182,6 +253,7 @@ public async UniTask PostFormDataAsync(string endpoint, Dictionary(url, formData, fileNames, headers, cancellationToken); } diff --git a/Assets/Infrastructure/Network/Services/ChatApiService.cs b/Assets/Infrastructure/Network/Services/ChatApiService.cs index b10a2c1..dd8bced 100644 --- a/Assets/Infrastructure/Network/Services/ChatApiService.cs +++ b/Assets/Infrastructure/Network/Services/ChatApiService.cs @@ -37,7 +37,7 @@ public async UniTask SendChatAsync(ChatRequest request, Cancellati var serverRequest = CreateServerRequest(request); LogRequestDetails(serverRequest); - return await _httpClient.PostAsync(CHAT_ENDPOINT, serverRequest, requiresSession: true, cancellationToken: cancellationToken); + return await _httpClient.PostAsync(CHAT_ENDPOINT, serverRequest, requiresSession: true, requiresAuth: true, cancellationToken: cancellationToken); } /// diff --git a/Assets/Infrastructure/Network/Services/STTService.cs b/Assets/Infrastructure/Network/Services/STTService.cs index 3284d77..00549d8 100644 --- a/Assets/Infrastructure/Network/Services/STTService.cs +++ b/Assets/Infrastructure/Network/Services/STTService.cs @@ -67,7 +67,7 @@ public async UniTask ConvertSpeechToTextAsync(byte[] audioData, string a string forcedLanguage = "ko"; string endpoint = $"stt/transcribe?language={forcedLanguage}"; - var response = await _httpClient.PostFormDataAsync(endpoint, formData, fileNames, cancellationToken: cancellationToken); + var response = await _httpClient.PostFormDataAsync(endpoint, formData, fileNames, null, requiresAuth: false, cancellationToken: cancellationToken); if (response != null && !string.IsNullOrEmpty(response.Text)) { From 094f04b9a859961ee06f7ca0ac4905859c8425e0 Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 25 Aug 2025 16:20:50 +0900 Subject: [PATCH 07/14] =?UTF-8?q?crone:=20=EB=B9=8C=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + Assets/App/Scenes/MainScene.unity | 413 ++++++++++++++++++------------ 2 files changed, 265 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index fab0a65..7f08da6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,15 @@ ExportedObj/ *.VC.db *.vsconfig +# Claude AI related files +CLAUDE.md +claude.md +*CLAUDE.md +*claude.md +.claude/ +claude-* +*claude* + # Unity 메타 파일 *.pidb.meta *.pdb.meta diff --git a/Assets/App/Scenes/MainScene.unity b/Assets/App/Scenes/MainScene.unity index 163e514..7524bb3 100644 --- a/Assets/App/Scenes/MainScene.unity +++ b/Assets/App/Scenes/MainScene.unity @@ -130,6 +130,127 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 1aa08ab6e0800fa44ae55d278d1423e3, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &73955217 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 73955221} + - component: {fileID: 73955220} + - component: {fileID: 73955219} + - component: {fileID: 73955218} + m_Layer: 5 + m_Name: Button + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &73955218 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 73955219} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &73955219 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!222 &73955220 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_CullTransparentMesh: 1 +--- !u!224 &73955221 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1394009831} + m_Father: {fileID: 149056741} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: -1106, y: 40} + m_SizeDelta: {x: 160, y: 233.5773} + m_Pivot: {x: 0.5, y: 0.5} --- !u!1 &133449153 GameObject: m_ObjectHideFlags: 0 @@ -209,6 +330,51 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &149056740 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 149056741} + - component: {fileID: 149056742} + m_Layer: 5 + m_Name: Image + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &149056741 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 149056740} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 73955221} + m_Father: {fileID: 1145326587} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &149056742 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 149056740} + m_CullTransparentMesh: 1 --- !u!1 &322172995 GameObject: m_ObjectHideFlags: 0 @@ -407,6 +573,59 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &364439184 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 364439186} + - component: {fileID: 364439185} + m_Layer: 0 + m_Name: GameObject + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &364439185 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 59d58c6b995b5494abd828406d93edee, type: 3} + m_Name: + m_EditorClassIdentifier: + loginButton: {fileID: 73955218} + logoutButton: {fileID: 0} + refreshButton: {fileID: 0} + clearButton: {fileID: 0} + checkButton: {fileID: 0} + statusText: {fileID: 0} + userInfoText: {fileID: 0} + debugText: {fileID: 0} + oauth2Config: {fileID: 11400000, guid: e3e96e5220c979744a485156e5e314cd, type: 2} +--- !u!4 &364439186 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 647.5694, y: 818.4147, z: 8.117435} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &396605756 stripped GameObject: m_CorrespondingSourceObject: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} @@ -632,127 +851,6 @@ Transform: - {fileID: 920517455} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &642672887 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 642672888} - - component: {fileID: 642672891} - - component: {fileID: 642672890} - - component: {fileID: 642672889} - m_Layer: 5 - m_Name: Button - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!224 &642672888 -RectTransform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 642672887} - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1485874732} - m_Father: {fileID: 1145326587} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 1, y: 1} - m_AnchoredPosition: {x: 0, y: -104.28076} - m_SizeDelta: {x: 0, y: 208.5618} - m_Pivot: {x: 0.5, y: 0.5} ---- !u!114 &642672889 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 642672887} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} - m_Name: - m_EditorClassIdentifier: - m_Navigation: - m_Mode: 3 - m_WrapAround: 0 - m_SelectOnUp: {fileID: 0} - m_SelectOnDown: {fileID: 0} - m_SelectOnLeft: {fileID: 0} - m_SelectOnRight: {fileID: 0} - m_Transition: 1 - m_Colors: - m_NormalColor: {r: 1, g: 1, b: 1, a: 1} - m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} - m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} - m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} - m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} - m_ColorMultiplier: 1 - m_FadeDuration: 0.1 - m_SpriteState: - m_HighlightedSprite: {fileID: 0} - m_PressedSprite: {fileID: 0} - m_SelectedSprite: {fileID: 0} - m_DisabledSprite: {fileID: 0} - m_AnimationTriggers: - m_NormalTrigger: Normal - m_HighlightedTrigger: Highlighted - m_PressedTrigger: Pressed - m_SelectedTrigger: Selected - m_DisabledTrigger: Disabled - m_Interactable: 1 - m_TargetGraphic: {fileID: 642672890} - m_OnClick: - m_PersistentCalls: - m_Calls: [] ---- !u!114 &642672890 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 642672887} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} - m_Name: - m_EditorClassIdentifier: - m_Material: {fileID: 0} - m_Color: {r: 1, g: 1, b: 1, a: 1} - m_RaycastTarget: 1 - m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} - m_Maskable: 1 - m_OnCullStateChanged: - m_PersistentCalls: - m_Calls: [] - m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} - m_Type: 1 - m_PreserveAspect: 0 - m_FillCenter: 1 - m_FillMethod: 4 - m_FillAmount: 1 - m_FillClockwise: 1 - m_FillOrigin: 0 - m_UseSpriteMesh: 0 - m_PixelsPerUnitMultiplier: 1 ---- !u!222 &642672891 -CanvasRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 642672887} - m_CullTransparentMesh: 1 --- !u!1 &709008643 GameObject: m_ObjectHideFlags: 0 @@ -1086,12 +1184,12 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1087467994} serializedVersion: 2 - m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 0} + m_Father: {fileID: 1104447313} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1087467996 MonoBehaviour: @@ -1110,7 +1208,7 @@ MonoBehaviour: _logContentParent: {fileID: 2076648994} _logEntryPrefab: {fileID: 6280972447933324029, guid: d74792089ff38e04a9a7c41def8766bb, type: 3} _clearButton: {fileID: 0} - _toggleButton: {fileID: 642672889} + _toggleButton: {fileID: 0} _filterInput: {fileID: 0} _settings: {fileID: 11400000, guid: 8e5ce280818bafb45bd699331eb688e4, type: 2} --- !u!1 &1104447311 @@ -1142,7 +1240,8 @@ Transform: m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: [] + m_Children: + - {fileID: 1087467995} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1104447314 @@ -1265,7 +1364,7 @@ RectTransform: - {fileID: 1274474669} - {fileID: 981746805} - {fileID: 396605757} - - {fileID: 642672888} + - {fileID: 149056741} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1359,11 +1458,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: -1655.8998 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.y - value: -1260.7114 + value: 0 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_LocalPosition.x @@ -1395,11 +1494,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: 827.94995 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchoredPosition.y - value: 630.3557 + value: 0 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_LocalEulerAnglesHint.x @@ -1450,18 +1549,7 @@ PrefabInstance: m_AddedGameObjects: [] m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} ---- !u!114 &1411584568 stripped -MonoBehaviour: - m_CorrespondingSourceObject: {fileID: 5859485178518072226, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} - m_PrefabInstance: {fileID: 2142640635} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} - m_Name: - m_EditorClassIdentifier: ---- !u!1 &1485874731 +--- !u!1 &1394009830 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -1469,9 +1557,9 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1485874732} - - component: {fileID: 1485874734} - - component: {fileID: 1485874733} + - component: {fileID: 1394009831} + - component: {fileID: 1394009833} + - component: {fileID: 1394009832} m_Layer: 5 m_Name: Text (TMP) m_TagString: Untagged @@ -1479,32 +1567,32 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!224 &1485874732 +--- !u!224 &1394009831 RectTransform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1485874731} + m_GameObject: {fileID: 1394009830} m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 642672888} + m_Father: {fileID: 73955221} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 0, y: 0} m_Pivot: {x: 0.5, y: 0.5} ---- !u!114 &1485874733 +--- !u!114 &1394009832 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1485874731} + m_GameObject: {fileID: 1394009830} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} @@ -1589,14 +1677,25 @@ MonoBehaviour: m_hasFontAssetChanged: 0 m_baseMaterial: {fileID: 0} m_maskOffset: {x: 0, y: 0, z: 0, w: 0} ---- !u!222 &1485874734 +--- !u!222 &1394009833 CanvasRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1485874731} + m_GameObject: {fileID: 1394009830} m_CullTransparentMesh: 1 +--- !u!114 &1411584568 stripped +MonoBehaviour: + m_CorrespondingSourceObject: {fileID: 5859485178518072226, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} + m_PrefabInstance: {fileID: 2142640635} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: --- !u!1001 &1636555666 PrefabInstance: m_ObjectHideFlags: 0 @@ -1748,7 +1847,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMax.x - value: 1 + value: 0.5 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMax.y @@ -1756,7 +1855,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMin.x - value: 0 + value: 0.5 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_AnchorMin.y @@ -1764,7 +1863,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_SizeDelta.x - value: -440 + value: 1000 objectReference: {fileID: 0} - target: {fileID: 118827301036148531, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} propertyPath: m_SizeDelta.y @@ -1832,7 +1931,6 @@ SceneRoots: m_ObjectHideFlags: 0 m_Roots: - {fileID: 322172998} - - {fileID: 1087467995} - {fileID: 1104447313} - {fileID: 1145326587} - {fileID: 829067253} @@ -1840,3 +1938,4 @@ SceneRoots: - {fileID: 332900996} - {fileID: 622824879} - {fileID: 873552999} + - {fileID: 364439186} From acf92ac66c36e6f59332fab2795f8a3b79a97261 Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 25 Aug 2025 21:31:48 +0900 Subject: [PATCH 08/14] =?UTF-8?q?remove:=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Editor.meta | 8 -------- Assets/Editor/OpenDocsMenu.cs | 14 -------------- Assets/Editor/OpenDocsMenu.cs.meta | 2 -- Assets/Infrastructure/Bridge.meta | 8 -------- 4 files changed, 32 deletions(-) delete mode 100644 Assets/Editor.meta delete mode 100644 Assets/Editor/OpenDocsMenu.cs delete mode 100644 Assets/Editor/OpenDocsMenu.cs.meta delete mode 100644 Assets/Infrastructure/Bridge.meta diff --git a/Assets/Editor.meta b/Assets/Editor.meta deleted file mode 100644 index 0baaa73..0000000 --- a/Assets/Editor.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: c880b556fd897fc4b8ff8fec8f532d2f -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Editor/OpenDocsMenu.cs b/Assets/Editor/OpenDocsMenu.cs deleted file mode 100644 index e067144..0000000 --- a/Assets/Editor/OpenDocsMenu.cs +++ /dev/null @@ -1,14 +0,0 @@ -using UnityEditor; -using UnityEngine; - -public static class OpenDocsMenu -{ - /** - * 프로젝트 문서(루트 Docs/)를 파일 탐색기로 여는 메뉴 항목을 추가합니다. - */ - [MenuItem("Help/Open Project Docs")] - public static void OpenProjectDocs() - { - - } -} diff --git a/Assets/Editor/OpenDocsMenu.cs.meta b/Assets/Editor/OpenDocsMenu.cs.meta deleted file mode 100644 index 30ad15e..0000000 --- a/Assets/Editor/OpenDocsMenu.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 51cc0cffee369434a853777240686338 \ No newline at end of file diff --git a/Assets/Infrastructure/Bridge.meta b/Assets/Infrastructure/Bridge.meta deleted file mode 100644 index e6b4956..0000000 --- a/Assets/Infrastructure/Bridge.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 8e7bce2422064f948a9595706ec79f62 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: From e06e01f24fc28e6dc2d0aadd9769ab25304110bd Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 25 Aug 2025 22:22:17 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=EC=95=B1=20=EB=B0=B1=EA=B7=B8?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=EC=97=90=EC=84=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OAuth2/Handlers/DesktopCallbackHandler.cs | 62 ++++++++++++++- .../Auth/OAuth2/ServerOAuth2Provider.cs | 76 +++++++++++++++---- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs index 466d8ee..fd45dc4 100644 --- a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs @@ -20,6 +20,8 @@ public class DesktopCallbackHandler : IOAuth2CallbackHandler private HttpListener _listener; private CancellationTokenSource _cancellationTokenSource; private string _callbackUrl; + private DateTime _lastActivityTime; + private bool _isWaitingForCallback = false; public string PlatformName => "Desktop"; public bool IsSupported => true; @@ -30,6 +32,10 @@ public async Task InitializeAsync(string expectedState, float timeoutSeconds) _timeoutSeconds = timeoutSeconds; _isInitialized = true; _cancellationTokenSource = new CancellationTokenSource(); + _lastActivityTime = DateTime.UtcNow; + + // Unity 이벤트 등록 + Application.focusChanged += OnApplicationFocusChanged; // 로컬 HTTP 서버 시작 await StartLocalServerAsync(); @@ -45,6 +51,7 @@ public async Task WaitForCallbackAsync() } Debug.Log("[DesktopCallbackHandler] OAuth2 콜백 대기 시작"); + _isWaitingForCallback = true; var startTime = DateTime.UtcNow; var timeout = TimeSpan.FromSeconds(_timeoutSeconds); @@ -54,23 +61,38 @@ public async Task WaitForCallbackAsync() if (!string.IsNullOrEmpty(_callbackUrl)) { Debug.Log($"[DesktopCallbackHandler] OAuth2 콜백 수신: {_callbackUrl}"); + _isWaitingForCallback = false; return _callbackUrl; } - // 100ms 대기 - await UniTask.Delay(100); + // 앱이 포커스를 잃었을 때 더 자주 체크 + var checkInterval = Application.isFocused ? 100 : 50; // 백그라운드일 때 더 빠르게 체크 + await UniTask.Delay(checkInterval); + + // 디버그 정보 출력 (10초마다) + if ((DateTime.UtcNow - _lastActivityTime).TotalSeconds >= 10) + { + Debug.Log($"[DesktopCallbackHandler] 콜백 대기 중... (경과: {(DateTime.UtcNow - startTime).TotalSeconds:F1}초, 포커스: {Application.isFocused})"); + _lastActivityTime = DateTime.UtcNow; + } } Debug.LogWarning("[DesktopCallbackHandler] OAuth2 콜백 타임아웃"); + _isWaitingForCallback = false; return null; } public void Cleanup() { _isDisposed = true; + _isWaitingForCallback = false; _cancellationTokenSource?.Cancel(); _listener?.Stop(); _listener?.Close(); + + // Unity 이벤트 해제 + Application.focusChanged -= OnApplicationFocusChanged; + Debug.Log("[DesktopCallbackHandler] 정리 완료"); } @@ -236,5 +258,41 @@ private async Task ProcessCallbackAsync(HttpListenerContext context) Debug.LogError($"[DesktopCallbackHandler] 콜백 처리 중 오류: {ex.Message}"); } } + + /// + /// 앱 포커스 변경 이벤트 처리 + /// + private void OnApplicationFocusChanged(bool hasFocus) + { + if (_isWaitingForCallback) + { + Debug.Log($"[DesktopCallbackHandler] 앱 포커스 변경: {hasFocus}"); + + if (hasFocus) + { + // 앱이 다시 포커스를 받았을 때 콜백 URL 확인 + CheckForCallbackUrl(); + } + } + } + + /// + /// 콜백 URL 확인 (앱 포커스 복귀 시) + /// + private void CheckForCallbackUrl() + { + try + { + // 로컬 서버에서 최근 요청 확인 + if (_listener != null && _listener.IsListening) + { + Debug.Log("[DesktopCallbackHandler] 앱 포커스 복귀 - 콜백 URL 재확인"); + } + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 URL 확인 중 오류: {ex.Message}"); + } + } } } diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs index 967ad28..bfb303a 100644 --- a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -40,6 +40,10 @@ public ServerOAuth2Provider(ServerOAuth2Config config) Debug.Log($"[ServerOAuth2Provider] HttpApiClient 초기화 상태: {_httpClient.IsInitialized}"); + // Unity 백그라운드 실행 설정 + Application.runInBackground = true; + Debug.Log("[ServerOAuth2Provider] 백그라운드 실행 활성화"); + // 플랫폼별 콜백 핸들러 생성 _callbackHandler = OAuth2CallbackHandlerFactory.CreateHandler(); Debug.Log($"[ServerOAuth2Provider] {_callbackHandler.PlatformName} 콜백 핸들러 생성됨"); @@ -318,6 +322,12 @@ public async Task LoginWithServerOAuth2Async(string scope) try { Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 시작"); + Debug.Log("=== 🔐 OAuth2 로그인 시작 ==="); + Debug.Log("1. 브라우저가 열립니다."); + Debug.Log("2. Google 로그인을 완료해주세요."); + Debug.Log("3. 로그인 완료 후 Unity 앱으로 돌아와주세요."); + Debug.Log("4. 콜백을 자동으로 처리합니다."); + Debug.Log("================================"); // 1. PKCE 파라미터 생성 var pkce = await GeneratePKCEAsync(); @@ -332,6 +342,8 @@ public async Task LoginWithServerOAuth2Async(string scope) throw new InvalidOperationException($"브라우저 열기 실패: {browserResult.Error}"); } + Debug.Log("✅ 브라우저가 열렸습니다. Google 로그인을 진행해주세요."); + // 4. 콜백 대기 및 처리 var callbackResult = await WaitForOAuth2CallbackAsync(pkce.State); if (!callbackResult.success) @@ -339,9 +351,12 @@ public async Task LoginWithServerOAuth2Async(string scope) throw new InvalidOperationException("OAuth2 콜백 처리 실패"); } + Debug.Log("✅ OAuth2 콜백 수신 완료. 토큰을 요청합니다."); + // 5. 토큰 요청 var tokenSet = await RequestTokenAsync(callbackResult.state); + Debug.Log("=== 🔐 OAuth2 로그인 완료 ==="); Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 완료"); return tokenSet; } @@ -404,14 +419,30 @@ private async Task OpenOAuth2BrowserAsync(string authUrl) Application.OpenURL(authUrl); browserType = "Default Browser"; #else - // 데스크톱에서는 기본 브라우저 사용 - Application.OpenURL(authUrl); - browserType = "Default Browser"; + // 데스크톱에서는 기본 브라우저 사용 (백그라운드에서 실행) + try + { + // Windows에서 백그라운드로 브라우저 열기 + var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = authUrl; + process.StartInfo.UseShellExecute = true; + process.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Normal; + process.Start(); + + Debug.Log("[ServerOAuth2Provider] 브라우저가 백그라운드에서 열렸습니다."); + } + catch (Exception ex) + { + Debug.LogWarning($"[ServerOAuth2Provider] 백그라운드 브라우저 열기 실패, 기본 방식 사용: {ex.Message}"); + Application.OpenURL(authUrl); + } + browserType = "Background Browser"; #endif - // 브라우저 열기 대기 - await UniTask.Delay(1000); + // 브라우저 열기 대기 (더 짧게) + await UniTask.Delay(500); + Debug.Log("[ServerOAuth2Provider] 브라우저 열기 완료 - 콜백 대기 시작"); return OAuth2BrowserResult.SuccessResult(authUrl, platform, browserType); } catch (Exception ex) @@ -431,24 +462,43 @@ private async Task OpenOAuth2BrowserAsync(string authUrl) try { Debug.Log($"[ServerOAuth2Provider] OAuth2 콜백 대기 시작 - State: {expectedState}"); + Debug.Log("[ServerOAuth2Provider] 브라우저에서 OAuth2 로그인을 완료한 후 Unity 앱으로 돌아와주세요."); // 콜백 핸들러 초기화 await _callbackHandler.InitializeAsync(expectedState, _config.TimeoutSeconds); - // 콜백 대기 - var callbackUrl = await _callbackHandler.WaitForCallbackAsync(); + // 콜백 대기 (더 강화된 로직) + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); - if (!string.IsNullOrEmpty(callbackUrl)) + while (DateTime.UtcNow - startTime < timeout) { - var result = await HandleOAuth2CallbackAsync(callbackUrl); - if (result.success && result.state == expectedState) + // 앱이 포커스를 잃었을 때 더 자주 체크 + var checkInterval = Application.isFocused ? 200 : 100; + await UniTask.Delay(checkInterval); + + // 콜백 핸들러에서 URL 확인 + var callbackUrl = await _callbackHandler.WaitForCallbackAsync(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + var result = await HandleOAuth2CallbackAsync(callbackUrl); + if (result.success && result.state == expectedState) + { + Debug.Log("[ServerOAuth2Provider] OAuth2 콜백 수신 완료"); + return result; + } + } + + // 디버그 정보 출력 (5초마다) + var elapsed = (DateTime.UtcNow - startTime).TotalSeconds; + if (elapsed % 5 < 0.2) // 5초마다 { - Debug.Log("[ServerOAuth2Provider] OAuth2 콜백 수신 완료"); - return result; + Debug.Log($"[ServerOAuth2Provider] 콜백 대기 중... (경과: {elapsed:F1}초, 포커스: {Application.isFocused})"); } } - Debug.LogError("[ServerOAuth2Provider] OAuth2 콜백 타임아웃 또는 실패"); + Debug.LogError("[ServerOAuth2Provider] OAuth2 콜백 타임아웃"); return (false, null); } catch (Exception ex) From 11d203121edc69ebc6e8132bda475760e1787416 Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 26 Aug 2025 12:01:37 +0900 Subject: [PATCH 10/14] =?UTF-8?q?remove:=20=EB=AF=B8=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Examples/ServerOAuth2Example.cs | 4 +- .../Auth/OAuth2/Config/ServerOAuth2Config.cs | 41 +++---------------- .../Auth/OAuth2/IServerOAuth2Client.cs | 15 ++----- .../Auth/OAuth2/Models/ServerOAuth2Models.cs | 11 ----- .../Auth/OAuth2/ServerOAuth2Provider.cs | 28 +++---------- Assets/Infrastructure/Auth/TokenManager.cs | 8 ++-- 6 files changed, 19 insertions(+), 88 deletions(-) diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs index 42b931a..db72151 100644 --- a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs @@ -173,7 +173,7 @@ private async UniTaskVoid OnLoginButtonClicked() SetButtonsEnabled(false); // 전체 OAuth2 로그인 플로우 실행 - _currentTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(OAuth2Config.Scope); + _currentTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); if (_currentTokenSet?.IsValid() == true) { @@ -258,7 +258,7 @@ private async UniTaskVoid StartAutoTokenRefresh() // TODO: 서버 OAuth2 토큰 갱신 API 호출 // 현재는 전체 재로그인 플로우 실행 - var newTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(OAuth2Config.Scope); + var newTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); if (newTokenSet?.IsValid() == true) { diff --git a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs index ee7a673..9c7f497 100644 --- a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs +++ b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs @@ -11,9 +11,6 @@ namespace ProjectVG.Infrastructure.Auth.OAuth2.Config public class ServerOAuth2Config : ScriptableObject { [Header("OAuth2 설정")] - [SerializeField] private string clientId = "test-client-id"; - - [SerializeField] private string scope = "openid profile email"; [Header("플랫폼별 리다이렉트 URI")] [SerializeField] private string webGLRedirectUri = "http://localhost:3000/auth/callback"; @@ -23,9 +20,9 @@ public class ServerOAuth2Config : ScriptableObject [SerializeField] private string macosRedirectUri = "http://localhost:3000/auth/callback"; [Header("고급 설정")] - [SerializeField] private int pkceCodeVerifierLength = 64; // PKCE 표준에 맞게 64바이트 (43자 이상) - [SerializeField] private int stateLength = 16; // JavaScript와 동일하게 16바이트 - [SerializeField] private float timeoutSeconds = 300f; // 5분 + [SerializeField] private int pkceCodeVerifierLength = 64; + [SerializeField] private int stateLength = 16; + [SerializeField] private float timeoutSeconds = 300f; // Singleton instance private static ServerOAuth2Config _instance; @@ -50,16 +47,7 @@ public static ServerOAuth2Config Instance /// 서버 URL (NetworkConfig에서 가져옴) /// public string ServerUrl => ProjectVG.Infrastructure.Network.Configs.NetworkConfig.HttpServerAddress; - - /// - /// 클라이언트 ID - /// - public string ClientId => clientId; - - /// - /// OAuth2 스코프 - /// - public string Scope => scope; + /// /// 현재 플랫폼의 리다이렉트 URI @@ -111,9 +99,7 @@ public bool IsValid() } // 기본 필수 값 검사 - var hasRequiredFields = !string.IsNullOrEmpty(clientId) && - !string.IsNullOrEmpty(scope) && - !string.IsNullOrEmpty(GetCurrentPlatformRedirectUri()); + var hasRequiredFields = !string.IsNullOrEmpty(GetCurrentPlatformRedirectUri()); // PKCE 설정 검사 var hasValidPKCE = pkceCodeVerifierLength >= 43 && pkceCodeVerifierLength <= 128 && @@ -128,18 +114,6 @@ public bool IsValid() { Debug.LogError($"[ServerOAuth2Config] 설정 유효성 검사 실패:"); - if (string.IsNullOrEmpty(clientId)) - Debug.LogError($" - clientId: 비어있음 (실제 클라이언트 ID로 설정 필요)"); - else if (clientId.Contains("your-game-client")) - Debug.LogError($" - clientId: '{clientId}' (기본값입니다. 실제 클라이언트 ID로 변경하세요)"); - else - Debug.LogError($" - clientId: '{clientId}'"); - - if (string.IsNullOrEmpty(scope)) - Debug.LogError($" - scope: 비어있음"); - else - Debug.LogError($" - scope: '{scope}'"); - if (string.IsNullOrEmpty(GetCurrentPlatformRedirectUri())) Debug.LogError($" - redirectUri: 비어있음"); else if (GetCurrentPlatformRedirectUri().Contains("your-domain")) @@ -160,7 +134,6 @@ public bool IsValid() { Debug.Log($"[ServerOAuth2Config] 설정 유효성 검사 통과"); Debug.Log($" - 서버: {ServerUrl} (NetworkConfig에서 가져옴)"); - Debug.Log($" - 클라이언트 ID: {clientId}"); Debug.Log($" - 플랫폼: {GetCurrentPlatformName()}"); Debug.Log($" - 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); } @@ -196,8 +169,6 @@ private static ServerOAuth2Config CreateDefaultInstance() var instance = CreateInstance(); // JavaScript 클라이언트와 동일한 기본값으로 초기화 - instance.clientId = "test-client-id"; - instance.scope = "openid profile email"; instance.webGLRedirectUri = "http://localhost:3000/auth/callback"; instance.androidRedirectUri = "com.yourgame://auth/callback"; instance.iosRedirectUri = "com.yourgame://auth/callback"; @@ -222,8 +193,6 @@ private void OnValidate() { Debug.Log($"[ServerOAuth2Config] 현재 플랫폼: {GetCurrentPlatformName()}"); Debug.Log($"[ServerOAuth2Config] 서버 URL: {ServerUrl}"); - Debug.Log($"[ServerOAuth2Config] 클라이언트 ID: {ClientId}"); - Debug.Log($"[ServerOAuth2Config] 스코프: {Scope}"); Debug.Log($"[ServerOAuth2Config] 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); Debug.Log($"[ServerOAuth2Config] 설정 유효: {IsValid()}"); } diff --git a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs index 3501c49..56584d5 100644 --- a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs +++ b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs @@ -23,30 +23,21 @@ public interface IServerOAuth2Client /// /// 서버 OAuth2 인증 시작 /// - /// PKCE 파라미터 - /// OAuth2 스코프 - /// Google OAuth2 URL - Task StartServerOAuth2Async(PKCEParameters pkce, string scope); + Task StartServerOAuth2Async(PKCEParameters pkce); /// - /// OAuth2 콜백 처리 (redirect URL에서 state 추출) + /// OAuth2 콜백 처리 /// - /// 콜백 URL - /// 성공 여부와 state 값 Task<(bool success, string state)> HandleOAuth2CallbackAsync(string callbackUrl); /// /// 서버에서 토큰 요청 /// - /// OAuth2 state 값 - /// JWT 토큰 세트 Task RequestTokenAsync(string state); /// /// 전체 OAuth2 로그인 플로우 (편의 메서드) /// - /// OAuth2 스코프 - /// JWT 토큰 세트 - Task LoginWithServerOAuth2Async(string scope); + Task LoginWithServerOAuth2Async(); } } diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs index 2426e6f..c18a81d 100644 --- a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs +++ b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs @@ -9,17 +9,6 @@ namespace ProjectVG.Infrastructure.Auth.OAuth2.Models [Serializable] public class ServerOAuth2AuthorizeRequest { - [JsonProperty("client_id")] - public string ClientId { get; set; } - - [JsonProperty("redirect_uri")] - public string RedirectUri { get; set; } - - [JsonProperty("response_type")] - public string ResponseType { get; set; } = "code"; - - [JsonProperty("scope")] - public string Scope { get; set; } [JsonProperty("state")] public string State { get; set; } diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs index bfb303a..2066da2 100644 --- a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -93,20 +93,14 @@ public async Task GeneratePKCEAsync() /// 서버 OAuth2 인증 시작 /// /// PKCE 파라미터 - /// OAuth2 스코프 /// Google OAuth2 URL - public async Task StartServerOAuth2Async(PKCEParameters pkce, string scope) + public async Task StartServerOAuth2Async(PKCEParameters pkce) { if (pkce == null || !pkce.IsValid()) { throw new ArgumentException("유효하지 않은 PKCE 파라미터입니다.", nameof(pkce)); } - if (string.IsNullOrEmpty(scope)) - { - scope = _config.Scope; - } - try { Debug.Log("[ServerOAuth2Provider] 서버 OAuth2 인증 시작"); @@ -115,10 +109,6 @@ public async Task StartServerOAuth2Async(PKCEParameters pkce, string sco // 1. JavaScript와 동일한 방식으로 쿼리 파라미터 생성 var queryParams = new Dictionary { - { "client_id", _config.ClientId }, - { "redirect_uri", _config.GetCurrentPlatformRedirectUri() }, - { "response_type", "code" }, - { "scope", scope }, { "state", pkce.State }, { "code_challenge", pkce.CodeChallenge }, { "code_challenge_method", "S256" }, @@ -129,8 +119,9 @@ public async Task StartServerOAuth2Async(PKCEParameters pkce, string sco Debug.Log($"[ServerOAuth2Provider] 쿼리 파라미터: {JsonConvert.SerializeObject(queryParams)}"); // 2. 쿼리 파라미터를 URL에 추가 + string provider = "/google"; var queryString = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); - var authorizeUrl = $"{_config.ServerUrl}/auth/oauth2/authorize?{queryString}"; + var authorizeUrl = $"{_config.ServerUrl}/auth/oauth2/authorize{provider}?{queryString}"; Debug.Log($"[ServerOAuth2Provider] 최종 URL: {authorizeUrl}"); @@ -317,23 +308,17 @@ public async Task RequestTokenAsync(string state) /// /// OAuth2 스코프 /// JWT 토큰 세트 - public async Task LoginWithServerOAuth2Async(string scope) + public async Task LoginWithServerOAuth2Async() { try { Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 시작"); - Debug.Log("=== 🔐 OAuth2 로그인 시작 ==="); - Debug.Log("1. 브라우저가 열립니다."); - Debug.Log("2. Google 로그인을 완료해주세요."); - Debug.Log("3. 로그인 완료 후 Unity 앱으로 돌아와주세요."); - Debug.Log("4. 콜백을 자동으로 처리합니다."); - Debug.Log("================================"); // 1. PKCE 파라미터 생성 var pkce = await GeneratePKCEAsync(); // 2. 서버 OAuth2 인증 시작 - var authUrl = await StartServerOAuth2Async(pkce, scope); + var authUrl = await StartServerOAuth2Async(pkce); // 3. 브라우저에서 OAuth2 로그인 진행 var browserResult = await OpenOAuth2BrowserAsync(authUrl); @@ -357,7 +342,6 @@ public async Task LoginWithServerOAuth2Async(string scope) var tokenSet = await RequestTokenAsync(callbackResult.state); Debug.Log("=== 🔐 OAuth2 로그인 완료 ==="); - Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 완료"); return tokenSet; } catch (Exception ex) @@ -526,8 +510,6 @@ public string GetDebugInfo() var info = $"ServerOAuth2Provider Debug Info:\n"; info += $"Is Configured: {IsConfigured}\n"; info += $"Server URL: {_config?.ServerUrl}\n"; - info += $"Client ID: {_config?.ClientId}\n"; - info += $"Scope: {_config?.Scope}\n"; info += $"Platform: {_config?.GetCurrentPlatformName()}\n"; info += $"Redirect URI: {_config?.GetCurrentPlatformRedirectUri()}\n"; info += $"Client Redirect URI: {GetClientRedirectUri()}\n"; diff --git a/Assets/Infrastructure/Auth/TokenManager.cs b/Assets/Infrastructure/Auth/TokenManager.cs index d3a82e3..49a0a3e 100644 --- a/Assets/Infrastructure/Auth/TokenManager.cs +++ b/Assets/Infrastructure/Auth/TokenManager.cs @@ -14,10 +14,10 @@ namespace ProjectVG.Infrastructure.Auth /// public class TokenManager : MonoBehaviour { - private const string ACCESS_TOKEN_KEY = "oauth2_access_token"; - private const string REFRESH_TOKEN_KEY = "oauth2_refresh_token"; - private const string TOKEN_EXPIRY_KEY = "oauth2_token_expiry"; - private const string USER_ID_KEY = "oauth2_user_id"; + private const string ACCESS_TOKEN_KEY = "access_token"; + private const string REFRESH_TOKEN_KEY = "refresh_token"; + private const string TOKEN_EXPIRY_KEY = "token_expiry"; + private const string USER_ID_KEY = "user_id"; private const string ENCRYPTION_KEY = "ProjectVG_OAuth2_Secure_Key_2024"; private static TokenManager _instance; From 794e8296ae7d338b844df6b9f215eeb6f8c8e277 Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 26 Aug 2025 12:45:55 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20access=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20+=20access?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Infrastructure/Auth/TokenManager.cs | 129 ++++++++++++--------- 1 file changed, 71 insertions(+), 58 deletions(-) diff --git a/Assets/Infrastructure/Auth/TokenManager.cs b/Assets/Infrastructure/Auth/TokenManager.cs index 49a0a3e..47f1e8f 100644 --- a/Assets/Infrastructure/Auth/TokenManager.cs +++ b/Assets/Infrastructure/Auth/TokenManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Security.Cryptography; using System.Text; using UnityEngine; @@ -14,9 +15,8 @@ namespace ProjectVG.Infrastructure.Auth /// public class TokenManager : MonoBehaviour { - private const string ACCESS_TOKEN_KEY = "access_token"; + // AccessToken은 메모리에만 저장하므로 ACCESS_TOKEN_KEY, TOKEN_EXPIRY_KEY 는 제거 private const string REFRESH_TOKEN_KEY = "refresh_token"; - private const string TOKEN_EXPIRY_KEY = "token_expiry"; private const string USER_ID_KEY = "user_id"; private const string ENCRYPTION_KEY = "ProjectVG_OAuth2_Secure_Key_2024"; @@ -54,6 +54,13 @@ private void Awake() _instance = this; DontDestroyOnLoad(gameObject); LoadTokensFromStorage(); + + // 앱 시작 시 RefreshToken이 있으면 자동으로 AccessToken 복구 시도 + if (HasRefreshToken && !IsRefreshTokenExpired()) + { + Debug.Log("[TokenManager] RefreshToken 발견 - 자동 AccessToken 복구 시작"); + StartCoroutine(AutoRecoverAccessTokenCoroutine()); + } } else if (_instance != this) { @@ -74,21 +81,11 @@ public void SaveTokens(TokenSet tokenSet) try { + // AccessToken은 메모리에만 저장 (영속적 저장 안함) _currentAccessToken = tokenSet.AccessToken; _currentRefreshToken = tokenSet.RefreshToken; _currentUserId = tokenSet.RefreshToken?.DeviceId; - // Access Token 저장 (암호화) - var accessTokenData = new TokenStorageData - { - Token = _currentAccessToken.Token, - ExpiresAt = _currentAccessToken.ExpiresAt, - TokenType = _currentAccessToken.TokenType, - Scope = _currentAccessToken.Scope - }; - var encryptedAccessToken = EncryptData(JsonConvert.SerializeObject(accessTokenData)); - PlayerPrefs.SetString(ACCESS_TOKEN_KEY, encryptedAccessToken); - // Refresh Token 저장 (강력한 암호화) string encryptedRefreshToken = null; if (_currentRefreshToken != null) @@ -109,8 +106,7 @@ public void SaveTokens(TokenSet tokenSet) PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); } - // 만료 시간 저장 - PlayerPrefs.SetString(TOKEN_EXPIRY_KEY, _currentAccessToken.ExpiresAt.ToString("O")); + // AccessToken 만료 시간은 메모리에만 보관 (PlayerPrefs 저장 안함) // User ID 저장 if (!string.IsNullOrEmpty(_currentUserId)) @@ -121,9 +117,8 @@ public void SaveTokens(TokenSet tokenSet) PlayerPrefs.Save(); Debug.Log("=== 🔐 TokenManager 토큰 저장 완료 ==="); - Debug.Log($"[TokenManager] Access Token 저장 위치: PlayerPrefs['{ACCESS_TOKEN_KEY}']"); + Debug.Log($"[TokenManager] Access Token 저장: 메모리 전용 (영속적 저장 안함)"); Debug.Log($"[TokenManager] Access Token 만료: {_currentAccessToken.ExpiresAt}"); - Debug.Log($"[TokenManager] Access Token 암호화: {!string.IsNullOrEmpty(encryptedAccessToken)}"); if (_currentRefreshToken != null) { @@ -137,7 +132,6 @@ public void SaveTokens(TokenSet tokenSet) } Debug.Log($"[TokenManager] User ID 저장 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); - Debug.Log($"[TokenManager] 만료 시간 저장 위치: PlayerPrefs['{TOKEN_EXPIRY_KEY}'] = {_currentAccessToken.ExpiresAt:O}"); Debug.Log("=== 토큰 저장 완료 ==="); OnTokensUpdated?.Invoke(tokenSet); @@ -226,22 +220,10 @@ public void UpdateAccessToken(string newAccessToken, int expiresInSeconds) try { + // AccessToken은 메모리에만 업데이트 (영속적 저장 안함) _currentAccessToken = new AccessToken(newAccessToken, expiresInSeconds, "Bearer", "oauth2"); - // Access Token만 업데이트 - var accessTokenData = new TokenStorageData - { - Token = _currentAccessToken.Token, - ExpiresAt = _currentAccessToken.ExpiresAt, - TokenType = _currentAccessToken.TokenType, - Scope = _currentAccessToken.Scope - }; - var encryptedAccessToken = EncryptData(JsonConvert.SerializeObject(accessTokenData)); - PlayerPrefs.SetString(ACCESS_TOKEN_KEY, encryptedAccessToken); - PlayerPrefs.SetString(TOKEN_EXPIRY_KEY, _currentAccessToken.ExpiresAt.ToString("O")); - PlayerPrefs.Save(); - - Debug.Log("[TokenManager] Access Token 갱신 완료"); + Debug.Log("[TokenManager] Access Token 갱신 완료 (메모리 전용)"); Debug.Log($"[TokenManager] 새로운 만료 시간: {_currentAccessToken.ExpiresAt}"); var tokenSet = new TokenSet(_currentAccessToken, _currentRefreshToken); @@ -261,13 +243,13 @@ public void ClearTokens() { try { + // 메모리에서 AccessToken 제거 _currentAccessToken = null; _currentRefreshToken = null; _currentUserId = null; - PlayerPrefs.DeleteKey(ACCESS_TOKEN_KEY); + // RefreshToken만 PlayerPrefs에서 삭제 (기존 ACCESS_TOKEN_KEY, TOKEN_EXPIRY_KEY 삭제 로직 제거) PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); - PlayerPrefs.DeleteKey(TOKEN_EXPIRY_KEY); PlayerPrefs.DeleteKey(USER_ID_KEY); PlayerPrefs.Save(); @@ -288,20 +270,9 @@ private void LoadTokensFromStorage() { try { - // Access Token 로드 - if (PlayerPrefs.HasKey(ACCESS_TOKEN_KEY)) - { - var encryptedAccessToken = PlayerPrefs.GetString(ACCESS_TOKEN_KEY); - var decryptedAccessToken = DecryptData(encryptedAccessToken); - var accessTokenData = JsonConvert.DeserializeObject(decryptedAccessToken); - - _currentAccessToken = new AccessToken( - accessTokenData.Token, - accessTokenData.ExpiresAt, - accessTokenData.TokenType, - accessTokenData.Scope - ); - } + // AccessToken은 저장소에서 로드하지 않음 (메모리 전용) + // 앱 시작 시 AccessToken은 null 상태로 시작 + _currentAccessToken = null; // Refresh Token 로드 if (PlayerPrefs.HasKey(REFRESH_TOKEN_KEY)) @@ -324,13 +295,8 @@ private void LoadTokensFromStorage() } Debug.Log("=== 🔍 TokenManager 저장소에서 토큰 로드 완료 ==="); - Debug.Log($"[TokenManager] Access Token 로드 위치: PlayerPrefs['{ACCESS_TOKEN_KEY}']"); - Debug.Log($"[TokenManager] Access Token 존재: {_currentAccessToken != null}"); - if (_currentAccessToken != null) - { - Debug.Log($"[TokenManager] Access Token 만료: {_currentAccessToken.ExpiresAt}"); - Debug.Log($"[TokenManager] Access Token 유효: {!_currentAccessToken.IsExpired()}"); - } + Debug.Log($"[TokenManager] Access Token: 메모리 전용 (영속적 로드 안함)"); + Debug.Log($"[TokenManager] Access Token 상태: null (앱 시작 시 기본값)"); Debug.Log($"[TokenManager] Refresh Token 로드 위치: PlayerPrefs['{REFRESH_TOKEN_KEY}']"); Debug.Log($"[TokenManager] Refresh Token 존재: {_currentRefreshToken != null}"); @@ -341,7 +307,6 @@ private void LoadTokensFromStorage() } Debug.Log($"[TokenManager] User ID 로드 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); - Debug.Log($"[TokenManager] 만료 시간 로드 위치: PlayerPrefs['{TOKEN_EXPIRY_KEY}']"); Debug.Log("=== 토큰 로드 완료 ==="); } catch (Exception ex) @@ -418,10 +383,9 @@ public string GetDebugInfo() { var info = "TokenManager Debug Info:\n"; info += $"=== 저장 위치 정보 ===\n"; - info += $"Access Token 저장: PlayerPrefs['{ACCESS_TOKEN_KEY}']\n"; + info += $"Access Token 저장: 메모리 전용 (영속적 저장 안함)\n"; info += $"Refresh Token 저장: PlayerPrefs['{REFRESH_TOKEN_KEY}']\n"; info += $"User ID 저장: PlayerPrefs['{USER_ID_KEY}']\n"; - info += $"만료 시간 저장: PlayerPrefs['{TOKEN_EXPIRY_KEY}']\n"; info += $"=== 토큰 상태 ===\n"; info += $"Has Valid Tokens: {HasValidTokens}\n"; info += $"Has Refresh Token: {HasRefreshToken}\n"; @@ -445,6 +409,55 @@ public string GetDebugInfo() return info; } + + /// + /// 앱 시작 시 RefreshToken으로 AccessToken 자동 복구 + /// + private IEnumerator AutoRecoverAccessTokenCoroutine() + { + // TokenRefreshService가 초기화될 때까지 대기 + yield return new WaitForSeconds(0.5f); + + if (TokenRefreshService.Instance != null) + { + Debug.Log("[TokenManager] TokenRefreshService를 통해 AccessToken 복구 시도"); + + // 비동기 호출을 코루틴에서 처리 + StartCoroutine(TryRefreshTokenCoroutine()); + } + else + { + Debug.LogWarning("[TokenManager] TokenRefreshService가 아직 초기화되지 않았습니다."); + } + } + + /// + /// RefreshToken으로 AccessToken 복구 코루틴 + /// + private IEnumerator TryRefreshTokenCoroutine() + { + var refreshTask = TokenRefreshService.Instance.RefreshAccessTokenAsync(); + + // UniTask를 코루틴에서 기다림 + yield return new WaitUntil(() => refreshTask.Status != Cysharp.Threading.Tasks.UniTaskStatus.Pending); + + if (refreshTask.Status == Cysharp.Threading.Tasks.UniTaskStatus.Succeeded) + { + bool success = refreshTask.GetAwaiter().GetResult(); + if (success) + { + Debug.Log("[TokenManager] 앱 시작 시 AccessToken 자동 복구 성공"); + } + else + { + Debug.LogWarning("[TokenManager] 앱 시작 시 AccessToken 자동 복구 실패"); + } + } + else + { + Debug.LogError("[TokenManager] AccessToken 복구 중 오류 발생"); + } + } } /// From c475a2af07140dc4d88a2c8ce398b360b41904f4 Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 26 Aug 2025 20:47:58 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20api=20=EC=8A=A4=ED=8C=A9=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Examples/GuestLoginExample.cs | 266 ++++++++++++++++++ .../Auth/Examples/GuestLoginExample.cs.meta | 2 + Assets/Infrastructure/Auth/Services.meta | 8 + .../Auth/Services/GuestAuthService.cs | 252 +++++++++++++++++ .../Auth/Services/GuestAuthService.cs.meta | 2 + .../Auth/TokenRefreshService.cs | 2 +- Assets/Infrastructure/Auth/Utils.meta | 8 + .../Auth/Utils/DeviceIdProvider.cs | 238 ++++++++++++++++ .../Auth/Utils/DeviceIdProvider.cs.meta | 2 + .../Network/Configs/NetworkConfig.cs | 4 +- Assets/Infrastructure/Network/DTOs/Auth.meta | 8 + .../Network/DTOs/Auth/GuestLoginRequest.cs | 46 +++ .../DTOs/Auth/GuestLoginRequest.cs.meta | 2 + .../Network/DTOs/Auth/GuestLoginResponse.cs | 168 +++++++++++ .../DTOs/Auth/GuestLoginResponse.cs.meta | 2 + .../Network/Services/CharacterApiService.cs | 10 +- .../Network/Services/ChatApiService.cs | 2 +- .../Network/Services/STTService.cs | 2 +- 18 files changed, 1014 insertions(+), 10 deletions(-) create mode 100644 Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs create mode 100644 Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta create mode 100644 Assets/Infrastructure/Auth/Services.meta create mode 100644 Assets/Infrastructure/Auth/Services/GuestAuthService.cs create mode 100644 Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta create mode 100644 Assets/Infrastructure/Auth/Utils.meta create mode 100644 Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs create mode 100644 Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta create mode 100644 Assets/Infrastructure/Network/DTOs/Auth.meta create mode 100644 Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs create mode 100644 Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta create mode 100644 Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs create mode 100644 Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta diff --git a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs new file mode 100644 index 0000000..0f03266 --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs @@ -0,0 +1,266 @@ +using UnityEngine; +using ProjectVG.Infrastructure.Auth.Services; +using ProjectVG.Infrastructure.Auth.Utils; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// Guest 로그인 사용 예제 + /// + public class GuestLoginExample : MonoBehaviour + { + [Header("Debug UI")] + public bool showDebugUI = true; + + private GuestAuthService _guestAuthService; + private TokenManager _tokenManager; + private bool _isLoggingIn = false; + + private void Start() + { + InitializeServices(); + SetupEventHandlers(); + + // 앱 시작 시 자동 게스트 로그인 시도 + CheckAutoGuestLogin(); + } + + private void InitializeServices() + { + _guestAuthService = GuestAuthService.Instance; + _tokenManager = TokenManager.Instance; + + Debug.Log("[GuestLoginExample] 서비스 초기화 완료"); + } + + private void SetupEventHandlers() + { + // Guest 로그인 이벤트 구독 + _guestAuthService.OnGuestLoginSuccess += HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed += HandleGuestLoginFailed; + + // Token 이벤트 구독 + _tokenManager.OnTokensUpdated += HandleTokensUpdated; + _tokenManager.OnTokensExpired += HandleTokensExpired; + } + + private async void CheckAutoGuestLogin() + { + // 이미 유효한 토큰이 있으면 자동 로그인 스킵 + if (_tokenManager.HasValidTokens) + { + Debug.Log("[GuestLoginExample] 유효한 토큰 존재 - 자동 로그인 스킵"); + return; + } + + // RefreshToken이 있으면 자동 갱신 대기 + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + Debug.Log("[GuestLoginExample] RefreshToken 존재 - 자동 갱신 대기"); + return; + } + + // Guest 로그인 가능하면 자동 로그인 시도 + if (_guestAuthService.CanLoginAsGuest()) + { + Debug.Log("[GuestLoginExample] 자동 Guest 로그인 시도"); + await PerformGuestLoginAsync(); + } + } + + /// + /// Guest 로그인 수행 + /// + public async void PerformGuestLogin() + { + await PerformGuestLoginAsync(); + } + + private async System.Threading.Tasks.Task PerformGuestLoginAsync() + { + if (_isLoggingIn) + { + Debug.LogWarning("[GuestLoginExample] 이미 로그인 진행 중입니다."); + return; + } + + _isLoggingIn = true; + + try + { + Debug.Log("[GuestLoginExample] Guest 로그인 시작"); + + // 디바이스 정보 출력 + Debug.Log($"[GuestLoginExample] 디바이스 정보: {DeviceIdProvider.GetPlatformInfo()}"); + Debug.Log($"[GuestLoginExample] 디바이스 ID: {MaskString(_guestAuthService.GetCurrentDeviceId())}"); + + // Guest 로그인 수행 + bool success = await _guestAuthService.LoginAsGuestAsync(); + + if (success) + { + Debug.Log("[GuestLoginExample] Guest 로그인 성공"); + } + else + { + Debug.LogError("[GuestLoginExample] Guest 로그인 실패"); + } + } + catch (System.Exception ex) + { + Debug.LogError($"[GuestLoginExample] Guest 로그인 중 오류: {ex.Message}"); + } + finally + { + _isLoggingIn = false; + } + } + + /// + /// 토큰 상태 확인 + /// + public void CheckTokenStatus() + { + Debug.Log("=== Token Status ==="); + Debug.Log(_tokenManager.GetDebugInfo()); + Debug.Log("==================="); + } + + /// + /// Guest 로그인 상태 확인 + /// + public void CheckGuestLoginStatus() + { + var status = _guestAuthService.GetGuestLoginStatus(); + Debug.Log("=== Guest Login Status ==="); + Debug.Log(status.GetDebugInfo()); + Debug.Log("=========================="); + } + + /// + /// 모든 토큰 삭제 (테스트용) + /// + public void ClearAllTokens() + { + _tokenManager.ClearTokens(); + Debug.Log("[GuestLoginExample] 모든 토큰 삭제 완료"); + } + + /// + /// 디바이스 ID 리셋 (테스트용) + /// + public void ResetDeviceId() + { + _guestAuthService.ResetDeviceId(); + Debug.Log("[GuestLoginExample] 디바이스 ID 리셋 완료"); + } + + #region Event Handlers + + private void HandleGuestLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[GuestLoginExample] Guest 로그인 성공 이벤트 수신"); + Debug.Log($"[GuestLoginExample] AccessToken 만료: {tokenSet.AccessToken.ExpiresAt}"); + } + + private void HandleGuestLoginFailed(string error) + { + Debug.LogError($"[GuestLoginExample] Guest 로그인 실패 이벤트 수신: {error}"); + } + + private void HandleTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[GuestLoginExample] 토큰 업데이트 이벤트 수신"); + } + + private void HandleTokensExpired() + { + Debug.Log("[GuestLoginExample] 토큰 만료 이벤트 수신 - 자동 갱신 시도"); + } + + #endregion + + #region Debug UI + + private void OnGUI() + { + if (!showDebugUI) return; + + GUILayout.BeginArea(new Rect(10, 10, 400, 600)); + GUILayout.Label("=== Guest Login Debug UI ===", GUI.skin.box); + + // 로그인 버튼 + GUI.enabled = !_isLoggingIn && _guestAuthService.CanLoginAsGuest(); + if (GUILayout.Button(_isLoggingIn ? "로그인 중..." : "Guest 로그인")) + { + PerformGuestLogin(); + } + GUI.enabled = true; + + GUILayout.Space(10); + + // 상태 확인 버튼들 + if (GUILayout.Button("토큰 상태 확인")) + { + CheckTokenStatus(); + } + + if (GUILayout.Button("Guest 로그인 상태 확인")) + { + CheckGuestLoginStatus(); + } + + GUILayout.Space(10); + + // 테스트 버튼들 + GUILayout.Label("=== 테스트 기능 ===", GUI.skin.box); + + if (GUILayout.Button("모든 토큰 삭제")) + { + ClearAllTokens(); + } + + if (GUILayout.Button("디바이스 ID 리셋")) + { + ResetDeviceId(); + } + + GUILayout.Space(10); + + // 현재 상태 표시 + GUILayout.Label("=== 현재 상태 ===", GUI.skin.box); + GUILayout.Label($"로그인 중: {_isLoggingIn}"); + GUILayout.Label($"유효한 토큰: {_tokenManager?.HasValidTokens ?? false}"); + GUILayout.Label($"RefreshToken: {_tokenManager?.HasRefreshToken ?? false}"); + GUILayout.Label($"Guest 로그인 가능: {_guestAuthService?.CanLoginAsGuest() ?? false}"); + + GUILayout.EndArea(); + } + + #endregion + + private string MaskString(string input) + { + if (string.IsNullOrEmpty(input) || input.Length < 8) + return "***"; + return $"{input.Substring(0, 4)}****{input.Substring(input.Length - 4)}"; + } + + private void OnDestroy() + { + // 이벤트 구독 해제 + if (_guestAuthService != null) + { + _guestAuthService.OnGuestLoginSuccess -= HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed -= HandleGuestLoginFailed; + } + + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= HandleTokensUpdated; + _tokenManager.OnTokensExpired -= HandleTokensExpired; + } + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta new file mode 100644 index 0000000..d68574b --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b273f87c4feba7c45b007339de53f770 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Services.meta b/Assets/Infrastructure/Auth/Services.meta new file mode 100644 index 0000000..094cda6 --- /dev/null +++ b/Assets/Infrastructure/Auth/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f32a7cd3cce3c44b846f03de6123ae1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs new file mode 100644 index 0000000..7e2680e --- /dev/null +++ b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs @@ -0,0 +1,252 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Utils; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Network.DTOs.Auth; +using ProjectVG.Infrastructure.Network.Http; + +namespace ProjectVG.Infrastructure.Auth.Services +{ + /// + /// Guest 인증 서비스 + /// 디바이스 고유 ID를 사용한 게스트 로그인 처리 + /// + public class GuestAuthService : MonoBehaviour + { + private static GuestAuthService _instance; + public static GuestAuthService Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("GuestAuthService"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private HttpApiClient _httpClient; + private TokenManager _tokenManager; + + public event Action OnGuestLoginSuccess; + public event Action OnGuestLoginFailed; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + Initialize(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private void Initialize() + { + _httpClient = HttpApiClient.Instance; + _tokenManager = TokenManager.Instance; + + Debug.Log("[GuestAuthService] 초기화 완료"); + } + + /// + /// Guest 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginAsGuestAsync() + { + try + { + Debug.Log("[GuestAuthService] Guest 로그인 시작"); + + // 디바이스 고유 ID 생성 + string deviceId = DeviceIdProvider.GetDeviceId(); + Debug.Log($"[GuestAuthService] 디바이스 ID 생성: {MaskDeviceId(deviceId)}"); + + // Guest 로그인 요청 + var request = new GuestLoginRequest(deviceId); + + Debug.Log($"[GuestAuthService] 서버 요청: {request.GetDebugInfo()}"); + + // API 호출 - requiresAuth=false (로그인 전이므로) + var response = await _httpClient.PostAsync( + "/api/v1/auth/guest-login", + deviceId, // 서버는 [FromBody] string guestId를 받음 + requiresAuth: false + ); + + if (response == null) + { + throw new Exception("서버 응답이 null입니다."); + } + + Debug.Log($"[GuestAuthService] 서버 응답: {response.GetDebugInfo()}"); + + if (!response.Success) + { + throw new Exception(response.Message ?? "Guest 로그인 실패"); + } + + // 토큰 검증 + if (response.Tokens == null || string.IsNullOrEmpty(response.Tokens.AccessToken)) + { + throw new Exception("서버에서 유효한 토큰을 받지 못했습니다."); + } + + // TokenSet 생성 및 저장 + var tokenSet = response.ToTokenSet(); + _tokenManager.SaveTokens(tokenSet); + + Debug.Log("[GuestAuthService] Guest 로그인 성공"); + Debug.Log($"[GuestAuthService] 사용자 ID: {response.User?.UserId}"); + Debug.Log($"[GuestAuthService] AccessToken 만료: {tokenSet.AccessToken.ExpiresAt}"); + + // 성공 이벤트 발생 + OnGuestLoginSuccess?.Invoke(tokenSet); + + return true; + } + catch (Exception ex) + { + Debug.LogError($"[GuestAuthService] Guest 로그인 실패: {ex.Message}"); + + // 실패 이벤트 발생 + OnGuestLoginFailed?.Invoke(ex.Message); + + return false; + } + } + + /// + /// 현재 디바이스가 게스트 로그인 가능한지 확인 + /// + /// 게스트 로그인 가능 여부 + public bool CanLoginAsGuest() + { + try + { + string deviceId = DeviceIdProvider.GetDeviceId(); + return !string.IsNullOrEmpty(deviceId); + } + catch (Exception ex) + { + Debug.LogError($"[GuestAuthService] 디바이스 ID 생성 실패: {ex.Message}"); + return false; + } + } + + /// + /// 현재 디바이스 ID 반환 + /// + /// 디바이스 ID + public string GetCurrentDeviceId() + { + return DeviceIdProvider.GetDeviceId(); + } + + /// + /// 디바이스 ID 초기화 (테스트/디버깅용) + /// + public void ResetDeviceId() + { + DeviceIdProvider.ClearDeviceId(); + Debug.Log("[GuestAuthService] 디바이스 ID 초기화 완료"); + } + + /// + /// Guest 로그인 가능 상태 확인 + /// + /// 현재 상태 정보 + public GuestLoginStatus GetGuestLoginStatus() + { + var status = new GuestLoginStatus + { + CanLoginAsGuest = CanLoginAsGuest(), + DeviceId = GetCurrentDeviceId(), + HasStoredTokens = _tokenManager.HasValidTokens || _tokenManager.HasRefreshToken, + PlatformInfo = DeviceIdProvider.GetPlatformInfo() + }; + + return status; + } + + /// + /// 디바이스 ID 마스킹 (로깅용) + /// + private string MaskDeviceId(string deviceId) + { + if (string.IsNullOrEmpty(deviceId) || deviceId.Length < 8) + { + return "***"; + } + + return $"{deviceId.Substring(0, 4)}****{deviceId.Substring(deviceId.Length - 4)}"; + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = "GuestAuthService Debug Info:\n"; + info += $"Can Login As Guest: {CanLoginAsGuest()}\n"; + info += $"Current Device ID: {MaskDeviceId(GetCurrentDeviceId())}\n"; + info += $"Has Valid Tokens: {_tokenManager?.HasValidTokens ?? false}\n"; + info += $"Has Refresh Token: {_tokenManager?.HasRefreshToken ?? false}\n"; + info += $"{DeviceIdProvider.GetPlatformInfo()}\n"; + + return info; + } + + private void OnDestroy() + { + // 이벤트 정리는 구독자가 담당 + } + } + + /// + /// Guest 로그인 상태 정보 + /// + [Serializable] + public class GuestLoginStatus + { + /// + /// Guest 로그인 가능 여부 + /// + public bool CanLoginAsGuest { get; set; } + + /// + /// 현재 디바이스 ID + /// + public string DeviceId { get; set; } + + /// + /// 저장된 토큰 존재 여부 + /// + public bool HasStoredTokens { get; set; } + + /// + /// 플랫폼 정보 + /// + public string PlatformInfo { get; set; } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + return $"GuestLoginStatus: CanLogin={CanLoginAsGuest}, " + + $"HasTokens={HasStoredTokens}, " + + $"Platform={PlatformInfo}"; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta new file mode 100644 index 0000000..fc62867 --- /dev/null +++ b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e47475a6323a8e94bbf28c623daa3b87 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs b/Assets/Infrastructure/Auth/TokenRefreshService.cs index 7a04985..41814bc 100644 --- a/Assets/Infrastructure/Auth/TokenRefreshService.cs +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs @@ -159,7 +159,7 @@ private async UniTask RequestTokenRefreshAsync(string refreshToken) }; var response = await httpClient.PostAsync( - "/auth/oauth2/refresh", + "/api/v1/auth/oauth2/refresh", refreshRequest, requiresAuth: false // 토큰 갱신은 인증 불필요 ); diff --git a/Assets/Infrastructure/Auth/Utils.meta b/Assets/Infrastructure/Auth/Utils.meta new file mode 100644 index 0000000..60e204b --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f87432ca0be4bfa4e801547fe86566f8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs new file mode 100644 index 0000000..d1a68b7 --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs @@ -0,0 +1,238 @@ +using System; +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.Utils +{ + /// + /// 디바이스 고유 ID 제공자 + /// 플랫폼별로 디바이스 고유 식별자를 생성/관리 + /// + public static class DeviceIdProvider + { + private const string DEVICE_ID_KEY = "projectvg_device_id"; + private static string _cachedDeviceId; + + /// + /// 디바이스 고유 ID 반환 + /// + /// 디바이스 고유 ID + public static string GetDeviceId() + { + if (!string.IsNullOrEmpty(_cachedDeviceId)) + { + return _cachedDeviceId; + } + + // PlayerPrefs에서 저장된 ID 확인 + if (PlayerPrefs.HasKey(DEVICE_ID_KEY)) + { + _cachedDeviceId = PlayerPrefs.GetString(DEVICE_ID_KEY); + Debug.Log($"[DeviceIdProvider] 저장된 디바이스 ID 로드: {MaskDeviceId(_cachedDeviceId)}"); + return _cachedDeviceId; + } + + // 새로운 디바이스 ID 생성 + _cachedDeviceId = GenerateDeviceId(); + + // PlayerPrefs에 저장 + PlayerPrefs.SetString(DEVICE_ID_KEY, _cachedDeviceId); + PlayerPrefs.Save(); + + Debug.Log($"[DeviceIdProvider] 새로운 디바이스 ID 생성: {MaskDeviceId(_cachedDeviceId)}"); + return _cachedDeviceId; + } + + /// + /// 플랫폼별 디바이스 ID 생성 + /// + private static string GenerateDeviceId() + { + string platformPrefix; + string platformId; + +#if UNITY_ANDROID && !UNITY_EDITOR + platformPrefix = "android"; + platformId = GetAndroidDeviceId(); +#elif UNITY_IOS && !UNITY_EDITOR + platformPrefix = "ios"; + platformId = GetIOSDeviceId(); +#elif UNITY_WEBGL && !UNITY_EDITOR + platformPrefix = "webgl"; + platformId = GetWebGLDeviceId(); +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + platformPrefix = "windows"; + platformId = GetWindowsDeviceId(); +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + platformPrefix = "macos"; + platformId = GetMacOSDeviceId(); +#elif UNITY_STANDALONE_LINUX && !UNITY_EDITOR + platformPrefix = "linux"; + platformId = GetLinuxDeviceId(); +#else + // Unity Editor 또는 알 수 없는 플랫폼 + platformPrefix = "editor"; + platformId = GetEditorDeviceId(); +#endif + + // 플랫폼 접두사 + 하이픈 + 디바이스 ID + 타임스탬프 + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return $"{platformPrefix}-{platformId}-{timestamp}"; + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static string GetAndroidDeviceId() + { + try + { + // Android Device ID 사용 + using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var currentActivity = unityClass.GetStatic("currentActivity")) + using (var contentResolver = currentActivity.Call("getContentResolver")) + using (var settingsSecure = new AndroidJavaClass("android.provider.Settings$Secure")) + { + string androidId = settingsSecure.CallStatic("getString", contentResolver, "android_id"); + if (!string.IsNullOrEmpty(androidId)) + { + return androidId; + } + } + } + catch (Exception ex) + { + Debug.LogError($"[DeviceIdProvider] Android ID 생성 실패: {ex.Message}"); + } + + // Fallback: SystemInfo.deviceUniqueIdentifier + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_IOS && !UNITY_EDITOR + private static string GetIOSDeviceId() + { + // iOS는 IDFV (Identifier for Vendor) 사용 + // SystemInfo.deviceUniqueIdentifier가 IDFV를 반환함 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_WEBGL && !UNITY_EDITOR + private static string GetWebGLDeviceId() + { + // WebGL에서는 브라우저 기반 ID 생성 + string browserId = SystemInfo.deviceUniqueIdentifier; + + // 추가로 브라우저 정보 포함 + string userAgent = Application.platform.ToString(); + string screenInfo = $"{Screen.width}x{Screen.height}"; + + return $"{browserId}-{userAgent.GetHashCode()}-{screenInfo.GetHashCode()}"; + } +#endif + +#if UNITY_STANDALONE_WIN && !UNITY_EDITOR + private static string GetWindowsDeviceId() + { + try + { + // Windows Machine GUID 사용 + var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + if (key != null) + { + var machineGuid = key.GetValue("MachineGuid")?.ToString(); + if (!string.IsNullOrEmpty(machineGuid)) + { + return machineGuid; + } + } + } + catch (Exception ex) + { + Debug.LogError($"[DeviceIdProvider] Windows Machine GUID 생성 실패: {ex.Message}"); + } + + // Fallback + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + private static string GetMacOSDeviceId() + { + // macOS는 SystemInfo.deviceUniqueIdentifier 사용 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_STANDALONE_LINUX && !UNITY_EDITOR + private static string GetLinuxDeviceId() + { + // Linux는 SystemInfo.deviceUniqueIdentifier 사용 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + + private static string GetEditorDeviceId() + { + // Unity Editor에서는 고정된 ID + 랜덤 요소 사용 + string editorId = SystemInfo.deviceUniqueIdentifier; + + // Editor에서는 개발 편의성을 위해 단순한 ID 생성 + if (string.IsNullOrEmpty(editorId) || editorId == "n/a") + { + editorId = $"editor-{Environment.MachineName}-{Environment.UserName}"; + } + + return editorId; + } + + /// + /// 디바이스 ID 마스킹 (로깅용) + /// + private static string MaskDeviceId(string deviceId) + { + if (string.IsNullOrEmpty(deviceId) || deviceId.Length < 8) + { + return "***"; + } + + // 앞 4자리와 뒤 4자리만 표시 + return $"{deviceId.Substring(0, 4)}****{deviceId.Substring(deviceId.Length - 4)}"; + } + + /// + /// 디바이스 ID 초기화 (테스트용) + /// + public static void ClearDeviceId() + { + _cachedDeviceId = null; + PlayerPrefs.DeleteKey(DEVICE_ID_KEY); + PlayerPrefs.Save(); + Debug.Log("[DeviceIdProvider] 디바이스 ID 초기화 완료"); + } + + /// + /// 현재 플랫폼 정보 반환 + /// + public static string GetPlatformInfo() + { + return $"Platform: {Application.platform}, " + + $"OS: {SystemInfo.operatingSystem}, " + + $"Device: {SystemInfo.deviceModel}"; + } + + /// + /// 디버그 정보 출력 + /// + public static string GetDebugInfo() + { + var info = "DeviceIdProvider Debug Info:\n"; + info += $"Cached Device ID: {MaskDeviceId(_cachedDeviceId)}\n"; + info += $"Has Stored ID: {PlayerPrefs.HasKey(DEVICE_ID_KEY)}\n"; + info += $"{GetPlatformInfo()}\n"; + info += $"System Device ID: {MaskDeviceId(SystemInfo.deviceUniqueIdentifier)}\n"; + + return info; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta new file mode 100644 index 0000000..1084a39 --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6e444377c5386384481442b3dd4f2105 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Configs/NetworkConfig.cs b/Assets/Infrastructure/Network/Configs/NetworkConfig.cs index d4c1d94..128fc09 100644 --- a/Assets/Infrastructure/Network/Configs/NetworkConfig.cs +++ b/Assets/Infrastructure/Network/Configs/NetworkConfig.cs @@ -203,11 +203,11 @@ public static string GetWebSocketUrlWithSession(string sessionId) public static bool IsJsonMessageType => Instance.wsMessageType?.ToLower() == "json"; public static bool IsBinaryMessageType => Instance.wsMessageType?.ToLower() == "binary"; - // HTTP URL 유틸 복원 + // HTTP URL 유틸 복원 - /api/v1 자동 추가 제거 public static string GetFullApiUrl(string endpoint) { var baseUrl = HttpServerAddress; - return $"{baseUrl.TrimEnd('/')}/{Instance.apiPath.TrimStart('/').TrimEnd('/')}/{Instance.apiVersion.TrimStart('/').TrimEnd('/')}/{endpoint.TrimStart('/')}"; + return $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}"; } public static string GetUserApiUrl(string path = "") => GetFullApiUrl($"users/{path.TrimStart('/')}"); public static string GetCharacterApiUrl(string path = "") => GetFullApiUrl($"characters/{path.TrimStart('/')}"); diff --git a/Assets/Infrastructure/Network/DTOs/Auth.meta b/Assets/Infrastructure/Network/DTOs/Auth.meta new file mode 100644 index 0000000..037369f --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02f5bf9ee5b574445a0f1cc302e4bde9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs new file mode 100644 index 0000000..5b4440d --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs @@ -0,0 +1,46 @@ +using System; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Network.DTOs.Auth +{ + /// + /// Guest 로그인 요청 DTO + /// + [Serializable] + public class GuestLoginRequest + { + /// + /// 게스트 ID (디바이스 고유 ID 기반) + /// + [JsonProperty("guestId")] + public string GuestId { get; set; } + + public GuestLoginRequest() + { + } + + public GuestLoginRequest(string guestId) + { + GuestId = guestId ?? throw new ArgumentNullException(nameof(guestId)); + } + + /// + /// 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(GuestId); + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var maskedGuestId = string.IsNullOrEmpty(GuestId) ? "null" : + GuestId.Length > 8 ? $"{GuestId.Substring(0, 4)}****{GuestId.Substring(GuestId.Length - 4)}" : "****"; + + return $"GuestLoginRequest: GuestId={maskedGuestId}, Valid={IsValid()}"; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta new file mode 100644 index 0000000..29eb019 --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f62442eca3b5793478030e3f21476485 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs new file mode 100644 index 0000000..ace624d --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs @@ -0,0 +1,168 @@ +using System; +using Newtonsoft.Json; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Network.DTOs.Auth +{ + /// + /// Guest 로그인 응답 DTO + /// + [Serializable] + public class GuestLoginResponse + { + /// + /// 성공 여부 + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// 토큰 정보 + /// + [JsonProperty("tokens")] + public GuestTokenInfo Tokens { get; set; } + + /// + /// 사용자 정보 + /// + [JsonProperty("user")] + public GuestUserInfo User { get; set; } + + /// + /// 오류 메시지 + /// + [JsonProperty("message")] + public string Message { get; set; } + + /// + /// TokenSet으로 변환 + /// + public TokenSet ToTokenSet() + { + if (Tokens == null) + { + return null; + } + + var accessToken = new AccessToken( + Tokens.AccessToken, + Tokens.ExpiresIn, + "Bearer", + "api" + ); + + RefreshToken refreshToken = null; + if (!string.IsNullOrEmpty(Tokens.RefreshToken)) + { + refreshToken = new RefreshToken( + Tokens.RefreshToken, + Tokens.RefreshExpiresIn, + User?.UserId ?? "guest" + ); + } + + return new TokenSet(accessToken, refreshToken); + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = $"GuestLoginResponse: Success={Success}"; + + if (!string.IsNullOrEmpty(Message)) + { + info += $", Message={Message}"; + } + + if (Tokens != null) + { + info += $", HasTokens=true"; + info += $", AccessTokenLength={Tokens.AccessToken?.Length ?? 0}"; + info += $", HasRefreshToken={!string.IsNullOrEmpty(Tokens.RefreshToken)}"; + } + + if (User != null) + { + info += $", UserId={User.UserId}"; + } + + return info; + } + } + + /// + /// Guest 토큰 정보 + /// + [Serializable] + public class GuestTokenInfo + { + /// + /// 액세스 토큰 + /// + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + /// + /// 리프레시 토큰 + /// + [JsonProperty("refreshToken")] + public string RefreshToken { get; set; } + + /// + /// 액세스 토큰 만료 시간 (초) + /// + [JsonProperty("expiresIn")] + public int ExpiresIn { get; set; } + + /// + /// 리프레시 토큰 만료 시간 (초) + /// + [JsonProperty("refreshExpiresIn")] + public int RefreshExpiresIn { get; set; } + + /// + /// 토큰 타입 + /// + [JsonProperty("tokenType")] + public string TokenType { get; set; } = "Bearer"; + } + + /// + /// Guest 사용자 정보 + /// + [Serializable] + public class GuestUserInfo + { + /// + /// 사용자 ID + /// + [JsonProperty("userId")] + public string UserId { get; set; } + + /// + /// 게스트 ID + /// + [JsonProperty("guestId")] + public string GuestId { get; set; } + + /// + /// 생성 시간 + /// + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 마지막 로그인 시간 + /// + [JsonProperty("lastLoginAt")] + public DateTime LastLoginAt { get; set; } + + /// + /// 사용자 타입 + /// + [JsonProperty("userType")] + public string UserType { get; set; } = "guest"; + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta new file mode 100644 index 0000000..338391b --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f57e18d77084bfa4e9cd5b8c9847957f \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Services/CharacterApiService.cs b/Assets/Infrastructure/Network/Services/CharacterApiService.cs index 605caed..541a382 100644 --- a/Assets/Infrastructure/Network/Services/CharacterApiService.cs +++ b/Assets/Infrastructure/Network/Services/CharacterApiService.cs @@ -35,7 +35,7 @@ public async UniTask GetAllCharactersAsync(CancellationToken ca return null; } - return await _httpClient.GetAsync("character", cancellationToken: cancellationToken); + return await _httpClient.GetAsync("/api/v1/character", cancellationToken: cancellationToken); } /// @@ -52,7 +52,7 @@ public async UniTask GetCharacterAsync(string characterId, Cancel return null; } - return await _httpClient.GetAsync($"character/{characterId}", cancellationToken: cancellationToken); + return await _httpClient.GetAsync($"/api/v1/character/{characterId}", cancellationToken: cancellationToken); } /// @@ -63,7 +63,7 @@ public async UniTask GetCharacterAsync(string characterId, Cancel /// 생성된 캐릭터 정보 public async UniTask CreateCharacterAsync(CreateCharacterRequest request, CancellationToken cancellationToken = default) { - return await _httpClient.PostAsync("character", request, cancellationToken: cancellationToken); + return await _httpClient.PostAsync("/api/v1/character", request, cancellationToken: cancellationToken); } /// @@ -102,7 +102,7 @@ public async UniTask CreateCharacterAsync( /// 수정된 캐릭터 정보 public async UniTask UpdateCharacterAsync(string characterId, UpdateCharacterRequest request, CancellationToken cancellationToken = default) { - return await _httpClient.PutAsync($"character/{characterId}", request, cancellationToken: cancellationToken); + return await _httpClient.PutAsync($"/api/v1/character/{characterId}", request, cancellationToken: cancellationToken); } /// @@ -143,7 +143,7 @@ public async UniTask DeleteCharacterAsync(string characterId, Cancellation { try { - await _httpClient.DeleteAsync($"character/{characterId}", cancellationToken: cancellationToken); + await _httpClient.DeleteAsync($"/api/v1/character/{characterId}", cancellationToken: cancellationToken); return true; } catch diff --git a/Assets/Infrastructure/Network/Services/ChatApiService.cs b/Assets/Infrastructure/Network/Services/ChatApiService.cs index dd8bced..be991ea 100644 --- a/Assets/Infrastructure/Network/Services/ChatApiService.cs +++ b/Assets/Infrastructure/Network/Services/ChatApiService.cs @@ -14,7 +14,7 @@ namespace ProjectVG.Infrastructure.Network.Services public class ChatApiService { private readonly HttpApiClient _httpClient; - private const string CHAT_ENDPOINT = "chat"; + private const string CHAT_ENDPOINT = "/api/v1/chat"; private const string DEFAULT_ACTION = "chat"; public ChatApiService() diff --git a/Assets/Infrastructure/Network/Services/STTService.cs b/Assets/Infrastructure/Network/Services/STTService.cs index 00549d8..20faccf 100644 --- a/Assets/Infrastructure/Network/Services/STTService.cs +++ b/Assets/Infrastructure/Network/Services/STTService.cs @@ -65,7 +65,7 @@ public async UniTask ConvertSpeechToTextAsync(byte[] audioData, string a // 서버 API에 맞게 language 파라미터만 사용 string forcedLanguage = "ko"; - string endpoint = $"stt/transcribe?language={forcedLanguage}"; + string endpoint = $"/api/v1/stt/transcribe?language={forcedLanguage}"; var response = await _httpClient.PostFormDataAsync(endpoint, formData, fileNames, null, requiresAuth: false, cancellationToken: cancellationToken); From 638d41cb503c04623373fdd5b418187d5e3db98d Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 26 Aug 2025 23:57:54 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20client=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=84=B8=EC=85=98=EC=97=90=EC=84=9C=20JWT?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/App/Scenes/MainScene.unity | 309 +++++++++-- Assets/Core/Managers/SystemManager.cs | 23 +- .../Auth/Examples/GuestLoginExample.cs | 15 - .../Auth/Examples/ServerOAuth2Example.cs | 40 +- Assets/Infrastructure/Auth/JwtTokenParser.cs | 47 ++ .../Auth/JwtTokenParser.cs.meta | 2 + .../Infrastructure/Auth/Models/AccessToken.cs | 133 +---- .../Auth/Models/RefreshToken.cs | 109 +--- Assets/Infrastructure/Auth/Models/TokenSet.cs | 13 +- .../Auth/OAuth2/ServerOAuth2Provider.cs | 2 +- .../Auth/Services/GuestAuthService.cs | 1 + Assets/Infrastructure/Auth/TokenManager.cs | 172 +----- .../Auth/TokenRefreshService.cs | 21 +- .../Network/DTOs/Auth/GuestLoginResponse.cs | 7 +- .../Network/Http/HttpApiClient.cs | 496 ++++++------------ .../Network/Services/ChatApiService.cs | 2 +- .../Network/Services/SessionManager.cs | 307 ----------- .../Network/Services/SessionManager.cs.meta | 2 - .../Network/WebSocket/WebSocketManager.cs | 144 +++-- 19 files changed, 599 insertions(+), 1246 deletions(-) create mode 100644 Assets/Infrastructure/Auth/JwtTokenParser.cs create mode 100644 Assets/Infrastructure/Auth/JwtTokenParser.cs.meta delete mode 100644 Assets/Infrastructure/Network/Services/SessionManager.cs delete mode 100644 Assets/Infrastructure/Network/Services/SessionManager.cs.meta diff --git a/Assets/App/Scenes/MainScene.unity b/Assets/App/Scenes/MainScene.unity index 7524bb3..d144ddf 100644 --- a/Assets/App/Scenes/MainScene.unity +++ b/Assets/App/Scenes/MainScene.unity @@ -582,7 +582,7 @@ GameObject: serializedVersion: 6 m_Component: - component: {fileID: 364439186} - - component: {fileID: 364439185} + - component: {fileID: 364439187} m_Layer: 0 m_Name: GameObject m_TagString: Untagged @@ -590,27 +590,6 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &364439185 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 364439184} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 59d58c6b995b5494abd828406d93edee, type: 3} - m_Name: - m_EditorClassIdentifier: - loginButton: {fileID: 73955218} - logoutButton: {fileID: 0} - refreshButton: {fileID: 0} - clearButton: {fileID: 0} - checkButton: {fileID: 0} - statusText: {fileID: 0} - userInfoText: {fileID: 0} - debugText: {fileID: 0} - oauth2Config: {fileID: 11400000, guid: e3e96e5220c979744a485156e5e314cd, type: 2} --- !u!4 &364439186 Transform: m_ObjectHideFlags: 0 @@ -626,6 +605,19 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &364439187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b273f87c4feba7c45b007339de53f770, type: 3} + m_Name: + m_EditorClassIdentifier: + showDebugUI: 1 --- !u!1 &396605756 stripped GameObject: m_CorrespondingSourceObject: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} @@ -1184,12 +1176,12 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1087467994} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 1104447313} + m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1087467996 MonoBehaviour: @@ -1208,7 +1200,7 @@ MonoBehaviour: _logContentParent: {fileID: 2076648994} _logEntryPrefab: {fileID: 6280972447933324029, guid: d74792089ff38e04a9a7c41def8766bb, type: 3} _clearButton: {fileID: 0} - _toggleButton: {fileID: 0} + _toggleButton: {fileID: 1871102646} _filterInput: {fileID: 0} _settings: {fileID: 11400000, guid: 8e5ce280818bafb45bd699331eb688e4, type: 2} --- !u!1 &1104447311 @@ -1240,8 +1232,7 @@ Transform: m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1087467995} + m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1104447314 @@ -1257,7 +1248,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: _webSocketManager: {fileID: 0} - _sessionManager: {fileID: 0} _httpApiClient: {fileID: 0} _audioManager: {fileID: 0} _loadingManager: {fileID: 0} @@ -1365,6 +1355,7 @@ RectTransform: - {fileID: 981746805} - {fileID: 396605757} - {fileID: 149056741} + - {fileID: 1871102645} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1419,6 +1410,142 @@ MonoBehaviour: _audioSource: {fileID: 0} _audioMixerGroup: {fileID: 0} _poolSize: 10 +--- !u!1 &1222649206 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1222649207} + - component: {fileID: 1222649209} + - component: {fileID: 1222649208} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1222649207 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1871102645} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1222649208 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Button + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 24 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1222649209 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_CullTransparentMesh: 1 --- !u!224 &1274474669 stripped RectTransform: m_CorrespondingSourceObject: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} @@ -1514,7 +1641,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 5385075895113204022, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_Value - value: 1 + value: 0 objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.x @@ -1813,6 +1940,127 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 2da0c512f12947e489f739169773d7ca, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &1871102644 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1871102645} + - component: {fileID: 1871102648} + - component: {fileID: 1871102647} + - component: {fileID: 1871102646} + m_Layer: 5 + m_Name: Button + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1871102645 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1222649207} + m_Father: {fileID: 1145326587} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 1} + m_AnchorMax: {x: 0.5, y: 1} + m_AnchoredPosition: {x: 41.880127, y: -37.550903} + m_SizeDelta: {x: 243.7604, y: 75.1018} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1871102646 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 1871102647} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &1871102647 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!222 &1871102648 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_CullTransparentMesh: 1 --- !u!114 &2017944365 stripped MonoBehaviour: m_CorrespondingSourceObject: {fileID: 7731201516897828228, guid: 290ac1b848f75584f9077b5dd03337f6, type: 3} @@ -1931,6 +2179,7 @@ SceneRoots: m_ObjectHideFlags: 0 m_Roots: - {fileID: 322172998} + - {fileID: 1087467995} - {fileID: 1104447313} - {fileID: 1145326587} - {fileID: 829067253} diff --git a/Assets/Core/Managers/SystemManager.cs b/Assets/Core/Managers/SystemManager.cs index b33cc2c..abc4075 100644 --- a/Assets/Core/Managers/SystemManager.cs +++ b/Assets/Core/Managers/SystemManager.cs @@ -16,7 +16,6 @@ public class SystemManager : Singleton { [Header("Core Managers")] [SerializeField] private WebSocketManager? _webSocketManager; - [SerializeField] private SessionManager? _sessionManager; [SerializeField] private HttpApiClient? _httpApiClient; [SerializeField] private AudioManager? _audioManager; [SerializeField] private LoadingManager? _loadingManager; @@ -35,7 +34,6 @@ public class SystemManager : Singleton public bool IsInitialized { get; private set; } public WebSocketManager? WebSocketManager => _webSocketManager; - public SessionManager? SessionManager => _sessionManager; public AudioManager? AudioManager => _audioManager; public LoadingManager? LoadingManager => _loadingManager; @@ -174,18 +172,9 @@ public async UniTask InitializeAppAsync() public void Shutdown() { try { _httpApiClient?.Shutdown(); } catch {} - try { _sessionManager?.Shutdown(); } catch {} try { _webSocketManager?.Shutdown(); } catch {} Debug.Log("[SystemManager] 시스템 종료 완료"); } - - [ContextMenu("Log Manager Status")] - public void LogManagerStatus() - { - Debug.Log($"[SystemManager] Initialized: {IsInitialized}"); - Debug.Log($"[SystemManager] Current Camera: {(_camera != null ? _camera.name : "null")}"); - Debug.Log($"[SystemManager] WS: {(WebSocketManager != null ? "OK" : "null")}, Session: {(SessionManager != null ? "OK" : "null")}, Audio: {(AudioManager != null ? "OK" : "null")}, Loading: {(LoadingManager != null ? "OK" : "null")}"); - } [ContextMenu("Update Camera")] public void UpdateCameraFromContextMenu() @@ -230,7 +219,6 @@ private void CreateManagersIfNotExist() if (_createManagersIfNotExist) { _webSocketManager = WebSocketManager.Instance; - _sessionManager = SessionManager.Instance; _httpApiClient = HttpApiClient.Instance; _audioManager = AudioManager.Instance; _loadingManager = LoadingManager.Instance; @@ -239,21 +227,14 @@ private void CreateManagersIfNotExist() private async UniTask InitializeManagersAsync() { - if (_webSocketManager == null || _sessionManager == null || _httpApiClient == null) + if (_webSocketManager == null || _httpApiClient == null) { throw new InvalidOperationException("필수 매니저 인스턴스를 찾을 수 없습니다."); } _loadingManager?.BeginLoadingUI(); _audioManager?.Initialize(); _webSocketManager.Initialize(); - _sessionManager.Initialize(_webSocketManager); - _httpApiClient.Initialize(_sessionManager); - - bool connected = await _sessionManager.EnsureConnectionAsync(); - if (!connected) - { - throw new InvalidOperationException("세션 연결 실패"); - } + _httpApiClient.Initialize(); } } diff --git a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs index 0f03266..6af2b46 100644 --- a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs +++ b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs @@ -117,15 +117,6 @@ private async System.Threading.Tasks.Task PerformGuestLoginAsync() } } - /// - /// 토큰 상태 확인 - /// - public void CheckTokenStatus() - { - Debug.Log("=== Token Status ==="); - Debug.Log(_tokenManager.GetDebugInfo()); - Debug.Log("==================="); - } /// /// Guest 로그인 상태 확인 @@ -200,12 +191,6 @@ private void OnGUI() GUILayout.Space(10); - // 상태 확인 버튼들 - if (GUILayout.Button("토큰 상태 확인")) - { - CheckTokenStatus(); - } - if (GUILayout.Button("Guest 로그인 상태 확인")) { CheckGuestLoginStatus(); diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs index db72151..1497ccd 100644 --- a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs @@ -175,7 +175,7 @@ private async UniTaskVoid OnLoginButtonClicked() // 전체 OAuth2 로그인 플로우 실행 _currentTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); - if (_currentTokenSet?.IsValid() == true) + if (_currentTokenSet?.HasRefreshToken() == true) { _isLoggedIn = true; ShowStatus("서버 OAuth2 로그인 성공!", Color.green); @@ -256,11 +256,10 @@ private async UniTaskVoid StartAutoTokenRefresh() { Debug.Log("[ServerOAuth2Example] 토큰 갱신 시작"); - // TODO: 서버 OAuth2 토큰 갱신 API 호출 // 현재는 전체 재로그인 플로우 실행 var newTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); - if (newTokenSet?.IsValid() == true) + if (newTokenSet?.HasRefreshToken() == true) { _currentTokenSet = newTokenSet; Debug.Log("[ServerOAuth2Example] 토큰 갱신 성공"); @@ -453,20 +452,9 @@ private void DisplayTokenInfo() info += $"Access Token: {_currentTokenSet.AccessToken?.Token?.Substring(0, Math.Min(20, _currentTokenSet.AccessToken.Token.Length))}...\n"; info += $"Access Token 만료: {_currentTokenSet.AccessToken?.ExpiresAt:yyyy-MM-dd HH:mm:ss}\n"; info += $"Refresh Token: {(string.IsNullOrEmpty(_currentTokenSet.RefreshToken?.Token) ? "없음" : "있음")}\n"; - info += $"토큰 유효: {_currentTokenSet.IsValid()}\n"; info += $"갱신 필요: {_currentTokenSet.NeedsRefresh()}"; userInfoText.text = info; - - // 디버그 정보도 업데이트 - if (debugText != null) - { - var debugInfo = "=== TokenManager 상태 ===\n"; - debugInfo += _tokenManager.GetDebugInfo(); - debugInfo += "\n=== TokenRefreshService 상태 ===\n"; - debugInfo += _refreshService.GetDebugInfo(); - debugText.text = debugInfo; - } } /// @@ -488,8 +476,6 @@ private void LogDetailedTokenInfo() Debug.Log($"✅ Access Token: {_currentTokenSet.AccessToken.Token}"); Debug.Log($" - 만료 시간: {_currentTokenSet.AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss}"); Debug.Log($" - 만료까지: {(_currentTokenSet.AccessToken.ExpiresAt - DateTime.UtcNow).TotalMinutes:F1}분"); - Debug.Log($" - 토큰 타입: {_currentTokenSet.AccessToken.TokenType}"); - Debug.Log($" - 스코프: {_currentTokenSet.AccessToken.Scope}"); } else { @@ -511,7 +497,6 @@ private void LogDetailedTokenInfo() // 토큰 세트 상태 Debug.Log($"📊 토큰 세트 상태:"); - Debug.Log($" - 유효성: {_currentTokenSet.IsValid()}"); Debug.Log($" - 갱신 필요: {_currentTokenSet.NeedsRefresh()}"); Debug.Log($" - Refresh Token 보유: {_currentTokenSet.HasRefreshToken()}"); @@ -542,26 +527,7 @@ public TokenSet GetCurrentTokenSet() { return _currentTokenSet; } - - /// - /// 로그인 상태 조회 - /// - public bool IsLoggedIn() - { - return _isLoggedIn && _currentTokenSet?.IsValid() == true; - } - - /// - /// 유효한 Access Token 조회 - /// - public string GetValidAccessToken() - { - if (IsLoggedIn() && _currentTokenSet?.AccessToken?.IsValid() == true) - { - return _currentTokenSet.AccessToken.Token; - } - return null; - } + #endregion } diff --git a/Assets/Infrastructure/Auth/JwtTokenParser.cs b/Assets/Infrastructure/Auth/JwtTokenParser.cs new file mode 100644 index 0000000..e5514f3 --- /dev/null +++ b/Assets/Infrastructure/Auth/JwtTokenParser.cs @@ -0,0 +1,47 @@ +using System; +using System.Text; +using UnityEngine; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth +{ + public static class JwtTokenParser + { + public static DateTime GetExpirationTime(string token) + { + if (string.IsNullOrEmpty(token)) + return DateTime.MinValue; + + try + { + var parts = token.Split('.'); + if (parts.Length != 3) + return DateTime.MinValue; + + var payload = parts[1]; + payload = PadBase64String(payload); + + var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + var jwtPayload = JsonConvert.DeserializeObject(payloadJson); + + return DateTimeOffset.FromUnixTimeSeconds(jwtPayload.exp).DateTime; + } + catch (Exception ex) + { + Debug.LogError($"[JwtTokenParser] JWT 파싱 실패: {ex.Message}"); + return DateTime.MinValue; + } + } + + private static string PadBase64String(string base64) + { + var padding = base64.Length % 4; + return padding > 0 ? base64.PadRight(base64.Length + (4 - padding), '=') : base64; + } + + private class JwtPayload + { + public long exp { get; set; } + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta b/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta new file mode 100644 index 0000000..d9d86be --- /dev/null +++ b/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 56aba44677b62804d97d984948b307f6 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs b/Assets/Infrastructure/Auth/Models/AccessToken.cs index e3fbdfa..7ea7d9d 100644 --- a/Assets/Infrastructure/Auth/Models/AccessToken.cs +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs @@ -2,148 +2,23 @@ namespace ProjectVG.Infrastructure.Auth.Models { - /// - /// Access Token 모델 - /// [Serializable] public class AccessToken { - /// - /// 토큰 문자열 - /// public string Token { get; set; } - - /// - /// 만료 시간 (초) - /// - public int ExpiresIn { get; set; } - - /// - /// 만료 시간 (DateTime) - /// public DateTime ExpiresAt { get; set; } - /// - /// 토큰 타입 (예: Bearer) - /// - public string TokenType { get; set; } - - /// - /// 토큰 스코프 - /// - public string Scope { get; set; } - - /// - /// 생성 시간 - /// - public DateTime CreatedAt { get; set; } - - public AccessToken() - { - CreatedAt = DateTime.UtcNow; - } - - public AccessToken(string token, int expiresIn, string tokenType = "Bearer", string scope = "") - { - Token = token ?? throw new ArgumentNullException(nameof(token)); - ExpiresIn = expiresIn; - TokenType = tokenType ?? "Bearer"; - Scope = scope ?? ""; - CreatedAt = DateTime.UtcNow; - ExpiresAt = CreatedAt.AddSeconds(expiresIn); - } + public AccessToken() { } - public AccessToken(string token, DateTime expiresAt, string tokenType = "Bearer", string scope = "") + public AccessToken(string token) { Token = token ?? throw new ArgumentNullException(nameof(token)); - ExpiresAt = expiresAt; - TokenType = tokenType ?? "Bearer"; - Scope = scope ?? ""; - CreatedAt = DateTime.UtcNow; - ExpiresIn = (int)(expiresAt - CreatedAt).TotalSeconds; - } - - /// - /// 토큰 유효성 검사 - /// - public bool IsValid() - { - return !string.IsNullOrEmpty(Token) && !IsExpired(); + ExpiresAt = JwtTokenParser.GetExpirationTime(token); } - /// - /// 토큰 만료 여부 확인 - /// public bool IsExpired() { return DateTime.UtcNow >= ExpiresAt; } - - /// - /// 토큰 만료까지 남은 시간 - /// - public TimeSpan TimeUntilExpiry() - { - var timeLeft = ExpiresAt - DateTime.UtcNow; - return timeLeft > TimeSpan.Zero ? timeLeft : TimeSpan.Zero; - } - - /// - /// 토큰 만료까지 남은 시간 (초) - /// - public int SecondsUntilExpiry() - { - return (int)TimeUntilExpiry().TotalSeconds; - } - - /// - /// 토큰이 곧 만료될 예정인지 확인 - /// - /// 버퍼 시간 (분) - /// 곧 만료될 예정인지 여부 - public bool IsExpiringSoon(int bufferMinutes = 5) - { - var timeLeft = TimeUntilExpiry(); - return timeLeft <= TimeSpan.FromMinutes(bufferMinutes); - } - - /// - /// Authorization 헤더 값 생성 - /// - /// Authorization 헤더 값 - public string GetAuthorizationHeader() - { - return $"{TokenType} {Token}"; - } - - /// - /// 토큰 정보 복사 - /// - /// 새로운 AccessToken 인스턴스 - public AccessToken Clone() - { - return new AccessToken(Token, ExpiresAt, TokenType, Scope); - } - - /// - /// 디버그 정보 출력 - /// - /// 디버그 정보 - public string GetDebugInfo() - { - var info = $"AccessToken Debug Info:\n"; - info += $"Token: {Token?.Substring(0, Math.Min(20, Token.Length))}...\n"; - info += $"Expires In: {ExpiresIn}초\n"; - info += $"Expires At: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; - info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; - info += $"Token Type: {TokenType}\n"; - info += $"Scope: {Scope}\n"; - info += $"Is Valid: {IsValid()}\n"; - info += $"Is Expired: {IsExpired()}\n"; - info += $"Time Until Expiry: {TimeUntilExpiry()}\n"; - info += $"Is Expiring Soon: {IsExpiringSoon()}\n"; - - return info; - } } -} +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/RefreshToken.cs b/Assets/Infrastructure/Auth/Models/RefreshToken.cs index c153f66..b42c945 100644 --- a/Assets/Infrastructure/Auth/Models/RefreshToken.cs +++ b/Assets/Infrastructure/Auth/Models/RefreshToken.cs @@ -2,131 +2,32 @@ namespace ProjectVG.Infrastructure.Auth.Models { - /// - /// Refresh Token 모델 - /// [Serializable] public class RefreshToken { - /// - /// 토큰 문자열 - /// public string Token { get; set; } - - /// - /// 만료 시간 (초) - /// - public int ExpiresIn { get; set; } - - /// - /// 만료 시간 (DateTime) - /// public DateTime ExpiresAt { get; set; } - - /// - /// 디바이스 ID - /// public string DeviceId { get; set; } - /// - /// 생성 시간 - /// - public DateTime CreatedAt { get; set; } - - public RefreshToken() - { - CreatedAt = DateTime.UtcNow; - } + public RefreshToken() { } public RefreshToken(string token, int expiresIn, string deviceId = null) { Token = token ?? throw new ArgumentNullException(nameof(token)); - ExpiresIn = expiresIn; - DeviceId = deviceId ?? ""; - CreatedAt = DateTime.UtcNow; - ExpiresAt = CreatedAt.AddSeconds(expiresIn); + ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); + DeviceId = deviceId ?? string.Empty; } public RefreshToken(string token, DateTime expiresAt, string deviceId = null) { Token = token ?? throw new ArgumentNullException(nameof(token)); ExpiresAt = expiresAt; - DeviceId = deviceId ?? ""; - CreatedAt = DateTime.UtcNow; - ExpiresIn = (int)(expiresAt - CreatedAt).TotalSeconds; + DeviceId = deviceId ?? string.Empty; } - /// - /// 토큰 유효성 검사 - /// - public bool IsValid() - { - return !string.IsNullOrEmpty(Token) && !IsExpired(); - } - - /// - /// 토큰 만료 여부 확인 - /// public bool IsExpired() { return DateTime.UtcNow >= ExpiresAt; } - - /// - /// 토큰 만료까지 남은 시간 - /// - public TimeSpan TimeUntilExpiry() - { - var timeLeft = ExpiresAt - DateTime.UtcNow; - return timeLeft > TimeSpan.Zero ? timeLeft : TimeSpan.Zero; - } - - /// - /// 토큰 만료까지 남은 시간 (초) - /// - public int SecondsUntilExpiry() - { - return (int)TimeUntilExpiry().TotalSeconds; - } - - /// - /// 토큰이 곧 만료될 예정인지 확인 - /// - /// 버퍼 시간 (일) - /// 곧 만료될 예정인지 여부 - public bool IsExpiringSoon(int bufferDays = 7) - { - var timeLeft = TimeUntilExpiry(); - return timeLeft <= TimeSpan.FromDays(bufferDays); - } - - /// - /// 토큰 정보 복사 - /// - /// 새로운 RefreshToken 인스턴스 - public RefreshToken Clone() - { - return new RefreshToken(Token, ExpiresAt, DeviceId); - } - - /// - /// 디버그 정보 출력 - /// - /// 디버그 정보 - public string GetDebugInfo() - { - var info = $"RefreshToken Debug Info:\n"; - info += $"Token: {Token?.Substring(0, Math.Min(20, Token.Length))}...\n"; - info += $"Expires In: {ExpiresIn}초\n"; - info += $"Expires At: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; - info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; - info += $"Device ID: {DeviceId}\n"; - info += $"Is Valid: {IsValid()}\n"; - info += $"Is Expired: {IsExpired()}\n"; - info += $"Time Until Expiry: {TimeUntilExpiry()}\n"; - info += $"Is Expiring Soon: {IsExpiringSoon()}\n"; - - return info; - } } -} +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/TokenSet.cs b/Assets/Infrastructure/Auth/Models/TokenSet.cs index 0d786fb..a0c84d1 100644 --- a/Assets/Infrastructure/Auth/Models/TokenSet.cs +++ b/Assets/Infrastructure/Auth/Models/TokenSet.cs @@ -35,14 +35,6 @@ public TokenSet(AccessToken accessToken, RefreshToken refreshToken = null) CreatedAt = DateTime.UtcNow; } - /// - /// 토큰 세트 유효성 검사 - /// - public bool IsValid() - { - return AccessToken != null && AccessToken.IsValid(); - } - /// /// Access Token이 만료되었는지 확인 /// @@ -56,7 +48,7 @@ public bool IsAccessTokenExpired() /// public bool HasRefreshToken() { - return RefreshToken != null && RefreshToken.IsValid(); + return RefreshToken != null && !IsRefreshTokenExpired(); } /// @@ -120,7 +112,6 @@ public string GetDebugInfo() { var info = $"TokenSet Debug Info:\n"; info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; - info += $"Is Valid: {IsValid()}\n"; info += $"Has Refresh Token: {HasRefreshToken()}\n"; info += $"Needs Refresh: {NeedsRefresh()}\n"; @@ -130,8 +121,6 @@ public string GetDebugInfo() info += $" Token: {AccessToken.Token?.Substring(0, Math.Min(20, AccessToken.Token.Length))}...\n"; info += $" Expires At: {AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; info += $" Is Expired: {AccessToken.IsExpired()}\n"; - info += $" Token Type: {AccessToken.TokenType}\n"; - info += $" Scope: {AccessToken.Scope}\n"; } if (RefreshToken != null) diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs index 2066da2..b85b730 100644 --- a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -262,7 +262,7 @@ public async Task RequestTokenAsync(string state) } // 3. TokenSet 생성 - var accessTokenModel = new AccessToken(accessToken, expiresIn, "Bearer", "oauth2"); + var accessTokenModel = new AccessToken(accessToken); var refreshTokenModel = !string.IsNullOrEmpty(refreshToken) ? new RefreshToken(refreshToken, expiresIn * 2, userId) // userId를 DeviceId로 사용 : null; diff --git a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs index 7e2680e..f1d972f 100644 --- a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs +++ b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs @@ -108,6 +108,7 @@ public async UniTask LoginAsGuestAsync() Debug.Log("[GuestAuthService] Guest 로그인 성공"); Debug.Log($"[GuestAuthService] 사용자 ID: {response.User?.UserId}"); + Debug.Log($"[GuestAuthService] AccessToken : {tokenSet.AccessToken.Token}"); Debug.Log($"[GuestAuthService] AccessToken 만료: {tokenSet.AccessToken.ExpiresAt}"); // 성공 이벤트 발생 diff --git a/Assets/Infrastructure/Auth/TokenManager.cs b/Assets/Infrastructure/Auth/TokenManager.cs index 47f1e8f..dc7182a 100644 --- a/Assets/Infrastructure/Auth/TokenManager.cs +++ b/Assets/Infrastructure/Auth/TokenManager.cs @@ -5,17 +5,11 @@ using UnityEngine; using ProjectVG.Infrastructure.Auth.Models; using Newtonsoft.Json; -using ProjectVG.Infrastructure.Auth.Models; namespace ProjectVG.Infrastructure.Auth { - /// - /// OAuth2 토큰 관리자 - /// Access 토큰과 Refresh 토큰을 안전하게 저장하고 관리 - /// public class TokenManager : MonoBehaviour { - // AccessToken은 메모리에만 저장하므로 ACCESS_TOKEN_KEY, TOKEN_EXPIRY_KEY 는 제거 private const string REFRESH_TOKEN_KEY = "refresh_token"; private const string USER_ID_KEY = "user_id"; private const string ENCRYPTION_KEY = "ProjectVG_OAuth2_Secure_Key_2024"; @@ -55,10 +49,8 @@ private void Awake() DontDestroyOnLoad(gameObject); LoadTokensFromStorage(); - // 앱 시작 시 RefreshToken이 있으면 자동으로 AccessToken 복구 시도 if (HasRefreshToken && !IsRefreshTokenExpired()) { - Debug.Log("[TokenManager] RefreshToken 발견 - 자동 AccessToken 복구 시작"); StartCoroutine(AutoRecoverAccessTokenCoroutine()); } } @@ -68,9 +60,6 @@ private void Awake() } } - /// - /// 토큰 세트 저장 - /// public void SaveTokens(TokenSet tokenSet) { if (tokenSet?.AccessToken == null) @@ -81,24 +70,19 @@ public void SaveTokens(TokenSet tokenSet) try { - // AccessToken은 메모리에만 저장 (영속적 저장 안함) _currentAccessToken = tokenSet.AccessToken; _currentRefreshToken = tokenSet.RefreshToken; _currentUserId = tokenSet.RefreshToken?.DeviceId; - // Refresh Token 저장 (강력한 암호화) - string encryptedRefreshToken = null; if (_currentRefreshToken != null) { var refreshTokenData = new TokenStorageData { Token = _currentRefreshToken.Token, ExpiresAt = _currentRefreshToken.ExpiresAt, - TokenType = "Refresh", - Scope = "oauth2", UserId = _currentRefreshToken.DeviceId }; - encryptedRefreshToken = EncryptData(JsonConvert.SerializeObject(refreshTokenData)); + var encryptedRefreshToken = EncryptData(JsonConvert.SerializeObject(refreshTokenData)); PlayerPrefs.SetString(REFRESH_TOKEN_KEY, encryptedRefreshToken); } else @@ -106,33 +90,13 @@ public void SaveTokens(TokenSet tokenSet) PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); } - // AccessToken 만료 시간은 메모리에만 보관 (PlayerPrefs 저장 안함) - - // User ID 저장 if (!string.IsNullOrEmpty(_currentUserId)) { PlayerPrefs.SetString(USER_ID_KEY, _currentUserId); } PlayerPrefs.Save(); - - Debug.Log("=== 🔐 TokenManager 토큰 저장 완료 ==="); - Debug.Log($"[TokenManager] Access Token 저장: 메모리 전용 (영속적 저장 안함)"); - Debug.Log($"[TokenManager] Access Token 만료: {_currentAccessToken.ExpiresAt}"); - - if (_currentRefreshToken != null) - { - Debug.Log($"[TokenManager] Refresh Token 저장 위치: PlayerPrefs['{REFRESH_TOKEN_KEY}']"); - Debug.Log($"[TokenManager] Refresh Token 만료: {_currentRefreshToken.ExpiresAt}"); - Debug.Log($"[TokenManager] Refresh Token 암호화: {!string.IsNullOrEmpty(encryptedRefreshToken)}"); - } - else - { - Debug.Log("[TokenManager] Refresh Token: 없음"); - } - - Debug.Log($"[TokenManager] User ID 저장 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); - Debug.Log("=== 토큰 저장 완료 ==="); + Debug.Log("[TokenManager] 토큰 저장 완료"); OnTokensUpdated?.Invoke(tokenSet); } @@ -143,9 +107,6 @@ public void SaveTokens(TokenSet tokenSet) } } - /// - /// 저장된 토큰 로드 - /// public TokenSet LoadTokens() { if (_currentAccessToken != null && _currentRefreshToken != null) @@ -163,9 +124,6 @@ public TokenSet LoadTokens() return null; } - /// - /// Access Token만 반환 - /// public string GetAccessToken() { if (_currentAccessToken != null && !_currentAccessToken.IsExpired()) @@ -173,44 +131,30 @@ public string GetAccessToken() return _currentAccessToken.Token; } - // 만료된 경우 Refresh Token으로 갱신 시도 if (_currentRefreshToken != null && !_currentRefreshToken.IsExpired()) { - Debug.Log("[TokenManager] Access Token이 만료되었습니다. Refresh Token으로 갱신을 시도하세요."); OnTokensExpired?.Invoke(); } return null; } - /// - /// Refresh Token 반환 - /// public string GetRefreshToken() { return _currentRefreshToken?.Token; } - /// - /// 토큰 만료 여부 확인 - /// public bool IsAccessTokenExpired() { return _currentAccessToken?.IsExpired() ?? true; } - /// - /// Refresh Token 만료 여부 확인 - /// public bool IsRefreshTokenExpired() { return _currentRefreshToken?.IsExpired() ?? true; } - /// - /// 토큰 갱신 - /// - public void UpdateAccessToken(string newAccessToken, int expiresInSeconds) + public void UpdateAccessToken(string newAccessToken) { if (string.IsNullOrEmpty(newAccessToken)) { @@ -220,11 +164,7 @@ public void UpdateAccessToken(string newAccessToken, int expiresInSeconds) try { - // AccessToken은 메모리에만 업데이트 (영속적 저장 안함) - _currentAccessToken = new AccessToken(newAccessToken, expiresInSeconds, "Bearer", "oauth2"); - - Debug.Log("[TokenManager] Access Token 갱신 완료 (메모리 전용)"); - Debug.Log($"[TokenManager] 새로운 만료 시간: {_currentAccessToken.ExpiresAt}"); + _currentAccessToken = new AccessToken(newAccessToken); var tokenSet = new TokenSet(_currentAccessToken, _currentRefreshToken); OnTokensUpdated?.Invoke(tokenSet); @@ -236,24 +176,18 @@ public void UpdateAccessToken(string newAccessToken, int expiresInSeconds) } } - /// - /// 모든 토큰 삭제 - /// public void ClearTokens() { try { - // 메모리에서 AccessToken 제거 _currentAccessToken = null; _currentRefreshToken = null; _currentUserId = null; - // RefreshToken만 PlayerPrefs에서 삭제 (기존 ACCESS_TOKEN_KEY, TOKEN_EXPIRY_KEY 삭제 로직 제거) PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); PlayerPrefs.DeleteKey(USER_ID_KEY); PlayerPrefs.Save(); - Debug.Log("[TokenManager] 모든 토큰 삭제 완료"); OnTokensCleared?.Invoke(); } catch (Exception ex) @@ -263,18 +197,12 @@ public void ClearTokens() } } - /// - /// 저장소에서 토큰 로드 - /// private void LoadTokensFromStorage() { try { - // AccessToken은 저장소에서 로드하지 않음 (메모리 전용) - // 앱 시작 시 AccessToken은 null 상태로 시작 _currentAccessToken = null; - // Refresh Token 로드 if (PlayerPrefs.HasKey(REFRESH_TOKEN_KEY)) { var encryptedRefreshToken = PlayerPrefs.GetString(REFRESH_TOKEN_KEY); @@ -284,42 +212,22 @@ private void LoadTokensFromStorage() _currentRefreshToken = new RefreshToken( refreshTokenData.Token, refreshTokenData.ExpiresAt, - refreshTokenData.UserId // UserId 필드에 DeviceId가 저장되어 있음 + refreshTokenData.UserId ); } - // User ID 로드 if (PlayerPrefs.HasKey(USER_ID_KEY)) { _currentUserId = PlayerPrefs.GetString(USER_ID_KEY); } - - Debug.Log("=== 🔍 TokenManager 저장소에서 토큰 로드 완료 ==="); - Debug.Log($"[TokenManager] Access Token: 메모리 전용 (영속적 로드 안함)"); - Debug.Log($"[TokenManager] Access Token 상태: null (앱 시작 시 기본값)"); - - Debug.Log($"[TokenManager] Refresh Token 로드 위치: PlayerPrefs['{REFRESH_TOKEN_KEY}']"); - Debug.Log($"[TokenManager] Refresh Token 존재: {_currentRefreshToken != null}"); - if (_currentRefreshToken != null) - { - Debug.Log($"[TokenManager] Refresh Token 만료: {_currentRefreshToken.ExpiresAt}"); - Debug.Log($"[TokenManager] Refresh Token 유효: {!_currentRefreshToken.IsExpired()}"); - } - - Debug.Log($"[TokenManager] User ID 로드 위치: PlayerPrefs['{USER_ID_KEY}'] = {_currentUserId}"); - Debug.Log("=== 토큰 로드 완료 ==="); } catch (Exception ex) { Debug.LogError($"[TokenManager] 토큰 로드 실패: {ex.Message}"); - // 로드 실패 시 모든 토큰 삭제 ClearTokens(); } } - /// - /// 데이터 암호화 - /// private string EncryptData(string data) { try @@ -348,9 +256,6 @@ private string EncryptData(string data) } } - /// - /// 데이터 복호화 - /// private string DecryptData(string encryptedData) { try @@ -376,100 +281,37 @@ private string DecryptData(string encryptedData) } } - /// - /// 디버그 정보 출력 - /// - public string GetDebugInfo() - { - var info = "TokenManager Debug Info:\n"; - info += $"=== 저장 위치 정보 ===\n"; - info += $"Access Token 저장: 메모리 전용 (영속적 저장 안함)\n"; - info += $"Refresh Token 저장: PlayerPrefs['{REFRESH_TOKEN_KEY}']\n"; - info += $"User ID 저장: PlayerPrefs['{USER_ID_KEY}']\n"; - info += $"=== 토큰 상태 ===\n"; - info += $"Has Valid Tokens: {HasValidTokens}\n"; - info += $"Has Refresh Token: {HasRefreshToken}\n"; - info += $"Access Token Expired: {IsAccessTokenExpired()}\n"; - info += $"Refresh Token Expired: {IsRefreshTokenExpired()}\n"; - info += $"User ID: {_currentUserId}\n"; - - if (_currentAccessToken != null) - { - info += $"Access Token Expires: {_currentAccessToken.ExpiresAt}\n"; - info += $"Access Token Type: {_currentAccessToken.TokenType}\n"; - info += $"Access Token 유효: {!_currentAccessToken.IsExpired()}\n"; - } - - if (_currentRefreshToken != null) - { - info += $"Refresh Token Expires: {_currentRefreshToken.ExpiresAt}\n"; - info += $"Refresh Token Device ID: {_currentRefreshToken.DeviceId}\n"; - info += $"Refresh Token 유효: {!_currentRefreshToken.IsExpired()}\n"; - } - - return info; - } - - /// - /// 앱 시작 시 RefreshToken으로 AccessToken 자동 복구 - /// private IEnumerator AutoRecoverAccessTokenCoroutine() { - // TokenRefreshService가 초기화될 때까지 대기 yield return new WaitForSeconds(0.5f); if (TokenRefreshService.Instance != null) { - Debug.Log("[TokenManager] TokenRefreshService를 통해 AccessToken 복구 시도"); - - // 비동기 호출을 코루틴에서 처리 StartCoroutine(TryRefreshTokenCoroutine()); } - else - { - Debug.LogWarning("[TokenManager] TokenRefreshService가 아직 초기화되지 않았습니다."); - } } - /// - /// RefreshToken으로 AccessToken 복구 코루틴 - /// private IEnumerator TryRefreshTokenCoroutine() { var refreshTask = TokenRefreshService.Instance.RefreshAccessTokenAsync(); - - // UniTask를 코루틴에서 기다림 yield return new WaitUntil(() => refreshTask.Status != Cysharp.Threading.Tasks.UniTaskStatus.Pending); if (refreshTask.Status == Cysharp.Threading.Tasks.UniTaskStatus.Succeeded) { bool success = refreshTask.GetAwaiter().GetResult(); - if (success) - { - Debug.Log("[TokenManager] 앱 시작 시 AccessToken 자동 복구 성공"); - } - else + if (!success) { Debug.LogWarning("[TokenManager] 앱 시작 시 AccessToken 자동 복구 실패"); } } - else - { - Debug.LogError("[TokenManager] AccessToken 복구 중 오류 발생"); - } } } - /// - /// 토큰 저장용 데이터 구조 - /// [Serializable] public class TokenStorageData { public string Token { get; set; } public DateTime ExpiresAt { get; set; } - public string TokenType { get; set; } - public string Scope { get; set; } public string UserId { get; set; } } -} +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs b/Assets/Infrastructure/Auth/TokenRefreshService.cs index 41814bc..7ec0c37 100644 --- a/Assets/Infrastructure/Auth/TokenRefreshService.cs +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs @@ -169,13 +169,7 @@ private async UniTask RequestTokenRefreshAsync(string refreshToken) throw new InvalidOperationException($"토큰 갱신 실패: {response?.Message ?? "응답이 null입니다."}"); } - // 새로운 토큰 생성 - var accessToken = new AccessToken( - response.AccessToken, - response.ExpiresIn, - "Bearer", - "oauth2" - ); + var accessToken = new AccessToken(response.AccessToken); var newRefreshToken = !string.IsNullOrEmpty(response.RefreshToken) ? new RefreshToken(response.RefreshToken, response.ExpiresIn * 2, response.UserId) // UserId를 DeviceId로 사용 @@ -223,19 +217,6 @@ public async UniTask EnsureValidTokenAsync() return false; } - /// - /// 디버그 정보 출력 - /// - public string GetDebugInfo() - { - var info = "TokenRefreshService Debug Info:\n"; - info += $"Is Refreshing: {_isRefreshing}\n"; - info += $"Has Valid Tokens: {_tokenManager.HasValidTokens}\n"; - info += $"Has Refresh Token: {_tokenManager.HasRefreshToken}\n"; - info += $"Access Token Expired: {_tokenManager.IsAccessTokenExpired()}\n"; - info += $"Refresh Token Expired: {_tokenManager.IsRefreshTokenExpired()}\n"; - return info; - } private void OnDestroy() { diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs index ace624d..bc3bfa2 100644 --- a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs @@ -44,12 +44,7 @@ public TokenSet ToTokenSet() return null; } - var accessToken = new AccessToken( - Tokens.AccessToken, - Tokens.ExpiresIn, - "Bearer", - "api" - ); + var accessToken = new AccessToken(Tokens.AccessToken); RefreshToken refreshToken = null; if (!string.IsNullOrEmpty(Tokens.RefreshToken)) diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index 5d60e0d..c43636f 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -19,10 +19,10 @@ public class HttpApiClient : Singleton private const string ACCEPT_HEADER = "application/json"; private const string AUTHORIZATION_HEADER = "Authorization"; private const string BEARER_PREFIX = "Bearer "; + private const string DEFAULT_FILE_NAME = "file.wav"; private readonly Dictionary defaultHeaders = new Dictionary(); private CancellationTokenSource cancellationTokenSource; - private SessionManager _sessionManager; public bool IsInitialized { get; private set; } #region Unity Lifecycle @@ -44,9 +44,8 @@ private void OnDestroy() /// /// 초기화 실행 /// - public void Initialize(SessionManager sessionManager) + public void Initialize() { - _sessionManager = sessionManager; cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); @@ -55,83 +54,55 @@ public void Initialize(SessionManager sessionManager) IsInitialized = true; } - /// - /// 기본 헤더 추가 - /// public void AddDefaultHeader(string key, string value) { defaultHeaders[key] = value; } - /// - /// 기본 헤더 제거 - /// public void RemoveDefaultHeader(string key) { - if (defaultHeaders.ContainsKey(key)) - { - defaultHeaders.Remove(key); - Debug.Log($"[HttpApiClient] 기본 헤더 제거: {key}"); - } + defaultHeaders.Remove(key); } - /// - /// 인증 토큰 설정 - /// public void SetAuthToken(string token) { AddDefaultHeader(AUTHORIZATION_HEADER, $"{BEARER_PREFIX}{token}"); } - /// - /// TokenManager에서 자동으로 토큰 설정 - /// - public void SetAuthTokenFromManager() + private void EnsureAuthToken(bool requiresAuth) { + if (!requiresAuth) return; + try { - Debug.Log("[HttpApiClient] TokenManager에서 Access Token 요청 중..."); var tokenManager = ProjectVG.Infrastructure.Auth.TokenManager.Instance; var accessToken = tokenManager.GetAccessToken(); - + + Debug.Log($"[HttpApiClient] Access Token: {accessToken}"); + if (!string.IsNullOrEmpty(accessToken)) { SetAuthToken(accessToken); - Debug.Log($"[HttpApiClient] TokenManager에서 Access Token 설정 완료 (토큰 길이: {accessToken.Length})"); } else { - Debug.LogWarning("[HttpApiClient] TokenManager에서 유효한 Access Token을 찾을 수 없습니다."); - // Authorization 헤더 제거 + Debug.LogWarning("[HttpApiClient] 유효한 Access Token을 찾을 수 없습니다."); RemoveDefaultHeader(AUTHORIZATION_HEADER); } } catch (Exception ex) { - Debug.LogError($"[HttpApiClient] TokenManager에서 토큰 설정 실패: {ex.Message}"); - // Authorization 헤더 제거 + Debug.LogError($"[HttpApiClient] 토큰 설정 실패: {ex.Message}"); RemoveDefaultHeader(AUTHORIZATION_HEADER); } } public async UniTask GetAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 자동 초기화 - if (!IsInitialized) - { - Debug.LogWarning("[HttpApiClient] 자동 초기화 수행"); - Initialize(null); - } - - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] GET 요청 시작: {url} (인증 필요: {requiresAuth})"); - Debug.Log($"[HttpApiClient] 헤더: {JsonConvert.SerializeObject(headers)}"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } @@ -140,120 +111,70 @@ public async UniTask GetAsync(string endpoint, Dictionary /// public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] GET With Headers 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } - public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, bool requiresAuth = false, CancellationToken cancellationToken = default) + public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = GetFullUrl(endpoint); - var jsonData = SerializeData(data, requiresSession); - Debug.Log($"[HttpApiClient] POST 요청 시작: {url} (인증 필요: {requiresAuth})"); + var jsonData = SerializeData(data); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); } - public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, bool requiresAuth = false, CancellationToken cancellationToken = default) + public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = GetFullUrl(endpoint); - var jsonData = SerializeData(data, requiresSession); - Debug.Log($"[HttpApiClient] PUT 요청 시작: {url} (인증 필요: {requiresAuth})"); + var jsonData = SerializeData(data); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPUT, jsonData, headers, cancellationToken); } public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] DELETE 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbDELETE, null, headers, cancellationToken); } public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = GetFullUrl(endpoint); - var formData = new Dictionary - { - { fieldName, fileData } - }; - var fileNames = new Dictionary - { - { fieldName, fileName } - }; - Debug.Log($"[HttpApiClient] Upload File 요청 시작: {url} (인증 필요: {requiresAuth})"); + var formData = new Dictionary { { fieldName, fileData } }; + var fileNames = new Dictionary { { fieldName, fileName } }; + return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - Debug.Log($"[HttpApiClient] Post Form Data 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendFormDataRequestAsync(url, formData, null, headers, cancellationToken); } public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { - // 인증이 필요한 경우 TokenManager에서 토큰 설정 - if (requiresAuth) - { - SetAuthTokenFromManager(); - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + ValidateFileSize(formData); var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - - // 파일 크기 검사 - if (NetworkConfig.EnableFileSizeCheck) - { - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { - if (byteData.Length > NetworkConfig.MaxFileSize) - { - var fileSizeMB = byteData.Length / 1024.0 / 1024.0; - var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; - throw new FileSizeExceededException(fileSizeMB, maxSizeMB); - } - } - } - } - - Debug.Log($"[HttpApiClient] Post Form Data with Files 요청 시작: {url} (인증 필요: {requiresAuth})"); return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } @@ -271,6 +192,34 @@ public void Shutdown() #region Private Methods + private void EnsureInitialized() + { + if (!IsInitialized) + { + Initialize(); + } + } + + private void ValidateFileSize(Dictionary formData) + { + if (!NetworkConfig.EnableFileSizeCheck) return; + + foreach (var kvp in formData) + { + if (kvp.Value is byte[] byteData && byteData.Length > NetworkConfig.MaxFileSize) + { + var fileSizeMB = byteData.Length / 1024.0 / 1024.0; + var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; + throw new FileSizeExceededException(fileSizeMB, maxSizeMB); + } + } + } + + private string SerializeData(object data) + { + return data == null ? null : JsonConvert.SerializeObject(data); + } + private void ApplyNetworkConfig() { } @@ -292,244 +241,146 @@ private bool IsFullUrl(string url) { return url.StartsWith("http://") || url.StartsWith("https://"); } + private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + { + return await ExecuteRequestWithRetry(async (attempt, token) => + { + using var request = CreateJsonRequest(url, method, jsonData, headers); + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) + return ParseResponse(request); + + await HandleRequestFailure(request, attempt, token); + return default(T); + }, cancellationToken); + } - private string SerializeData(object data, bool requiresSession = false) + private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { - if (data == null) return null; - - var jsonData = JsonConvert.SerializeObject(data); - - if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) + return await ExecuteRequestWithRetry<(T, Dictionary)>(async (attempt, token) => { - var sessionId = _sessionManager?.SessionId ?? ""; - if (!string.IsNullOrEmpty(sessionId)) - { - var jsonObject = JsonConvert.DeserializeObject>(jsonData); - jsonObject["session_id"] = sessionId; - jsonData = JsonConvert.SerializeObject(jsonObject); - } - else + using var request = CreateJsonRequest(url, method, jsonData, headers); + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) { - Debug.LogWarning("[HttpApiClient] 세션 연결이 필요한 요청이지만 세션 ID를 획득할 수 없습니다."); + var data = ParseResponse(request); + var responseHeaders = ExtractResponseHeaders(request); + return (data, responseHeaders); } - } - - return jsonData; + + await HandleRequestFailure(request, attempt, token); + return default; + }, cancellationToken); } - - - private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) { - Debug.Log($"[HttpApiClient] SendJsonRequestAsync 시작"); - Debug.Log($"[HttpApiClient] URL: {url}"); - Debug.Log($"[HttpApiClient] Method: {method}"); - Debug.Log($"[HttpApiClient] JSON Data: {jsonData}"); - Debug.Log($"[HttpApiClient] Headers: {JsonConvert.SerializeObject(headers)}"); - Debug.Log($"[HttpApiClient] IsInitialized: {IsInitialized}"); - Debug.Log($"[HttpApiClient] cancellationTokenSource null: {cancellationTokenSource == null}"); + fileNames ??= new Dictionary(); - var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + return await ExecuteRequestWithRetry(async (attempt, token) => { - try - { - using var request = CreateJsonRequest(url, method, jsonData, headers); + var form = CreateFormData(formData, fileNames); + using var request = UnityWebRequest.Post(url, form); + SetupRequest(request, headers); + request.timeout = (int)NetworkConfig.UploadTimeout; + + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) + return ParseResponse(request); - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - return ParseResponse(request); - } - else - { - await HandleRequestFailure(request, attempt, combinedCancellationToken); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ex is not ApiException) - { - await HandleRequestException(ex, attempt, combinedCancellationToken); - } - } - - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); + await HandleRequestFailure(request, attempt, token, isFileUpload: true); + return default(T); + }, cancellationToken); } - private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + private CancellationToken CreateCombinedCancellationToken(CancellationToken cancellationToken) { - var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); - - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + if (cancellationTokenSource?.Token.CanBeCanceled == true) { try { - using var request = CreateJsonRequest(url, method, jsonData, headers); - - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - var data = ParseResponse(request); - var responseHeaders = ExtractResponseHeaders(request); - return (data, responseHeaders); - } - else - { - await HandleRequestFailure(request, attempt, combinedCancellationToken); - } + return CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenSource.Token).Token; } - catch (OperationCanceledException) + catch (Exception ex) { - throw; - } - catch (Exception ex) when (ex is not ApiException) - { - await HandleRequestException(ex, attempt, combinedCancellationToken); + Debug.LogError($"[HttpApiClient] CancellationToken 생성 실패: {ex.Message}"); } } - - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); + + return cancellationToken; } - - - - - - - private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) + private async UniTask ExecuteRequestWithRetry(Func> requestFunc, CancellationToken cancellationToken) { - fileNames = fileNames ?? new Dictionary(); var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { try { - var form = new WWWForm(); - Debug.Log($"[HttpApiClient] 폼 데이터 전송 시작 - URL: {url}"); - Debug.Log($"[HttpApiClient] 실제 전송 URL: {url}"); - - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { - string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : "file.wav"; - form.AddBinaryData(kvp.Key, byteData, fileName); - Debug.Log($"[HttpApiClient] 바이너리 데이터 추가 - 필드: {kvp.Key}, 파일명: {fileName}, 크기: {byteData.Length} bytes"); - } - else - { - form.AddField(kvp.Key, kvp.Value.ToString()); - Debug.Log($"[HttpApiClient] 필드 추가 - {kvp.Key}: {kvp.Value}"); - } - } - - using var request = UnityWebRequest.Post(url, form); - // 파일 업로드 시 Content-Type은 UnityWebRequest가 자동으로 설정하도록 함 - SetupRequest(request, headers); - request.timeout = (int)NetworkConfig.UploadTimeout; // Use UploadTimeout - - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - return ParseResponse(request); - } - else - { - await HandleFileUploadFailure(request, attempt, combinedCancellationToken); - } + return await requestFunc(attempt, combinedCancellationToken); } catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is not ApiException) + catch (ApiException) when (attempt == NetworkConfig.MaxRetryCount) + { + throw; + } + catch (Exception ex) when (ex is not ApiException && attempt < NetworkConfig.MaxRetryCount) { - await HandleFileUploadException(ex, attempt, combinedCancellationToken); + await DelayForRetry(attempt, combinedCancellationToken); } } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 파일 업로드 실패", 0, "최대 재시도 횟수 초과"); - } - - private CancellationToken CreateCombinedCancellationToken(CancellationToken cancellationToken) - { - if (cancellationTokenSource == null) - { - Debug.LogError("[HttpApiClient] cancellationTokenSource가 null입니다. HttpApiClient가 초기화되지 않았습니다."); - return cancellationToken; - } - - try - { - return CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenSource.Token).Token; - } - catch (Exception ex) - { - Debug.LogError($"[HttpApiClient] CreateCombinedCancellationToken 실패: {ex.Message}"); - return cancellationToken; - } + throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } - - private async UniTask HandleRequestFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken) + + private async UniTask HandleRequestFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken, bool isFileUpload = false) { var error = new ApiException(request.error, request.responseCode, request.downloadHandler?.text); + var requestType = isFileUpload ? "파일 업로드" : "API 요청"; if (ShouldRetry(request.responseCode) && attempt < NetworkConfig.MaxRetryCount) { - Debug.LogWarning($"[HttpApiClient] API 요청 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); + Debug.LogWarning($"[HttpApiClient] {requestType} 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); + await DelayForRetry(attempt, cancellationToken); return; } throw error; } - - private async UniTask HandleRequestException(Exception ex, int attempt, CancellationToken cancellationToken) + + private async UniTask DelayForRetry(int attempt, CancellationToken cancellationToken) { - if (attempt < NetworkConfig.MaxRetryCount) - { - Debug.LogWarning($"[HttpApiClient] API 요청 예외 발생 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {ex.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; - } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, ex.Message); + await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); } - - private async UniTask HandleFileUploadFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken) + + private WWWForm CreateFormData(Dictionary formData, Dictionary fileNames) { - var error = new ApiException(request.error, request.responseCode, request.downloadHandler?.text); + var form = new WWWForm(); - if (ShouldRetry(request.responseCode) && attempt < NetworkConfig.MaxRetryCount) + foreach (var kvp in formData) { - Debug.LogWarning($"[HttpApiClient] 파일 업로드 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; + if (kvp.Value is byte[] byteData) + { + string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : DEFAULT_FILE_NAME; + form.AddBinaryData(kvp.Key, byteData, fileName); + } + else + { + form.AddField(kvp.Key, kvp.Value.ToString()); + } } - throw error; - } - - private async UniTask HandleFileUploadException(Exception ex, int attempt, CancellationToken cancellationToken) - { - if (attempt < NetworkConfig.MaxRetryCount) - { - Debug.LogWarning($"[HttpApiClient] 파일 업로드 예외 발생 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {ex.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; - } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 파일 업로드 실패", 0, ex.Message); + return form; } @@ -577,23 +428,14 @@ private void SetupRequest(UnityWebRequest request, Dictionary he } } - // 디버깅: Content-Type 헤더 확인 - string contentType = request.GetRequestHeader("Content-Type"); - Debug.Log($"[HttpApiClient] 요청 헤더 설정 완료 - Content-Type: {contentType}"); } private T ParseResponse(UnityWebRequest request) { var responseText = request.downloadHandler?.text; - Debug.Log($"[HttpApiClient] 응답 파싱 - Status: {request.responseCode}, Content-Length: {request.downloadHandler?.data?.Length ?? 0}"); - Debug.Log($"[HttpApiClient] 응답 텍스트: '{responseText}'"); - if (string.IsNullOrEmpty(responseText)) - { - Debug.LogWarning("[HttpApiClient] 응답 텍스트가 비어있습니다."); return default(T); - } try { @@ -601,7 +443,6 @@ private T ParseResponse(UnityWebRequest request) } catch (Exception ex) { - Debug.LogError($"[HttpApiClient] JSON 파싱 실패: {ex.Message}"); return TryFallbackParse(responseText, request.responseCode, ex); } } @@ -614,47 +455,26 @@ private T TryFallbackParse(string responseText, long responseCode, Exception } catch (Exception fallbackEx) { - throw new ApiException($"응답 파싱 실패: {originalException.Message} (폴백도 실패: {fallbackEx.Message})", responseCode, responseText); + var errorMessage = $"JSON 파싱 실패: {originalException.Message} (Unity JsonUtility 폴백도 실패: {fallbackEx.Message})"; + Debug.LogError($"[HttpApiClient] {errorMessage}"); + throw new ApiException(errorMessage, responseCode, responseText); } } - /// - /// HTTP 응답 헤더 추출 - /// private Dictionary ExtractResponseHeaders(UnityWebRequest request) { var headers = new Dictionary(); + var headerNames = new[] + { + "X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-User-Id", + "Content-Type", "Authorization" + }; - // UnityWebRequest에서 사용 가능한 헤더들 - var responseHeaders = request.GetResponseHeader("X-Access-Token"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Access-Token"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-Refresh-Token"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Refresh-Token"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-Expires-In"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-Expires-In"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("X-User-Id"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["X-User-Id"] = responseHeaders; - - // 기타 헤더들도 추가 가능 - responseHeaders = request.GetResponseHeader("Content-Type"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["Content-Type"] = responseHeaders; - - responseHeaders = request.GetResponseHeader("Authorization"); - if (!string.IsNullOrEmpty(responseHeaders)) - headers["Authorization"] = responseHeaders; - - Debug.Log($"[HttpApiClient] 응답 헤더 추출: {headers.Count}개 헤더"); - foreach (var header in headers) + foreach (var headerName in headerNames) { - Debug.Log($"[HttpApiClient] 헤더: {header.Key} = {header.Value}"); + var headerValue = request.GetResponseHeader(headerName); + if (!string.IsNullOrEmpty(headerValue)) + headers[headerName] = headerValue; } return headers; diff --git a/Assets/Infrastructure/Network/Services/ChatApiService.cs b/Assets/Infrastructure/Network/Services/ChatApiService.cs index be991ea..cdd72f4 100644 --- a/Assets/Infrastructure/Network/Services/ChatApiService.cs +++ b/Assets/Infrastructure/Network/Services/ChatApiService.cs @@ -37,7 +37,7 @@ public async UniTask SendChatAsync(ChatRequest request, Cancellati var serverRequest = CreateServerRequest(request); LogRequestDetails(serverRequest); - return await _httpClient.PostAsync(CHAT_ENDPOINT, serverRequest, requiresSession: true, requiresAuth: true, cancellationToken: cancellationToken); + return await _httpClient.PostAsync(CHAT_ENDPOINT, serverRequest, requiresAuth: true, cancellationToken: cancellationToken); } /// diff --git a/Assets/Infrastructure/Network/Services/SessionManager.cs b/Assets/Infrastructure/Network/Services/SessionManager.cs deleted file mode 100644 index 7d4b339..0000000 --- a/Assets/Infrastructure/Network/Services/SessionManager.cs +++ /dev/null @@ -1,307 +0,0 @@ -using UnityEngine; -using System; -using Cysharp.Threading.Tasks; -using ProjectVG.Infrastructure.Network.WebSocket; - -namespace ProjectVG.Infrastructure.Network.Services -{ - /// - /// 새로운 이벤트 기반 SessionManager - /// WebSocketManager의 연결/해제 상태를 모니터링하고 세션 ID를 관리 - /// - public class SessionManager : Singleton - { - [Header("Session Info")] - [SerializeField] private string _currentSessionId = ""; - [SerializeField] private bool _isInitialized = false; - - private WebSocketManager _webSocketManager; - - // 공개 속성 - public string SessionId => _currentSessionId; - public bool IsSessionConnected => !string.IsNullOrEmpty(_currentSessionId) && _webSocketManager?.IsConnected == true; - public bool IsWebSocketConnected => _webSocketManager?.IsConnected == true; - public bool IsWebSocketConnecting => _webSocketManager?.IsConnecting == true; - public bool IsInitialized => _isInitialized; - - // 이벤트 - public event Action OnSessionStarted; - public event Action OnSessionEnded; - public event Action OnSessionError; - - #region Unity Lifecycle - - protected override void Awake() - { - base.Awake(); - } - - private void OnDestroy() - { - Shutdown(); - } - - #endregion - - #region Public Methods - - /// - /// 세션 ID 요청 - /// - public async UniTask GetSessionIdAsync() - { - if (IsSessionConnected) - { - Debug.Log($"[SessionManager] 현재 세션 ID 반환: {_currentSessionId}"); - return _currentSessionId; - } - - Debug.Log("[SessionManager] 세션이 없거나 연결되지 않음. 연결을 시도합니다."); - bool connected = await EnsureConnectionAsync(); - - if (connected) - { - return _currentSessionId; - } - else - { - Debug.LogError("[SessionManager] 세션 연결에 실패했습니다."); - return null; - } - } - - /// - /// 세션 연결 보장 - /// - public async UniTask EnsureConnectionAsync() - { - Debug.Log($"[SessionManager] EnsureConnectionAsync 호출 - 초기화 상태: {_isInitialized}, WebSocketManager: {(_webSocketManager != null ? "존재" : "null")}"); - - if (IsSessionConnected) - { - Debug.Log("[SessionManager] 이미 세션이 연결되어 있습니다."); - return true; - } - - // DI로 주입받은 WebSocketManager 확인 - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 DI로 주입되지 않았습니다. DependencyManager 설정을 확인하세요."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return false; - } - - return await RequestConnectionAsync(); - } - - /// - /// 연결 요청 - /// - private async UniTask RequestConnectionAsync() - { - try - { - // DI로 주입받은 WebSocketManager 사용 - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 DI로 주입되지 않았습니다."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return false; - } - - // 1. WebSocket 연결 상태 확인 및 연결 요청 - if (!IsWebSocketConnected) - { - if (IsWebSocketConnecting) - { - Debug.Log("[SessionManager] WebSocket이 이미 연결 중입니다. 연결 완료를 기다립니다."); - } - else - { - Debug.Log("[SessionManager] WebSocket 연결을 요청합니다."); - bool connected = await _webSocketManager.ConnectAsync(); - if (!connected) - { - Debug.LogError("[SessionManager] WebSocket 연결에 실패했습니다."); - return false; - } - } - } - - // 2. 연결 완료 대기 (폴링) - return await WaitForSessionConnection(); - } - catch (Exception ex) - { - Debug.LogError($"[SessionManager] 연결 요청 실패: {ex.Message}"); - Debug.LogError($"[SessionManager] 스택 트레이스: {ex.StackTrace}"); - OnSessionError?.Invoke($"연결 요청 실패: {ex.Message}"); - return false; - } - } - - /// - /// 세션 연결 완료 대기 - /// - private async UniTask WaitForSessionConnection() - { - Debug.Log("[SessionManager] 세션 연결 완료 대기 중..."); - - const int timeoutSeconds = 10; - const int pollIntervalMs = 100; // 100ms마다 체크 - int elapsedMs = 0; - - while (elapsedMs < timeoutSeconds * 1000) - { - // 세션이 연결되었는지 확인 - if (IsSessionConnected) - { - Debug.Log($"[SessionManager] 세션 연결 완료: {_currentSessionId}"); - return true; - } - - // WebSocket 연결이 끊어졌다면 실패 - if (!IsWebSocketConnected && !IsWebSocketConnecting) - { - Debug.LogError("[SessionManager] WebSocket 연결이 끊어졌습니다."); - return false; - } - - await UniTask.Delay(pollIntervalMs); - elapsedMs += pollIntervalMs; - } - - Debug.LogError($"[SessionManager] 세션 연결 타임아웃 ({timeoutSeconds}초)"); - return false; - } - - /// - /// 세션 해제 - /// - public void EndSession() - { - if (!string.IsNullOrEmpty(_currentSessionId)) - { - string oldSessionId = _currentSessionId; - _currentSessionId = ""; - - Debug.Log($"[SessionManager] 세션 종료: {oldSessionId}"); - OnSessionEnded?.Invoke(); - } - } - - #endregion - - #region Private Methods - 초기화 및 이벤트 핸들링 - - /// - /// 초기화 실행 - /// - public void Initialize(WebSocketManager webSocketManager) - { - try - { - _webSocketManager = webSocketManager; - Debug.Log("[SessionManager] 초기화 시작"); - - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 null입니다."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return; - } - - SubscribeToWebSocketEvents(); - - _isInitialized = true; - Debug.Log("[SessionManager] 초기화 완료"); - } - catch (Exception ex) - { - Debug.LogError($"[SessionManager] 초기화 실패: {ex.Message}"); - Debug.LogError($"[SessionManager] 스택 트레이스: {ex.StackTrace}"); - OnSessionError?.Invoke($"초기화 실패: {ex.Message}"); - } - } - - private void SubscribeToWebSocketEvents() - { - if (_webSocketManager == null) return; - - // 기존 구독 해제 (중복 방지) - UnsubscribeFromWebSocketEvents(); - - // 새로운 이벤트 구독 - _webSocketManager.OnSessionConnected += OnWebSocketSessionConnected; - _webSocketManager.OnSessionDisconnected += OnWebSocketSessionDisconnected; - _webSocketManager.OnDisconnected += OnWebSocketDisconnected; - _webSocketManager.OnError += OnWebSocketError; - - Debug.Log("[SessionManager] WebSocket 이벤트 구독 완료"); - } - - private void UnsubscribeFromWebSocketEvents() - { - if (_webSocketManager == null) return; - - _webSocketManager.OnSessionConnected -= OnWebSocketSessionConnected; - _webSocketManager.OnSessionDisconnected -= OnWebSocketSessionDisconnected; - _webSocketManager.OnDisconnected -= OnWebSocketDisconnected; - _webSocketManager.OnError -= OnWebSocketError; - } - - #endregion - - #region WebSocket 이벤트 핸들러 - - private void OnWebSocketSessionConnected(string sessionId) - { - Debug.Log($"[SessionManager] WebSocket 세션 연결됨: {sessionId}"); - - _currentSessionId = sessionId; - OnSessionStarted?.Invoke(sessionId); - } - - private void OnWebSocketSessionDisconnected() - { - Debug.Log("[SessionManager] WebSocket 세션 연결 해제됨"); - - _currentSessionId = ""; - OnSessionEnded?.Invoke(); - } - - private void OnWebSocketDisconnected() - { - Debug.Log("[SessionManager] WebSocket 연결 해제됨"); - - if (!string.IsNullOrEmpty(_currentSessionId)) - { - _currentSessionId = ""; - OnSessionEnded?.Invoke(); - } - } - - private void OnWebSocketError(string error) - { - Debug.LogError($"[SessionManager] WebSocket 오류: {error}"); - OnSessionError?.Invoke(error); - } - - #endregion - - #region IManager 구현 - - public void Shutdown() - { - - UnsubscribeFromWebSocketEvents(); - - EndSession(); - - _isInitialized = false; - Debug.Log("[SessionManager] 종료 완료"); - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Services/SessionManager.cs.meta b/Assets/Infrastructure/Network/Services/SessionManager.cs.meta deleted file mode 100644 index f4b9335..0000000 --- a/Assets/Infrastructure/Network/Services/SessionManager.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f0048fe563a65f94a90a49760ba27126 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs b/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs index 68bb660..2c3d795 100644 --- a/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs +++ b/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs @@ -6,6 +6,7 @@ using ProjectVG.Infrastructure.Network.Configs; using ProjectVG.Infrastructure.Network.DTOs.Chat; using ProjectVG.Domain.Chat.Model; +using ProjectVG.Infrastructure.Auth; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -22,21 +23,19 @@ public class WebSocketManager : Singleton private bool _isConnected = false; private bool _isConnecting = false; private int _reconnectAttempts = 0; - private string _sessionId; private bool _autoReconnect = true; private float _reconnectDelay = 5f; private int _maxReconnectAttempts = 10; private float _maxReconnectDelay = 60f; private bool _useExponentialBackoff = true; private bool _isShutdown = false; - - // 순환 의존성 해결: 직접 참조 대신 이벤트 사용 - public event Action OnSessionMessageReceived; - // 새로운 설계: 세션 연결/해제 이벤트 - public event Action OnSessionConnected; // 세션 ID와 함께 연결 완료 - public event Action OnSessionDisconnected; // 세션 연결 해제 + private TokenManager _tokenManager; + private TokenRefreshService _tokenRefreshService; + // 메시지 이벤트 + public event Action OnMessageReceived; + public event Action OnConnected; public event Action OnDisconnected; public event Action OnError; @@ -44,7 +43,6 @@ public class WebSocketManager : Singleton public bool IsConnected => _isConnected; public bool IsConnecting => _isConnecting; - public string SessionId => _sessionId; public bool AutoReconnect => _autoReconnect; public int ReconnectAttempts => _reconnectAttempts; @@ -53,6 +51,8 @@ public class WebSocketManager : Singleton protected override void Awake() { base.Awake(); + _tokenManager = TokenManager.Instance; + _tokenRefreshService = TokenRefreshService.Instance; } private void OnDestroy() @@ -75,6 +75,11 @@ public void Initialize() } _cancellationTokenSource = new CancellationTokenSource(); InitializeNativeWebSocket(); + + // TokenManager 이벤트 구독 - 토큰 변경 시 연결 상태 관리 + _tokenManager.OnTokensUpdated += OnTokensUpdated; + _tokenManager.OnTokensCleared += OnTokensCleared; + #pragma warning disable CS4014 StartConnectionMonitoring(); #pragma warning restore CS4014 @@ -83,7 +88,7 @@ public void Initialize() /// /// 서버와 웹소켓 연결 시도 /// - public async UniTask ConnectAsync(string sessionId = null, CancellationToken cancellationToken = default) + public async UniTask ConnectAsync(CancellationToken cancellationToken = default) { if (_isConnected || _isConnecting) { @@ -91,17 +96,42 @@ public async UniTask ConnectAsync(string sessionId = null, CancellationTok return _isConnected; } + Console.WriteLine($"[WebSocket] ConnectAsync 호출 - 연결 상태: {_isConnected}, 연결 중: {_isConnecting}"); + Console.WriteLine($"[WebSocket] 토큰 상태 - AccessToken: {_tokenManager.GetAccessToken()?.Substring(0, 10) ?? "null"}, RefreshToken: {_tokenManager.GetRefreshToken()?.Substring(0, 10) ?? "null"}"); + + // 로그인 상태 확인 + if (!_tokenManager.HasValidTokens) + { + Debug.LogWarning("[WebSocket] 유효한 Access Token이 없어 연결할 수 없습니다."); + + // Refresh Token으로 Access Token 재요청 시도 + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + Debug.Log("[WebSocket] Refresh Token으로 Access Token 갱신 시도"); + var refreshSuccess = await _tokenRefreshService.RefreshAccessTokenAsync(); + if (!refreshSuccess) + { + Debug.LogError("[WebSocket] 토큰 갱신 실패 - 연결 불가"); + return false; + } + } + else + { + Debug.LogError("[WebSocket] 유효한 Refresh Token도 없습니다. 재로그인이 필요합니다."); + return false; + } + } + _isConnecting = true; - _sessionId = sessionId; try { var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token).Token; - var wsUrl = GetWebSocketUrl(sessionId); + var wsUrl = GetWebSocketUrlWithToken(); Debug.Log($"[WebSocket] 환경: {NetworkConfig.CurrentEnvironment}"); Debug.Log($"[WebSocket] 서버 주소(환경기반): {NetworkConfig.WebSocketServerAddress}"); - Debug.Log($"[WebSocket] 연결 시도 URL: {wsUrl}"); + Debug.Log($"[WebSocket] 연결 시도 URL: {wsUrl.Substring(0, Math.Min(wsUrl.Length, 100))}..."); var success = await _nativeWebSocket.ConnectAsync(wsUrl, combinedCancellationToken); @@ -126,7 +156,7 @@ public async UniTask ConnectAsync(string sessionId = null, CancellationTok } catch (Exception ex) { - var error = $"WebSocket 연결 중 예외 발생: {ex.Message}\n환경: {NetworkConfig.CurrentEnvironment}\n서버 주소: {NetworkConfig.WebSocketServerAddress}\n요청 URL: {GetWebSocketUrl(sessionId)}"; + var error = $"WebSocket 연결 중 예외 발생: {ex.Message}\n환경: {NetworkConfig.CurrentEnvironment}\n서버 주소: {NetworkConfig.WebSocketServerAddress}"; Debug.LogError($"[WebSocket] {error}"); OnError?.Invoke(error); return false; @@ -185,6 +215,13 @@ public void Shutdown() } _isShutdown = true; + // 이벤트 구독 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= OnTokensUpdated; + _tokenManager.OnTokensCleared -= OnTokensCleared; + } + _autoReconnect = false; DisconnectAsync().Forget(); @@ -211,13 +248,14 @@ private void InitializeNativeWebSocket() _nativeWebSocket.OnMessageReceived += OnNativeMessageReceived; } - private string GetWebSocketUrl(string sessionId = null) + private string GetWebSocketUrlWithToken() { string baseUrl = NetworkConfig.GetWebSocketUrl(); + string accessToken = _tokenManager.GetAccessToken(); - if (!string.IsNullOrEmpty(sessionId)) + if (!string.IsNullOrEmpty(accessToken)) { - return $"{baseUrl}?sessionId={sessionId}"; + return $"{baseUrl}?token={accessToken}"; } return baseUrl; @@ -243,7 +281,7 @@ private async UniTaskVoid TryReconnectAsync() if (!_isConnected) { - await ConnectAsync(_sessionId); + await ConnectAsync(); } } @@ -254,9 +292,9 @@ private async UniTaskVoid StartConnectionMonitoring() { await UniTask.Delay(TimeSpan.FromSeconds(30), cancellationToken: token); - if (!_isConnected && !_isConnecting && _autoReconnect && _reconnectAttempts < _maxReconnectAttempts) + if (!_isConnected && !_isConnecting && _autoReconnect && _reconnectAttempts < _maxReconnectAttempts && _tokenManager.HasValidTokens) { - await ConnectAsync(_sessionId); + await ConnectAsync(); } } } @@ -276,15 +314,7 @@ private void OnNativeDisconnected() _isConnected = false; _isConnecting = false; - string previousSessionId = _sessionId; - _sessionId = null; - - Debug.LogWarning("[WebSocket] 세션이 끊어졌습니다. 재연결을 시도합니다."); - - if (!string.IsNullOrEmpty(previousSessionId)) - { - OnSessionDisconnected?.Invoke(); - } + Debug.LogWarning("[WebSocket] 연결이 끊어졌습니다. 재연결을 시도합니다."); OnDisconnected?.Invoke(); @@ -398,14 +428,12 @@ private void ProcessMessage(string message) switch (messageType) { - case "session": - ProcessSessionMessage(dataToken.ToString(Formatting.None)); - break; case "chat": ProcessChatMessage(dataToken.ToString(Formatting.None)); break; default: - Debug.LogWarning($"[WebSocket] 알 수 없는 메시지 타입: {messageType}"); + Debug.Log($"[WebSocket] 메시지 타입: {messageType}"); + OnMessageReceived?.Invoke(message); break; } } @@ -415,32 +443,6 @@ private void ProcessMessage(string message) } } - private void ProcessSessionMessage(string data) - { - try - { - Debug.Log($"[WebSocket] 세션 메시지 수신: {data?.Substring(0, Math.Min(50, data?.Length ?? 0))}..."); - - var jsonObject = JObject.Parse(data); - string sessionId = jsonObject["session_id"]?.ToString(); - - if (!string.IsNullOrEmpty(sessionId)) - { - _sessionId = sessionId; - Debug.Log($"[WebSocket] 세션 연결 완료: {sessionId}"); - OnSessionConnected?.Invoke(sessionId); - } - - OnSessionMessageReceived?.Invoke(data); - - Debug.Log($"[WebSocket] 세션 메시지 처리 완료"); - } - catch (Exception ex) - { - Debug.LogError($"[WebSocket] 세션 메시지 처리 중 오류: {ex.Message}"); - } - } - private void ProcessChatMessage(string data) { try @@ -468,6 +470,32 @@ private void ProcessChatMessage(string data) } } + /// + /// 토큰 업데이트 이벤트 핸들러 - 로그인 완료 시 자동 연결 + /// + private void OnTokensUpdated(ProjectVG.Infrastructure.Auth.Models.TokenSet tokenSet) + { + Debug.Log("[WebSocket] 토큰이 업데이트되었습니다. 연결을 시도합니다."); + + // 로그인 완료 시 WebSocket 자동 연결 + if (!_isConnected && !_isConnecting) + { + ConnectAsync().Forget(); + } + } + + /// + /// 토큰 클리어 이벤트 핸들러 - 로그아웃 시 연결 해제 + /// + private void OnTokensCleared() + { + Debug.Log("[WebSocket] 토큰이 클리어되었습니다. 연결을 해제합니다."); + + // 로그아웃 시 WebSocket 연결 해제 + _autoReconnect = false; + DisconnectAsync().Forget(); + } + #endregion } } \ No newline at end of file From 9631741c3cd3d585f6377e2686ccd275e34f0e76 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 27 Aug 2025 01:12:14 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D/=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Infrastructure/Auth/AuthManager.cs | 512 ++++++++++++++++++ .../Infrastructure/Auth/AuthManager.cs.meta | 2 + .../Auth/Examples/AuthManagerExample.cs | 401 ++++++++++++++ .../Auth/Examples/AuthManagerExample.cs.meta | 2 + .../Infrastructure/Auth/Models/AccessToken.cs | 5 + 5 files changed, 922 insertions(+) create mode 100644 Assets/Infrastructure/Auth/AuthManager.cs create mode 100644 Assets/Infrastructure/Auth/AuthManager.cs.meta create mode 100644 Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs create mode 100644 Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta diff --git a/Assets/Infrastructure/Auth/AuthManager.cs b/Assets/Infrastructure/Auth/AuthManager.cs new file mode 100644 index 0000000..b2adef8 --- /dev/null +++ b/Assets/Infrastructure/Auth/AuthManager.cs @@ -0,0 +1,512 @@ +using System; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.Services; +using ProjectVG.Infrastructure.Auth.OAuth2; + +namespace ProjectVG.Infrastructure.Auth +{ + /// + /// 인증 시스템 전체를 관리하는 파사드/중재자 역할의 매니저 + /// Guest 로그인, OAuth2 로그인, 토큰 자동 갱신 등을 통합 관리 + /// + public class AuthManager : MonoBehaviour + { + #region Singleton Pattern + + private static AuthManager _instance; + public static AuthManager Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("AuthManager"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + #endregion + + #region Private Fields + + private TokenManager _tokenManager; + private TokenRefreshService _tokenRefreshService; + private GuestAuthService _guestAuthService; + private ServerOAuth2Provider _oauth2Provider; + + private bool _isInitialized = false; + private bool _isAutoRefreshInProgress = false; + + #endregion + + #region Public Properties + + /// + /// 현재 로그인 상태 (Access Token이 존재하고 유효한 경우) + /// + public bool IsLoggedIn => _tokenManager?.HasValidTokens ?? false; + + /// + /// Refresh Token이 존재하고 유효한 경우 + /// + public bool HasValidRefreshToken => _tokenManager?.HasRefreshToken == true && + !_tokenManager.IsRefreshTokenExpired(); + + /// + /// 현재 사용자 ID + /// + public string CurrentUserId => _tokenManager?.CurrentUserId; + + /// + /// 자동 토큰 갱신 진행 중 여부 + /// + public bool IsAutoRefreshInProgress => _isAutoRefreshInProgress; + + #endregion + + #region Events + + /// + /// 로그인 성공 시 발생하는 이벤트 (Guest, OAuth2 모두 포함) + /// + public event Action OnLoginSuccess; + + /// + /// 로그인 실패 시 발생하는 이벤트 + /// + public event Action OnLoginFailed; + + /// + /// 로그아웃 시 발생하는 이벤트 + /// + public event Action OnLoggedOut; + + /// + /// 자동 토큰 갱신 성공 시 발생하는 이벤트 + /// + public event Action OnTokenAutoRefreshed; + + /// + /// 토큰 갱신 실패로 재로그인이 필요한 경우 발생하는 이벤트 + /// + public event Action OnReLoginRequired; + + #endregion + + #region Unity Lifecycle + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + InitializeAsync().Forget(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private async UniTaskVoid InitializeAsync() + { + try + { + Debug.Log("[AuthManager] 초기화 시작"); + + // 의존성 초기화 + _tokenManager = TokenManager.Instance; + _tokenRefreshService = TokenRefreshService.Instance; + _guestAuthService = GuestAuthService.Instance; + + // TokenManager 이벤트 구독 + _tokenManager.OnTokensUpdated += HandleTokensUpdated; + _tokenManager.OnTokensExpired += HandleTokensExpired; + _tokenManager.OnTokensCleared += HandleTokensCleared; + + // TokenRefreshService 이벤트 구독 + _tokenRefreshService.OnTokenRefreshed += HandleTokenRefreshed; + _tokenRefreshService.OnTokenRefreshFailed += HandleTokenRefreshFailed; + + // GuestAuthService 이벤트 구독 + _guestAuthService.OnGuestLoginSuccess += HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed += HandleGuestLoginFailed; + + _isInitialized = true; + Debug.Log("[AuthManager] 초기화 완료"); + + // 앱 시작 시 자동 로그인 시도 + await TryAutoLoginAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 초기화 실패: {ex.Message}"); + } + } + + private void OnDestroy() + { + // 이벤트 구독 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= HandleTokensUpdated; + _tokenManager.OnTokensExpired -= HandleTokensExpired; + _tokenManager.OnTokensCleared -= HandleTokensCleared; + } + + if (_tokenRefreshService != null) + { + _tokenRefreshService.OnTokenRefreshed -= HandleTokenRefreshed; + _tokenRefreshService.OnTokenRefreshFailed -= HandleTokenRefreshFailed; + } + + if (_guestAuthService != null) + { + _guestAuthService.OnGuestLoginSuccess -= HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed -= HandleGuestLoginFailed; + } + } + + #endregion + + #region Public Methods - Login + + /// + /// Guest 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginAsGuestAsync() + { + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager가 초기화되지 않았습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] Guest 로그인 시작"); + return await _guestAuthService.LoginAsGuestAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] Guest 로그인 실패: {ex.Message}"); + OnLoginFailed?.Invoke(ex.Message); + return false; + } + } + + /// + /// OAuth2 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginWithOAuth2Async() + { + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager가 초기화되지 않았습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] OAuth2 로그인 시작"); + + // OAuth2Provider 초기화 (필요한 경우) + if (_oauth2Provider == null) + { + var config = ProjectVG.Infrastructure.Auth.OAuth2.Config.ServerOAuth2Config.Instance; + if (config == null) + { + throw new InvalidOperationException("ServerOAuth2Config를 찾을 수 없습니다."); + } + _oauth2Provider = new ServerOAuth2Provider(config); + } + + // OAuth2 로그인 수행 + var tokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); + + if (tokenSet?.AccessToken != null) + { + // 토큰 저장 + _tokenManager.SaveTokens(tokenSet); + Debug.Log("[AuthManager] OAuth2 로그인 성공"); + OnLoginSuccess?.Invoke(tokenSet); + return true; + } + else + { + throw new InvalidOperationException("OAuth2 로그인에서 유효한 토큰을 받지 못했습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] OAuth2 로그인 실패: {ex.Message}"); + OnLoginFailed?.Invoke(ex.Message); + return false; + } + } + + /// + /// 로그아웃 수행 (토큰 삭제) + /// + public void Logout() + { + try + { + Debug.Log("[AuthManager] 로그아웃 시작"); + _tokenManager.ClearTokens(); + Debug.Log("[AuthManager] 로그아웃 완료"); + OnLoggedOut?.Invoke(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 로그아웃 실패: {ex.Message}"); + } + } + + #endregion + + #region Public Methods - Token Management + + /// + /// 수동으로 토큰 갱신 요청 + /// + /// 갱신 성공 여부 + public async UniTask RefreshTokenAsync() + { + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager가 초기화되지 않았습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] 수동 토큰 갱신 시작"); + return await _tokenRefreshService.RefreshAccessTokenAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 수동 토큰 갱신 실패: {ex.Message}"); + return false; + } + } + + /// + /// 토큰이 곧 만료되는지 확인하고 필요시 갱신 + /// + /// 만료 몇 분 전에 갱신할지 + /// 유효한 토큰 보장 여부 + public async UniTask EnsureValidTokenAsync(int minutesBeforeExpiry = 5) + { + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager가 초기화되지 않았습니다."); + return false; + } + + try + { + // 이미 유효한 토큰이 있고 만료가 임박하지 않은 경우 + /* + var currentToken = _tokenManager.GetAccessToken(); + if (IsLoggedIn && currentToken != null && !currentToken.IsExpiringSoon(minutesBeforeExpiry)) + { + return true; + } + */ + + // 토큰이 만료되었거나 곧 만료될 경우 갱신 시도 + if (HasValidRefreshToken) + { + Debug.Log($"[AuthManager] 토큰이 {minutesBeforeExpiry}분 내에 만료 예정 - 갱신 시도"); + return await _tokenRefreshService.RefreshAccessTokenAsync(); + } + + Debug.LogWarning("[AuthManager] 유효한 Refresh Token이 없습니다."); + return false; + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 토큰 유효성 보장 실패: {ex.Message}"); + return false; + } + } + + #endregion + + #region Private Methods - Auto Login + + /// + /// 앱 시작 시 자동 로그인 시도 + /// + private async UniTask TryAutoLoginAsync() + { + try + { + Debug.Log("[AuthManager] 자동 로그인 시도 시작"); + + // 이미 유효한 Access Token이 있는 경우 + if (IsLoggedIn) + { + Debug.Log("[AuthManager] 이미 유효한 Access Token이 존재합니다."); + var tokenSet = _tokenManager.LoadTokens(); + OnLoginSuccess?.Invoke(tokenSet); + return; + } + + // Refresh Token으로 Access Token 재발급 시도 + if (HasValidRefreshToken) + { + Debug.Log("[AuthManager] Refresh Token으로 자동 로그인 시도"); + _isAutoRefreshInProgress = true; + + bool refreshSuccess = await _tokenRefreshService.RefreshAccessTokenAsync(); + + _isAutoRefreshInProgress = false; + + if (refreshSuccess) + { + Debug.Log("[AuthManager] 자동 로그인 성공"); + var tokenSet = _tokenManager.LoadTokens(); + OnLoginSuccess?.Invoke(tokenSet); + OnTokenAutoRefreshed?.Invoke("자동 로그인 성공"); + } + else + { + Debug.LogWarning("[AuthManager] 자동 로그인 실패 - 재로그인 필요"); + OnReLoginRequired?.Invoke("자동 로그인 실패"); + } + } + else + { + Debug.Log("[AuthManager] 저장된 유효한 토큰이 없습니다 - 로그인 필요"); + OnReLoginRequired?.Invoke("토큰 없음"); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 자동 로그인 시도 실패: {ex.Message}"); + OnReLoginRequired?.Invoke(ex.Message); + } + } + + #endregion + + #region Event Handlers + + private void HandleTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[AuthManager] 토큰 업데이트됨"); + // 로그인 성공 이벤트는 각 로그인 메서드에서 직접 발생 + } + + private void HandleTokensExpired() + { + Debug.LogWarning("[AuthManager] 토큰 만료됨 - 자동 갱신 시도"); + // TokenRefreshService가 자동으로 갱신 시도함 + } + + private void HandleTokensCleared() + { + Debug.Log("[AuthManager] 토큰이 삭제됨"); + } + + private void HandleTokenRefreshed(string newAccessToken) + { + Debug.Log("[AuthManager] 토큰 갱신 성공"); + OnTokenAutoRefreshed?.Invoke(newAccessToken); + } + + private void HandleTokenRefreshFailed(string error) + { + Debug.LogError($"[AuthManager] 토큰 갱신 실패: {error}"); + OnReLoginRequired?.Invoke(error); + } + + private void HandleGuestLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[AuthManager] Guest 로그인 성공"); + OnLoginSuccess?.Invoke(tokenSet); + } + + private void HandleGuestLoginFailed(string error) + { + Debug.LogError($"[AuthManager] Guest 로그인 실패: {error}"); + OnLoginFailed?.Invoke(error); + } + + #endregion + + #region Public Methods - Utility + + /// + /// 현재 Access Token 반환 (문자열) + /// + /// Access Token 문자열 (만료된 경우 null) + public string GetAccessToken() + { + return _tokenManager?.GetAccessToken(); + } + + /// + /// 현재 Access Token 객체 반환 + /// + /// AccessToken 객체 (없거나 만료된 경우 null) + public AccessToken GetCurrentAccessToken() + { + if (_tokenManager?.HasValidTokens == true) + { + var tokenSet = _tokenManager.LoadTokens(); + return tokenSet?.AccessToken; + } + return null; + } + + /// + /// Guest 로그인 가능 여부 확인 + /// + /// Guest 로그인 가능 여부 + public bool CanLoginAsGuest() + { + return _guestAuthService?.CanLoginAsGuest() ?? false; + } + + /// + /// 디버그 정보 반환 + /// + /// AuthManager 상태 정보 + public string GetDebugInfo() + { + var info = "=== AuthManager Debug Info ===\n"; + info += $"Is Initialized: {_isInitialized}\n"; + info += $"Is Logged In: {IsLoggedIn}\n"; + info += $"Has Valid Refresh Token: {HasValidRefreshToken}\n"; + info += $"Current User ID: {CurrentUserId ?? "None"}\n"; + info += $"Auto Refresh In Progress: {_isAutoRefreshInProgress}\n"; + info += $"Can Login As Guest: {CanLoginAsGuest()}\n"; + + if (_tokenManager != null) + { + var accessToken = GetCurrentAccessToken(); + if (accessToken != null) + { + info += $"Access Token Expires At: {accessToken.ExpiresAt}\n"; + info += $"Access Token Expires Soon (5min): {accessToken.IsExpiringSoon(5)}\n"; + } + } + + info += "==============================="; + return info; + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/AuthManager.cs.meta b/Assets/Infrastructure/Auth/AuthManager.cs.meta new file mode 100644 index 0000000..a2f00e5 --- /dev/null +++ b/Assets/Infrastructure/Auth/AuthManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d6a5faf59b732b459c9af71f83f202d \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs new file mode 100644 index 0000000..291c0bb --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs @@ -0,0 +1,401 @@ +using System; +using UnityEngine; +using UnityEngine.UI; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// AuthManager 사용 예제 + /// UI 버튼을 통해 다양한 인증 기능을 테스트할 수 있습니다. + /// + public class AuthManagerExample : MonoBehaviour + { + [Header("UI References")] + [SerializeField] private Button guestLoginButton; + [SerializeField] private Button oauth2LoginButton; + [SerializeField] private Button logoutButton; + [SerializeField] private Button refreshTokenButton; + [SerializeField] private Button checkStatusButton; + [SerializeField] private Text statusText; + [SerializeField] private Text debugInfoText; + + private AuthManager _authManager; + + #region Unity Lifecycle + + private void Start() + { + InitializeUI(); + SetupAuthManager(); + } + + private void OnDestroy() + { + UnsubscribeFromAuthEvents(); + } + + #endregion + + #region UI Setup + + private void InitializeUI() + { + // 버튼 이벤트 연결 + if (guestLoginButton != null) + guestLoginButton.onClick.AddListener(() => OnGuestLoginClicked().Forget()); + + if (oauth2LoginButton != null) + oauth2LoginButton.onClick.AddListener(() => OnOAuth2LoginClicked().Forget()); + + if (logoutButton != null) + logoutButton.onClick.AddListener(OnLogoutClicked); + + if (refreshTokenButton != null) + refreshTokenButton.onClick.AddListener(() => OnRefreshTokenClicked().Forget()); + + if (checkStatusButton != null) + checkStatusButton.onClick.AddListener(OnCheckStatusClicked); + + UpdateStatusText("AuthManager 초기화 중..."); + } + + #endregion + + #region AuthManager Setup + + private void SetupAuthManager() + { + try + { + _authManager = AuthManager.Instance; + + // AuthManager 이벤트 구독 + _authManager.OnLoginSuccess += HandleLoginSuccess; + _authManager.OnLoginFailed += HandleLoginFailed; + _authManager.OnLoggedOut += HandleLoggedOut; + _authManager.OnTokenAutoRefreshed += HandleTokenAutoRefreshed; + _authManager.OnReLoginRequired += HandleReLoginRequired; + + Debug.Log("[AuthManagerExample] AuthManager 설정 완료"); + UpdateStatusText("AuthManager 준비 완료"); + + // 초기 상태 업데이트 + UpdateUI(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] AuthManager 설정 실패: {ex.Message}"); + UpdateStatusText($"초기화 실패: {ex.Message}"); + } + } + + private void UnsubscribeFromAuthEvents() + { + if (_authManager != null) + { + _authManager.OnLoginSuccess -= HandleLoginSuccess; + _authManager.OnLoginFailed -= HandleLoginFailed; + _authManager.OnLoggedOut -= HandleLoggedOut; + _authManager.OnTokenAutoRefreshed -= HandleTokenAutoRefreshed; + _authManager.OnReLoginRequired -= HandleReLoginRequired; + } + } + + #endregion + + #region Button Event Handlers + + private async UniTaskVoid OnGuestLoginClicked() + { + try + { + UpdateStatusText("Guest 로그인 시도 중..."); + SetButtonsEnabled(false); + + bool success = await _authManager.LoginAsGuestAsync(); + + if (!success) + { + UpdateStatusText("Guest 로그인 실패"); + } + // 성공 시에는 HandleLoginSuccess 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] Guest 로그인 오류: {ex.Message}"); + UpdateStatusText($"Guest 로그인 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private async UniTaskVoid OnOAuth2LoginClicked() + { + try + { + UpdateStatusText("OAuth2 로그인 시도 중...\n브라우저에서 Google 로그인을 완료해주세요."); + SetButtonsEnabled(false); + + bool success = await _authManager.LoginWithOAuth2Async(); + + if (!success) + { + UpdateStatusText("OAuth2 로그인 실패"); + } + // 성공 시에는 HandleLoginSuccess 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] OAuth2 로그인 오류: {ex.Message}"); + UpdateStatusText($"OAuth2 로그인 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private void OnLogoutClicked() + { + try + { + UpdateStatusText("로그아웃 중..."); + _authManager.Logout(); + // 로그아웃 완료는 HandleLoggedOut 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 로그아웃 오류: {ex.Message}"); + UpdateStatusText($"로그아웃 오류: {ex.Message}"); + } + } + + private async UniTaskVoid OnRefreshTokenClicked() + { + try + { + UpdateStatusText("토큰 갱신 중..."); + SetButtonsEnabled(false); + + bool success = await _authManager.RefreshTokenAsync(); + + if (success) + { + UpdateStatusText("토큰 갱신 성공"); + } + else + { + UpdateStatusText("토큰 갱신 실패"); + } + + UpdateUI(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 토큰 갱신 오류: {ex.Message}"); + UpdateStatusText($"토큰 갱신 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private void OnCheckStatusClicked() + { + UpdateUI(); + UpdateStatusText("상태 정보를 업데이트했습니다."); + } + + #endregion + + #region AuthManager Event Handlers + + private void HandleLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[AuthManagerExample] 로그인 성공 이벤트 수신"); + UpdateStatusText($"로그인 성공!\n사용자 ID: {_authManager.CurrentUserId}"); + UpdateUI(); + SetButtonsEnabled(true); + } + + private void HandleLoginFailed(string error) + { + Debug.LogError($"[AuthManagerExample] 로그인 실패 이벤트 수신: {error}"); + UpdateStatusText($"로그인 실패: {error}"); + UpdateUI(); + SetButtonsEnabled(true); + } + + private void HandleLoggedOut() + { + Debug.Log("[AuthManagerExample] 로그아웃 이벤트 수신"); + UpdateStatusText("로그아웃 완료"); + UpdateUI(); + } + + private void HandleTokenAutoRefreshed(string newAccessToken) + { + Debug.Log("[AuthManagerExample] 토큰 자동 갱신 이벤트 수신"); + UpdateStatusText("토큰이 자동으로 갱신되었습니다."); + UpdateUI(); + } + + private void HandleReLoginRequired(string reason) + { + Debug.LogWarning($"[AuthManagerExample] 재로그인 필요 이벤트 수신: {reason}"); + UpdateStatusText($"재로그인이 필요합니다.\n이유: {reason}"); + UpdateUI(); + } + + #endregion + + #region UI Update Methods + + private void UpdateUI() + { + if (_authManager == null) return; + + // 버튼 활성화 상태 업데이트 + bool isLoggedIn = _authManager.IsLoggedIn; + bool hasValidRefreshToken = _authManager.HasValidRefreshToken; + + if (guestLoginButton != null) + guestLoginButton.interactable = !isLoggedIn && _authManager.CanLoginAsGuest(); + + if (oauth2LoginButton != null) + oauth2LoginButton.interactable = !isLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = isLoggedIn; + + if (refreshTokenButton != null) + refreshTokenButton.interactable = hasValidRefreshToken; + + // 디버그 정보 업데이트 + if (debugInfoText != null) + { + debugInfoText.text = _authManager.GetDebugInfo(); + } + } + + private void UpdateStatusText(string status) + { + if (statusText != null) + { + statusText.text = $"[{DateTime.Now:HH:mm:ss}] {status}"; + } + Debug.Log($"[AuthManagerExample] Status: {status}"); + } + + private void SetButtonsEnabled(bool enabled) + { + if (guestLoginButton != null) + guestLoginButton.interactable = enabled && !_authManager.IsLoggedIn && _authManager.CanLoginAsGuest(); + + if (oauth2LoginButton != null) + oauth2LoginButton.interactable = enabled && !_authManager.IsLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = enabled && _authManager.IsLoggedIn; + + if (refreshTokenButton != null) + refreshTokenButton.interactable = enabled && _authManager.HasValidRefreshToken; + + if (checkStatusButton != null) + checkStatusButton.interactable = enabled; + } + + #endregion + + #region Test Methods (Unity Inspector에서 호출 가능) + + [ContextMenu("Test Guest Login")] + public void TestGuestLogin() + { + OnGuestLoginClicked().Forget(); + } + + [ContextMenu("Test OAuth2 Login")] + public void TestOAuth2Login() + { + OnOAuth2LoginClicked().Forget(); + } + + [ContextMenu("Test Logout")] + public void TestLogout() + { + OnLogoutClicked(); + } + + [ContextMenu("Test Token Refresh")] + public void TestTokenRefresh() + { + OnRefreshTokenClicked().Forget(); + } + + [ContextMenu("Show Debug Info")] + public void ShowDebugInfo() + { + if (_authManager != null) + { + Debug.Log(_authManager.GetDebugInfo()); + } + } + + [ContextMenu("Test Auto Login Simulation")] + public async void TestAutoLoginSimulation() + { + try + { + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 시작"); + + // 현재 토큰 상태 확인 + if (_authManager.IsLoggedIn) + { + Debug.Log("[AuthManagerExample] 이미 로그인된 상태입니다."); + return; + } + + // Refresh Token으로 자동 로그인 시도 + if (_authManager.HasValidRefreshToken) + { + UpdateStatusText("자동 로그인 시뮬레이션 중..."); + bool success = await _authManager.RefreshTokenAsync(); + + if (success) + { + UpdateStatusText("자동 로그인 시뮬레이션 성공"); + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 성공"); + } + else + { + UpdateStatusText("자동 로그인 시뮬레이션 실패 - 재로그인 필요"); + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 실패"); + } + } + else + { + UpdateStatusText("유효한 Refresh Token이 없어 자동 로그인 불가"); + Debug.Log("[AuthManagerExample] 유효한 Refresh Token이 없습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 자동 로그인 시뮬레이션 오류: {ex.Message}"); + UpdateStatusText($"자동 로그인 시뮬레이션 오류: {ex.Message}"); + } + finally + { + UpdateUI(); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta new file mode 100644 index 0000000..bcdc07e --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f606a2fb3216f042a46e97bfca2f735 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs b/Assets/Infrastructure/Auth/Models/AccessToken.cs index 7ea7d9d..1215813 100644 --- a/Assets/Infrastructure/Auth/Models/AccessToken.cs +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs @@ -20,5 +20,10 @@ public bool IsExpired() { return DateTime.UtcNow >= ExpiresAt; } + + public bool IsExpiringSoon(int minutesBeforeExpiry) + { + return DateTime.UtcNow.AddMinutes(minutesBeforeExpiry) >= ExpiresAt; + } } } \ No newline at end of file