Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Preferences>
): 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]
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -77,7 +86,7 @@ object HttpClientFactory {
"auth/login",
"auth/email",
"auth/email/validation",
"auth/reissue" // 토큰 재발급 요청
// "auth/reissue" // 토큰 재발급 요청
)

val isNoAuthPath = pathWithNoAuth.any { noAuthPath ->
Expand All @@ -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<ReissueTokenResponseDto>()

if (response.success && response.data != null) {
Expand Down Expand Up @@ -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<BearerAuthProvider>()
requireNotNull(authProvider)
authProvider.clearToken()
println("BearerAuthProvider 토큰이 성공적으로 클리어되었습니다.")
} catch (e: Exception) {
println("토큰 클리어 중 오류 발생: ${e.message}")
}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -200,5 +201,33 @@ class RemoteAuthDataSource(
}
}

// TODO: 회원탈퇴
suspend fun deleteAccount(): ApiResult<DeleteAccountResponseDto> {
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,4 +42,7 @@ class AuthRepository(
suspend fun logout(refreshToken: String): ApiResult<LogoutResponseDto> =
dataSource.logout(refreshToken)

suspend fun deleteAccount(): ApiResult<DeleteAccountResponseDto> =
dataSource.deleteAccount()

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ fun MyPageScreen(
shape = RoundedCornerShape(10.dp),
onClick = {
showDeleteDialog = false
// TODO: 회원 탈퇴 api 연결
viewModel.deleteAccount()
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3636),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: 회원 탈퇴
}