Skip to content
Open
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
24 changes: 22 additions & 2 deletions app/src/main/java/com/kuit/ourmenu/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.kuit.ourmenu

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
Expand All @@ -12,23 +13,42 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.imageLoader
import com.kuit.ourmenu.ui.navigator.MainNavHost
import com.kuit.ourmenu.ui.navigator.MainTab
import com.kuit.ourmenu.ui.navigator.component.MainBottomBar
import com.kuit.ourmenu.ui.navigator.rememberMainNavigator
import androidx.navigation.compose.rememberNavController
import coil3.imageLoader
import com.kuit.ourmenu.ui.onboarding.screen.SplashScreen
import com.kuit.ourmenu.ui.theme.NeutralWhite
import com.kuit.ourmenu.ui.theme.OurMenuTheme
import com.kuit.ourmenu.utils.auth.TokenManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var tokenManager: TokenManager

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

// 로그아웃 이벤트 구독
lifecycleScope.launch {
tokenManager.logoutEvent.collect {
// Activity 스택을 모두 클리어하고 MainActivity를 다시 시작
val intent = Intent(this@MainActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
finish()
}
}

setContent {
var showSplash by remember { mutableStateOf(true) }
val navController = rememberMainNavigator()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,6 @@ class AuthRepository @Inject constructor(
}


suspend fun reissueToken(
refreshToken: String
) = runCatching {
authService.reissueToken(refreshToken).handleBaseResponse().getOrThrow()
}

suspend fun sendEmail(
email: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import com.kuit.ourmenu.data.model.auth.request.SignupRequest
import com.kuit.ourmenu.data.model.auth.response.CheckKakaoEmailResponse
import com.kuit.ourmenu.data.model.auth.response.EmailResponse
import com.kuit.ourmenu.data.model.auth.response.LoginResponse
import com.kuit.ourmenu.data.model.auth.response.ReissueTokenResponse
import com.kuit.ourmenu.data.model.auth.response.SignupResponse
import com.kuit.ourmenu.data.model.base.BaseResponse
import retrofit2.http.Body
Expand All @@ -27,10 +26,6 @@ interface AuthService {
@Body request: LoginRequest
): BaseResponse<LoginResponse>

@POST("api/users/reissue-token")
suspend fun reissueToken(
@Body refreshToken: String
): BaseResponse<ReissueTokenResponse>

@POST("/api/users/auth/kakao")
suspend fun checkKakaoEmail(
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/kuit/ourmenu/data/service/TokenService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.kuit.ourmenu.data.service

import com.kuit.ourmenu.data.model.auth.response.ReissueTokenResponse
import com.kuit.ourmenu.data.model.base.BaseResponse
import retrofit2.http.Body
import retrofit2.http.POST

interface TokenService {
@POST("api/users/reissue-token")
suspend fun reissueToken(
@Body refreshToken: String
): BaseResponse<ReissueTokenResponse>
Comment on lines +10 to +12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Retrofit+kotlinx-serialization에서 @Body String은 변환 불가 → 요청 모델 필요

Json 컨버터만 등록된 상태에서 @Body String은 지원되지 않습니다. 런타임에 “no converter for String” 오류가 납니다. 요청 DTO를 정의해 주세요.

적용 diff:

@@
-import com.kuit.ourmenu.data.model.auth.response.ReissueTokenResponse
+import com.kuit.ourmenu.data.model.auth.response.ReissueTokenResponse
+import com.kuit.ourmenu.data.model.auth.request.ReissueTokenRequest
@@
-    suspend fun reissueToken(
-        @Body refreshToken: String
-    ): BaseResponse<ReissueTokenResponse>
+    suspend fun reissueToken(
+        @Body body: ReissueTokenRequest
+    ): BaseResponse<ReissueTokenResponse>

추가 파일(예시):

// app/src/main/java/com/kuit/ourmenu/data/model/auth/request/ReissueTokenRequest.kt
package com.kuit.ourmenu.data.model.auth.request

import kotlinx.serialization.Serializable

@Serializable
data class ReissueTokenRequest(val refreshToken: String)

Also applies to: 3-5

🤖 Prompt for AI Agents
In app/src/main/java/com/kuit/ourmenu/data/service/TokenService.kt around lines
10-12, the Retrofit interface currently declares suspend fun reissueToken(@Body
refreshToken: String): BaseResponse<ReissueTokenResponse> which fails at runtime
because kotlinx-serialization's JSON converter does not support raw String
bodies; create a request DTO (e.g.
app/src/main/java/com/kuit/ourmenu/data/model/auth/request/ReissueTokenRequest.kt
annotated @Serializable with a single property refreshToken: String), update the
interface method to accept @Body ReissueTokenRequest, and update any call sites
to wrap the raw token string in the new DTO (apply the same change for the
similar methods referenced at lines 3-5).

}
67 changes: 35 additions & 32 deletions app/src/main/java/com/kuit/ourmenu/utils/auth/TokenAuthenticator.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.kuit.ourmenu.utils.auth

import android.util.Log
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.kuit.ourmenu.BuildConfig
import com.kuit.ourmenu.data.model.auth.response.ReissueTokenResponse
import com.kuit.ourmenu.data.service.AuthService
import com.kuit.ourmenu.data.service.TokenService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
Expand All @@ -13,50 +13,53 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import javax.inject.Inject

class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
): Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val refreshToken = runBlocking {
tokenManager.getRefreshToken().first()
// 이미 재시도했던 요청인 경우 null을 반환하여 재시도 중단
if (response.request.header("Retry-With-New-Token") != null) {
return null
}

return runBlocking {
if (refreshToken.isNullOrEmpty()){
return@runBlocking null
}
val newToken = getNewToken(refreshToken)
try {
val refreshToken = tokenManager.getRefreshToken().first()
if (refreshToken == null) {
tokenManager.clearToken()
return@runBlocking null
}

if (newToken == null) {
tokenManager.clearToken()
}
// 토큰 재발급 요청
val tokenService = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(TokenService::class.java)
val tokenResponse = tokenService.reissueToken(
refreshToken = refreshToken
)

Comment on lines +43 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

reissueToken 호출 시 String 바디 사용 문제 (실패 원인)

TokenService가 요청 DTO를 받도록 바뀌면 여기 호출도 DTO로 교체해야 합니다.

적용 diff:

+import com.kuit.ourmenu.data.model.auth.request.ReissueTokenRequest
@@
-                val tokenResponse = tokenService.reissueToken(
-                    refreshToken = refreshToken
-                )
+                val tokenResponse = tokenService.reissueToken(
+                    body = ReissueTokenRequest(refreshToken = refreshToken)
+                )

Also applies to: 3-7

🤖 Prompt for AI Agents
In app/src/main/java/com/kuit/ourmenu/utils/auth/TokenAuthenticator.kt around
lines 43 to 46 (and also check occurrences at lines ~3-7), the call to
tokenService.reissueToken currently passes a raw String refreshToken body but
TokenService signature has changed to accept a request DTO; replace the String
argument with the appropriate ReissueTokenRequest DTO instance (e.g.,
ReissueTokenRequest(refreshToken)) and import/use that DTO, updating the call
site to match the new method signature and adjust any surrounding code/error
handling accordingly.

// 새로운 토큰 저장
tokenManager.saveAccessToken(tokenResponse.response?.accessToken ?: "")
tokenManager.saveRefreshToken(tokenResponse.response?.refreshToken ?: "")
Log.d("TokenAuthenticator", "토큰 재발급 성공: ${tokenResponse.response?.accessToken}")

newToken?.let {
tokenManager.saveAccessToken(it.accessToken)
// 기존 요청에 새로운 토큰으로 헤더를 추가하여 재시도
response.request.newBuilder()
.header("Authorization", "Bearer ${it.accessToken}")
.header("Authorization", "Bearer ${tokenResponse.response?.accessToken}")
.header("Retry-With-New-Token", "true")
.build()
Comment on lines +48 to 56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

널/빈 토큰 저장 및 ‘Bearer null’ 헤더 위험 + 민감정보 로그 노출

  • 응답 토큰이 null/빈 문자열일 때 빈 값을 저장하고 ‘Bearer null’을 보낼 수 있습니다.
  • 액세스 토큰을 로그에 그대로 남기는 건 보안 리스크입니다.

적용 diff:

@@
-                // 새로운 토큰 저장
-                tokenManager.saveAccessToken(tokenResponse.response?.accessToken ?: "")
-                tokenManager.saveRefreshToken(tokenResponse.response?.refreshToken ?: "")
-                Log.d("TokenAuthenticator", "토큰 재발급 성공: ${tokenResponse.response?.accessToken}")
+                // 새로운 토큰 저장 (검증)
+                val newAccess = tokenResponse.response?.accessToken?.takeIf { it.isNotBlank() }
+                val newRefresh = tokenResponse.response?.refreshToken?.takeIf { it.isNotBlank() }
+                if (newAccess == null || newRefresh == null) {
+                    Log.w("TokenAuthenticator", "토큰 재발급 응답에 토큰이 없습니다.")
+                    tokenManager.clearToken()
+                    return@runBlocking null
+                }
+                tokenManager.saveAccessToken(newAccess)
+                tokenManager.saveRefreshToken(newRefresh)
+                Log.d("TokenAuthenticator", "토큰 재발급 성공") // 민감정보 비노출
@@
-                    .header("Authorization", "Bearer ${tokenResponse.response?.accessToken}")
+                    .header("Authorization", "Bearer $newAccess")
                     .header("Retry-With-New-Token", "true")
                     .build()
@@
-                // 토큰 재발급 실패 시 토큰 클리어
-                Log.d("TokenAuthenticator", "토큰 재발급 실패: ${e.message}")
+                // 토큰 재발급 실패 시 토큰 클리어 (민감정보 비노출)
+                Log.d("TokenAuthenticator", "토큰 재발급 실패: ${e.message}")
                 tokenManager.clearToken()
                 null

Also applies to: 57-62, 50-51

} catch (e: Exception) {
// 토큰 재발급 실패 시 토큰 클리어
Log.d("TokenAuthenticator", "토큰 재발급 실패: ${e.message}")
tokenManager.clearToken()
null
}
}
}

private suspend fun getNewToken(refreshToken: String): ReissueTokenResponse? {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
val okHttpClient = OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()

val retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(
Json.asConverterFactory(requireNotNull("application/json".toMediaType()))
)
.client(okHttpClient)
.build()
val service = retrofit.create(AuthService::class.java)
return service.reissueToken(refreshToken).response
}

}
6 changes: 6 additions & 0 deletions app/src/main/java/com/kuit/ourmenu/utils/auth/TokenManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -14,6 +16,9 @@ private val Context.dataStore by preferencesDataStore(name = "user_prefs")

@Singleton
class TokenManager @Inject constructor(@ApplicationContext private val context: Context) {
private val _logoutEvent = MutableSharedFlow<Unit>()
val logoutEvent = _logoutEvent.asSharedFlow()

Comment on lines +19 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

SharedFlow 기본값으로 emit이 영구 대기할 수 있음 (OkHttp 스레드 정지 위험) + 타입 명시 제안

clearToken()에서 _logoutEvent.emit(Unit)은 수집자가 없을 때 suspend됩니다. 현재 TokenAuthenticator.authenticate()에서 runBlocking 내부에서 clearToken()을 호출하므로, 수집자가 없으면 OkHttp 네트워크 스레드가 멈출 수 있습니다. 버퍼를 주고 tryEmit으로 전환해 비차단으로 바꿔주세요. 아울러 공개 API 가독성을 위해 타입을 명시하면 좋습니다.

적용 diff:

@@
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@
-    private val _logoutEvent = MutableSharedFlow<Unit>()
-    val logoutEvent = _logoutEvent.asSharedFlow()
+    private val _logoutEvent = MutableSharedFlow<Unit>(replay = 0, extraBufferCapacity = 1)
+    val logoutEvent: SharedFlow<Unit> = _logoutEvent.asSharedFlow()
@@
-        _logoutEvent.emit(Unit)
+        _logoutEvent.tryEmit(Unit)

Also applies to: 51-57, 9-11

🤖 Prompt for AI Agents
In app/src/main/java/com/kuit/ourmenu/utils/auth/TokenManager.kt around lines
9-11, 19-21 and 51-57, the MutableSharedFlow is created with default settings
and clearToken() calls emit from a suspend context inside runBlocking, which can
block OkHttp threads; change the flow to a non-blocking buffered shared flow and
make public types explicit: declare private val _logoutEvent:
MutableSharedFlow<Unit> = MutableSharedFlow(replay = 0, extraBufferCapacity = 1)
and public val logoutEvent: SharedFlow<Unit> to improve readability, then
replace any calls to _logoutEvent.emit(Unit) with the non-suspending
_logoutEvent.tryEmit(Unit) in clearToken() (and any other emit sites) so
emission is non-blocking.

companion object {
private val ACCESS_TOKEN = stringPreferencesKey("access_token")
private val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
Expand Down Expand Up @@ -48,5 +53,6 @@ class TokenManager @Inject constructor(@ApplicationContext private val context:
preferences.remove(ACCESS_TOKEN)
preferences.remove(REFRESH_TOKEN)
}
_logoutEvent.emit(Unit)
}
}