diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManager.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManager.kt index 45575c8..c6b879e 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManager.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManager.kt @@ -1,8 +1,13 @@ package org.whosin.client.core.datastore +import io.ktor.client.HttpClient + interface TokenManager { suspend fun getAccessToken(): String? suspend fun getRefreshToken(): String? suspend fun saveTokens(accessToken: String, refreshToken: String) suspend fun clearToken() + + // HttpClient 참조 설정 (토큰 변경 시 자동 클리어용) + fun setHttpClient(httpClient: HttpClient) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt index eb1690b..2b776f4 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt @@ -4,13 +4,16 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first +import org.whosin.client.core.network.invalidateAuthTokens class TokenManagerImpl( private val dataStore: DataStore ): TokenManager { private val accessKey = stringPreferencesKey("access_token") private val refreshKey = stringPreferencesKey("refresh_token") + private var httpClient: HttpClient? = null override suspend fun getAccessToken(): String? = dataStore.data.first()[accessKey] override suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshKey] @@ -20,9 +23,28 @@ class TokenManagerImpl( it[accessKey] = accessToken it[refreshKey] = refreshToken } + // 저장 후 즉시 확인하여 저장이 완료되었는지 검증 + val savedAccessToken = getAccessToken() + val savedRefreshToken = getRefreshToken() + println("저장된 토큰: AccessToken=$savedAccessToken, RefreshToken=$savedRefreshToken") + + if (savedAccessToken != accessToken || savedRefreshToken != refreshToken) { + throw IllegalStateException("토큰 저장이 완료되지 않았습니다.") + } + + // HttpClient의 캐시된 토큰 클리어하여 loadTokens가 다시 실행되도록 함 + httpClient?.invalidateAuthTokens() } override suspend fun clearToken() { dataStore.edit { it.clear() } + println("토큰이 성공적으로 삭제되었습니다. ${getAccessToken()}, ${getRefreshToken()}") + + // HttpClient의 캐시된 토큰 클리어 + httpClient?.invalidateAuthTokens() + } + + override fun setHttpClient(httpClient: HttpClient) { + this.httpClient = httpClient } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt index 66370ab..2eab1df 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt @@ -5,13 +5,17 @@ import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.authProvider import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.plugin +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType @@ -28,11 +32,12 @@ import org.whosin.client.data.dto.response.ReissueTokenResponseDto object HttpClientFactory { val BASE_URL = BuildKonfig.BASE_URL + fun create( engine: HttpClientEngine, tokenManager: TokenManager ): HttpClient { - return HttpClient(engine) { + val httpClient = HttpClient(engine) { install(ContentNegotiation) { json( json = Json { @@ -50,9 +55,13 @@ object HttpClientFactory { bearer { loadTokens { val accessToken = tokenManager.getAccessToken() - ?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk" - val refreshToken = tokenManager.getRefreshToken() ?: "no_token" - BearerTokens(accessToken = accessToken, refreshToken = refreshToken) + val refreshToken = tokenManager.getRefreshToken() + println("loadTokens 실행, Access: $accessToken, Refresh: $refreshToken") + if (accessToken.isNullOrEmpty() || refreshToken.isNullOrEmpty()) { + null + } else { + BearerTokens(accessToken = accessToken, refreshToken = refreshToken) + } } sendWithoutRequest { request -> val requestHost = request.url.host @@ -77,7 +86,7 @@ object HttpClientFactory { "auth/login", "auth/email", "auth/email/validation", - "auth/reissue" // 토큰 재발급 요청 +// "auth/reissue" // 토큰 재발급 요청 ) val isNoAuthPath = pathWithNoAuth.any { noAuthPath -> @@ -98,9 +107,15 @@ object HttpClientFactory { return@refreshTokens null } + // 토큰 재발급 요청 시에는 현재 access token을 헤더에 수동으로 추가 + val currentAccessToken = tokenManager.getAccessToken() val response = client.post("auth/reissue") { setBody(ReissueTokenRequestDto(refreshToken = rt)) - markAsRefreshTokenRequest() +// markAsRefreshTokenRequest() // 헤더에 access token이 추가될 필요가 없는 경우에 사용 + // 현재 access token을 헤더에 추가 + if (!currentAccessToken.isNullOrEmpty()) { + header("Authorization", "Bearer $currentAccessToken") + } }.body() if (response.success && response.data != null) { @@ -138,5 +153,25 @@ object HttpClientFactory { url(BASE_URL) } } + + // TokenManager에 HttpClient 참조 설정 + tokenManager.setHttpClient(httpClient) + + return httpClient + } +} + +/** + * HttpClient의 BearerAuthProvider에서 캐시된 토큰을 클리어하여 + * loadTokens 블록이 다시 실행되도록 합니다. + */ +fun HttpClient.invalidateAuthTokens() { + try { + val authProvider = this.authProvider() + requireNotNull(authProvider) + authProvider.clearToken() + println("BearerAuthProvider 토큰이 성공적으로 클리어되었습니다.") + } catch (e: Exception) { + println("토큰 클리어 중 오류 발생: ${e.message}") } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/DeleteAccountResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/DeleteAccountResponseDto.kt new file mode 100644 index 0000000..771b087 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/DeleteAccountResponseDto.kt @@ -0,0 +1,16 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteAccountResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt index 54c07e9..56afa77 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -13,6 +13,7 @@ import org.whosin.client.data.dto.request.FindPasswordRequestDto import org.whosin.client.data.dto.request.LoginRequestDto import org.whosin.client.data.dto.request.LogoutRequestDto import org.whosin.client.data.dto.request.SignupRequestDto +import org.whosin.client.data.dto.response.DeleteAccountResponseDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto import org.whosin.client.data.dto.response.ErrorResponseDto import org.whosin.client.data.dto.response.FindPasswordResponseDto @@ -200,5 +201,33 @@ class RemoteAuthDataSource( } } - // TODO: 회원탈퇴 + suspend fun deleteAccount(): ApiResult { + return try { + val response: HttpResponse = client + .post("users/delete/account") + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + // 에러 응답 파싱 시도 + try { + val errorResponse: ErrorResponseDto = response.body() + ApiResult.Error( + code = response.status.value, + message = errorResponse.message + ) + } catch (e: Exception) { + // 파싱 실패 시 기본 에러 메시지 + ApiResult.Error( + code = response.status.value, + message = "HTTP Error: ${response.status.value}" + ) + } + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt index e124553..67f82a0 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt @@ -2,6 +2,7 @@ package org.whosin.client.data.repository import org.whosin.client.data.remote.RemoteAuthDataSource import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.dto.response.DeleteAccountResponseDto import org.whosin.client.data.dto.response.LoginResponseDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto import org.whosin.client.data.dto.response.SignupResponseDto @@ -41,4 +42,7 @@ class AuthRepository( suspend fun logout(refreshToken: String): ApiResult = dataSource.logout(refreshToken) + suspend fun deleteAccount(): ApiResult = + dataSource.deleteAccount() + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/home/HomeScreen.kt index 74aef3e..954c96f 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/home/HomeScreen.kt @@ -61,8 +61,8 @@ import whosinclient.composeapp.generated.resources.people_count fun HomeScreen( modifier: Modifier = Modifier, onNavigateToMyPage: () -> Unit, - viewModel: HomeViewModel = koinViewModel() ) { + val viewModel: HomeViewModel = koinViewModel() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt index aaad255..1ee41a1 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt @@ -221,7 +221,7 @@ fun MyPageScreen( shape = RoundedCornerShape(10.dp), onClick = { showDeleteDialog = false - // TODO: 회원 탈퇴 api 연결 + viewModel.deleteAccount() }, colors = ButtonDefaults.buttonColors( containerColor = Color(0xFFFF3636), diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt index 85845f7..1081057 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt @@ -117,29 +117,41 @@ class MyPageViewModel( fun logout(){ viewModelScope.launch { val refreshToken = tokenManager.getRefreshToken() - if (refreshToken.isNullOrEmpty()) { - // 리프레시 토큰이 없으면 바로 토큰 삭제 및 로그인 화면으로 이동 - tokenManager.clearToken() - TokenExpiredManager.setTokenExpired() - return@launch + + // 서버에 로그아웃 요청 (실패해도 상관없음) + if (!refreshToken.isNullOrEmpty()) { + try { + authRepository.logout(refreshToken) + println("MyPageViewModel: 로그아웃 API 호출 완료") + } catch (e: Exception) { + println("MyPageViewModel: 로그아웃 API 호출 실패 - ${e.message}") + } } - when (val result = authRepository.logout(refreshToken)) { + // 토큰 삭제 및 로그인 화면으로 이동 + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + } + } + + fun deleteAccount() { + viewModelScope.launch { + _uiState.update{ it.copy(isLoading = true) } + when (val result = authRepository.deleteAccount()) { is ApiResult.Success -> { - println("MyPageViewModel: 로그아웃 성공") + println("MyPageViewModel: 회원 탈퇴 성공") // 토큰 삭제 및 로그인 화면으로 이동 tokenManager.clearToken() TokenExpiredManager.setTokenExpired() } is ApiResult.Error -> { _uiState.value = _uiState.value.copy( - errorMessage = result.message ?: "로그아웃에 실패했습니다." + isLoading = false, + errorMessage = result.message ?: "회원 탈퇴에 실패했습니다." ) - println("MyPageViewModel: 로그아웃 실패 - ${result.message}") + println("MyPageViewModel: 회원 탈퇴 실패 - ${result.message}") } } } } - - // TODO: 회원 탈퇴 } \ No newline at end of file