From 53efebbdedd8d9050b7d262d71fa82b2f53a19d7 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 24 Oct 2025 21:00:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20=EC=9C=A0=EC=A0=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8,=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85,?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0,=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83,=20Authorization=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/app/navigation/AppNavHost.kt | 44 ++++++++-- .../sampoom/android/core/database/.gitkeep | 0 .../android/core/datastore/AuthPreferences.kt | 88 +++++++++++++++++++ .../android/core/network/NetworkModule.kt | 61 ++++++++++--- .../android/core/network/TokenInterceptor.kt | 26 ++++++ .../core/network/TokenRefreshInterceptor.kt | 40 +++++++++ .../core/network/TokenRefreshService.kt | 41 +++++++++ .../data/local/preferences/AuthPreferences.kt | 50 ----------- .../feature/auth/data/mapper/AuthMappers.kt | 6 -- .../feature/auth/data/remote/api/AuthApi.kt | 17 ---- .../auth/data/remote/dto/SignUpRequestDto.kt | 10 --- .../data/repository/AuthRepositoryImpl.kt | 47 ---------- .../android/feature/auth/domain/model/User.kt | 10 --- .../auth/domain/repository/AuthRepository.kt | 18 ---- .../feature/user/data/mapper/AuthMappers.kt | 6 ++ .../feature/user/data/remote/api/AuthApi.kt | 36 ++++++++ .../data/remote/dto/LoginRequestDto.kt | 2 +- .../data/remote/dto/LoginResponseDto.kt | 4 +- .../user/data/remote/dto/RefreshRequestDto.kt | 5 ++ .../data/remote/dto/RefreshResponseDto.kt | 7 ++ .../user/data/remote/dto/SignUpRequestDto.kt | 10 +++ .../data/remote/dto/SignUpResponseDto.kt | 2 +- .../user/data/remote/dto/UpdateRequestDto.kt | 8 ++ .../user/data/remote/dto/UpdateResponseDto.kt | 10 +++ .../user/data/remote/dto/VerifyRequestDto.kt | 6 ++ .../user/data/remote/dto/VerifyResponseDto.kt | 8 ++ .../data/repository/AuthRepositoryImpl.kt | 72 +++++++++++++++ .../feature/{auth => user}/di/AuthModules.kt | 8 +- .../{auth => user}/domain/AuthValidator.kt | 2 +- .../android/feature/user/domain/model/User.kt | 10 +++ .../user/domain/repository/AuthRepository.kt | 20 +++++ .../domain/usecase/CheckLoginStateUseCase.kt | 10 +++ .../domain/usecase/LoginUseCase.kt | 6 +- .../user/domain/usecase/SignOutUseCase.kt | 10 +++ .../domain/usecase/SignUpUseCase.kt | 22 ++--- .../android/feature/user/ui/AuthViewModel.kt | 37 ++++++++ .../feature/{auth => user}/ui/LoginScreen.kt | 3 +- .../feature/{auth => user}/ui/LoginUiEvent.kt | 2 +- .../feature/{auth => user}/ui/LoginUiState.kt | 2 +- .../{auth => user}/ui/LoginViewModel.kt | 8 +- .../feature/{auth => user}/ui/SignUpScreen.kt | 5 +- .../{auth => user}/ui/SignUpUiEvent.kt | 2 +- .../{auth => user}/ui/SignUpUiState.kt | 2 +- .../{auth => user}/ui/SignUpViewModel.kt | 16 ++-- 44 files changed, 576 insertions(+), 223 deletions(-) delete mode 100644 app/src/main/java/com/sampoom/android/core/database/.gitkeep create mode 100644 app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt create mode 100644 app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt create mode 100644 app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt create mode 100644 app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt delete mode 100644 app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/mapper/AuthMappers.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/data/remote/dto/LoginRequestDto.kt (57%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/data/remote/dto/LoginResponseDto.kt (66%) create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshRequestDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshResponseDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpRequestDto.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/data/remote/dto/SignUpResponseDto.kt (64%) create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateRequestDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateResponseDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyRequestDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyResponseDto.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/di/AuthModules.kt (71%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/domain/AuthValidator.kt (98%) create mode 100644 app/src/main/java/com/sampoom/android/feature/user/domain/model/User.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/domain/usecase/LoginUseCase.kt (57%) create mode 100644 app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignOutUseCase.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/domain/usecase/SignUpUseCase.kt (50%) create mode 100644 app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/LoginScreen.kt (98%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/LoginUiEvent.kt (82%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/LoginUiState.kt (91%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/LoginViewModel.kt (92%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/SignUpScreen.kt (97%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/SignUpUiEvent.kt (91%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/SignUpUiState.kt (96%) rename app/src/main/java/com/sampoom/android/feature/{auth => user}/ui/SignUpViewModel.kt (93%) diff --git a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt index 482e416..db35350 100644 --- a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt +++ b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt @@ -3,8 +3,10 @@ package com.sampoom.android.app.navigation import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -12,11 +14,14 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -26,8 +31,9 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.sampoom.android.R import com.sampoom.android.core.ui.theme.backgroundColor -import com.sampoom.android.feature.auth.ui.LoginScreen -import com.sampoom.android.feature.auth.ui.SignUpScreen +import com.sampoom.android.feature.user.ui.AuthViewModel +import com.sampoom.android.feature.user.ui.LoginScreen +import com.sampoom.android.feature.user.ui.SignUpScreen import com.sampoom.android.feature.cart.ui.CartListScreen import com.sampoom.android.feature.order.ui.OrderDetailScreen import com.sampoom.android.feature.order.ui.OrderListScreen @@ -70,9 +76,16 @@ sealed class BottomNavItem( @Composable fun AppNavHost() { val navController = rememberNavController() + val authViewModel: AuthViewModel = hiltViewModel() + val isLoggedIn by authViewModel.isLoggedIn.collectAsState() - // TODO: 임시 로그인 상태 확인 -> AuthRepository에서 확인하도록 변경 - val isLoggedIn = true + LaunchedEffect(Unit) { + authViewModel.logoutEvent.collect { + navController.navigate(ROUTE_LOGIN) { + popUpTo(0) { inclusive = true } + } + } + } NavHost( navController = navController, @@ -82,6 +95,7 @@ fun AppNavHost() { composable(ROUTE_LOGIN) { LoginScreen( onSuccess = { + authViewModel.updateLoginState() navController.navigate(ROUTE_HOME) { popUpTo(ROUTE_LOGIN) { inclusive = true } // 로그인 화면 스택 제거 } @@ -163,7 +177,11 @@ fun MainScreen( composable(ROUTE_DASHBOARD) { DashboardScreen( paddingValues = innerPadding - ) + ) { + parentNavController.navigate(ROUTE_LOGIN) { + popUpTo(0) { inclusive = true } + } + } } composable(ROUTE_OUTBOUND) { OutboundListScreen( @@ -249,8 +267,20 @@ fun BottomNavigationBar(navController: NavHostController) { // 임시 화면들 (실제로는 각각의 feature 모듈에서 구현) @Composable private fun DashboardScreen( - paddingValues: PaddingValues + paddingValues: PaddingValues, + onClick: () -> Unit ) { + val authViewModel: AuthViewModel = hiltViewModel() // 홈 화면 구현 - Text("대시보드 화면", modifier = Modifier.padding(paddingValues)) + Column(Modifier.padding(paddingValues)) { + Text("대시보드 화면", modifier = Modifier.padding(paddingValues)) + + Button(onClick = { + authViewModel.signOut() + onClick() + }) { + Text("로그아웃") + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/database/.gitkeep b/app/src/main/java/com/sampoom/android/core/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt new file mode 100644 index 0000000..4c30e3c --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt @@ -0,0 +1,88 @@ +package com.sampoom.android.core.datastore + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.sampoom.android.feature.user.domain.model.User +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +// Per official guidance, DataStore instance should be single and at top-level. +private val Context.authDataStore by preferencesDataStore(name = "auth_prefs") + +@Singleton +class AuthPreferences @Inject constructor( + @param:ApplicationContext private val context: Context +){ + private val dataStore = context.authDataStore + + private object Keys { + val ACCESS_TOKEN: Preferences.Key = stringPreferencesKey("access_token") + val REFRESH_TOKEN: Preferences.Key = stringPreferencesKey("refresh_token") + val TOKEN_EXPIRES_AT: Preferences.Key = longPreferencesKey("token_expires_at") + val USER_ID: Preferences.Key = longPreferencesKey("user_id") + val USER_NAME: Preferences.Key = stringPreferencesKey("user_name") + val USER_ROLE: Preferences.Key = stringPreferencesKey("user_role") + } + + suspend fun saveUser(user: User) { + val expiresAt = System.currentTimeMillis() + (user.expiresIn * 1000) + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = user.accessToken + prefs[Keys.REFRESH_TOKEN] = user.refreshToken + prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt + prefs[Keys.USER_ID] = user.userId + prefs[Keys.USER_NAME] = user.userName + prefs[Keys.USER_ROLE] = user.role + } + } + + // Suspend save to avoid blocking thread + suspend fun saveToken(accessToken: String, refreshToken: String, expiresIn: Long) { + val expiresAt = System.currentTimeMillis() + (expiresIn * 1000) + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken + prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt + } + } + + fun getStoredUser(): User? = runBlocking { + val userId = dataStore.data.first()[Keys.USER_ID] + val userName = dataStore.data.first()[Keys.USER_NAME] + val userRole = dataStore.data.first()[Keys.USER_ROLE] + val accessToken = dataStore.data.first()[Keys.ACCESS_TOKEN] + val refreshToken = dataStore.data.first()[Keys.REFRESH_TOKEN] + + if (userId != null && userName != null && userRole != null && + accessToken != null && refreshToken != null) { + User(userId, userName, userRole, accessToken, refreshToken, 0) + } else null + } + + // Synchronous getters backed by runBlocking for minimal surface change + fun getAccessToken(): String? = runBlocking { + dataStore.data.first()[Keys.ACCESS_TOKEN] + } + + fun getRefreshToken(): String? = runBlocking { + dataStore.data.first()[Keys.REFRESH_TOKEN] + } + + fun isTokenExpired(): Boolean { + val expiresAt = runBlocking { dataStore.data.first()[Keys.TOKEN_EXPIRES_AT] } + return expiresAt == null || System.currentTimeMillis() > expiresAt + } + + suspend fun clear() { + dataStore.edit { it.clear() } + } + + fun hasToken(): Boolean = !getAccessToken().isNullOrEmpty() && !getRefreshToken().isNullOrEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt index dac6a14..3393b9b 100644 --- a/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt +++ b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt @@ -11,26 +11,61 @@ import javax.inject.Singleton import com.google.gson.GsonBuilder import com.google.gson.FieldNamingPolicy import com.sampoom.android.BuildConfig +import com.sampoom.android.core.datastore.AuthPreferences import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit @Module @InstallIn(SingletonComponent::class) object NetworkModule { - @Provides @Singleton fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .addInterceptor(HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) - HttpLoggingInterceptor.Level.BODY - else - HttpLoggingInterceptor.Level.NONE - }) - // TODO: 로그인 기능 연동 후 인증 인터셉터 추가 필요 - .build() + @Provides + @Singleton + fun provideTokenInterceptor( + authPreferences: AuthPreferences + ): TokenInterceptor { + return TokenInterceptor(authPreferences) + } + + @Provides + @Singleton + fun provideTokenRefreshService( + authPreferences: AuthPreferences + ): TokenRefreshService { + return TokenRefreshService(authPreferences) + } + + @Provides + @Singleton + fun provideTokenRefreshInterceptor( + authPreferences: AuthPreferences, + tokenRefreshService: TokenRefreshService + ): TokenRefreshInterceptor { + return TokenRefreshInterceptor(authPreferences, tokenRefreshService) + } + + @Provides + @Singleton + fun provideOkHttpClient( + tokenInterceptor: TokenInterceptor, + tokenRefreshInterceptor: TokenRefreshInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) + HttpLoggingInterceptor.Level.BODY + else + HttpLoggingInterceptor.Level.NONE + }) + .addInterceptor(tokenInterceptor) // 토큰 자동 삽입 + .addInterceptor(tokenRefreshInterceptor) // 토큰 갱신 + .build() + } - @Provides @Singleton + @Provides + @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit { val gson = GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY) diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt new file mode 100644 index 0000000..945fc23 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt @@ -0,0 +1,26 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.datastore.AuthPreferences +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class TokenInterceptor @Inject constructor( + private val authPreferences: AuthPreferences +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (originalRequest.header("Authorization") == null) { + val accessToken = authPreferences.getAccessToken() + if (!accessToken.isNullOrEmpty()) { + val newRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(newRequest) + } + } + + return chain.proceed(originalRequest) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt new file mode 100644 index 0000000..ce9720f --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt @@ -0,0 +1,40 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.datastore.AuthPreferences +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class TokenRefreshInterceptor @Inject constructor( + private val authPreferences: AuthPreferences, + private val tokenRefreshService: TokenRefreshService +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + // 401 error + if (response.code == 401) { + try { + val newUser = runBlocking { + tokenRefreshService.refreshToken().getOrThrow() + } + + val newRequest = request.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer ${newUser.accessToken}") + .build() + + return chain.proceed(newRequest) + } catch (e: Exception) { + runBlocking { + authPreferences.clear() + } + return response + } + } + + return response + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt new file mode 100644 index 0000000..d95f9dc --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshService.kt @@ -0,0 +1,41 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.datastore.AuthPreferences +import com.sampoom.android.feature.user.data.remote.api.AuthApi +import com.sampoom.android.feature.user.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.user.domain.model.User +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenRefreshService @Inject constructor( + private val authPreferences: AuthPreferences +) { + suspend fun refreshToken(): Result = runCatching { + val refreshToken = authPreferences.getRefreshToken() + ?: throw Exception("No refresh token available") + + // 새로운 Retrofit 인스턴스 생성 (인터셉터 없이) + val retrofit = Retrofit.Builder() + .baseUrl("https://sampoom.store/api/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val authApi = retrofit.create(AuthApi::class.java) + val response = authApi.refresh(RefreshRequestDto(refreshToken)) + + val existingUser = authPreferences.getStoredUser() + ?: throw Exception("No user information available") + + val updatedUser = existingUser.copy( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + expiresIn = response.data.expiresIn + ) + + authPreferences.saveUser(updatedUser) + updatedUser + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt deleted file mode 100644 index 789a143..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/local/preferences/AuthPreferences.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.sampoom.android.feature.auth.data.local.preferences - -import android.content.Context -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -// Per official guidance, DataStore instance should be single and at top-level. -private val Context.authDataStore by preferencesDataStore(name = "auth_prefs") - -@Singleton -class AuthPreferences @Inject constructor( - @param:ApplicationContext private val context: Context -){ - private val dataStore = context.authDataStore - - private object Keys { - val ACCESS_TOKEN: Preferences.Key = stringPreferencesKey("access_token") - val REFRESH_TOKEN: Preferences.Key = stringPreferencesKey("refresh_token") - } - - // Suspend save to avoid blocking thread - suspend fun saveToken(accessToken: String, refreshToken: String) { - dataStore.edit { prefs -> - prefs[Keys.ACCESS_TOKEN] = accessToken - prefs[Keys.REFRESH_TOKEN] = refreshToken - } - } - - // Synchronous getters backed by runBlocking for minimal surface change - fun getAccessToken(): String? = runBlocking { - dataStore.data.first()[Keys.ACCESS_TOKEN] - } - - fun getRefreshToken(): String? = runBlocking { - dataStore.data.first()[Keys.REFRESH_TOKEN] - } - - suspend fun clear() { - dataStore.edit { it.clear() } - } - - fun hasToken(): Boolean = !getAccessToken().isNullOrEmpty() && !getRefreshToken().isNullOrEmpty() -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt deleted file mode 100644 index b79dea4..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/mapper/AuthMappers.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.sampoom.android.feature.auth.data.mapper - -import com.sampoom.android.feature.auth.data.remote.dto.LoginResponseDto -import com.sampoom.android.feature.auth.domain.model.User - -fun LoginResponseDto.toModel(): User = User(userId, userName, role, accessToken, refreshToken, expiresIn) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt deleted file mode 100644 index 41ee5bd..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/api/AuthApi.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.sampoom.android.feature.auth.data.remote.api - -import com.sampoom.android.core.network.ApiResponse -import com.sampoom.android.feature.auth.data.remote.dto.LoginRequestDto -import com.sampoom.android.feature.auth.data.remote.dto.SignUpRequestDto -import com.sampoom.android.feature.auth.data.remote.dto.SignUpResponseDto -import com.sampoom.android.feature.auth.data.remote.dto.LoginResponseDto -import retrofit2.http.Body -import retrofit2.http.POST - -interface AuthApi { - @POST("auth/login") - suspend fun login(@Body body: LoginRequestDto): ApiResponse - - @POST("auth/signup") - suspend fun signUp(@Body body: SignUpRequestDto): ApiResponse -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt deleted file mode 100644 index ff24ca2..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpRequestDto.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.sampoom.android.feature.auth.data.remote.dto - -data class SignUpRequestDto( - val name: String, - val workspace: String, - val branch: String, - val position: String, - val email: String, - val password: String -) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt deleted file mode 100644 index 673d57a..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/repository/AuthRepositoryImpl.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.sampoom.android.feature.auth.data.repository - -import com.sampoom.android.feature.auth.data.local.preferences.AuthPreferences -import com.sampoom.android.feature.auth.data.mapper.toModel -import com.sampoom.android.feature.auth.data.remote.api.AuthApi -import com.sampoom.android.feature.auth.data.remote.dto.LoginRequestDto -import com.sampoom.android.feature.auth.data.remote.dto.SignUpRequestDto -import com.sampoom.android.feature.auth.domain.model.User -import com.sampoom.android.feature.auth.domain.repository.AuthRepository -import javax.inject.Inject - -class AuthRepositoryImpl @Inject constructor( - private val api: AuthApi, - private val preferences: AuthPreferences -) : AuthRepository { - override suspend fun signUp( - name: String, - workspace: String, - branch: String, - position: String, - email: String, - password: String - ): User { - api.signUp(SignUpRequestDto( - name = name, - workspace = workspace, - branch = branch, - position = position, - email = email, - password = password - )) - return signIn(email, password) - } - - override suspend fun signIn( - email: String, - password: String - ): User { - val dto = api.login(LoginRequestDto(email, password)) - preferences.saveToken(dto.data.accessToken, dto.data.refreshToken) - return dto.data.toModel() - } - - override suspend fun signOut() { preferences.clear() } - - override fun isSignedIn(): Boolean = preferences.hasToken() -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt deleted file mode 100644 index d8479ae..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/model/User.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.sampoom.android.feature.auth.domain.model - -data class User( - val id: Long, - val name: String, - val role: String, - val accessToken: String, - val refreshToken: String, - val expiresIn: Int -) diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt b/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt deleted file mode 100644 index 747e985..0000000 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/repository/AuthRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.sampoom.android.feature.auth.domain.repository - -import com.sampoom.android.feature.auth.domain.model.User - -interface AuthRepository { - suspend fun signUp( - name: String, - workspace: String, - branch: String, - position: String, - email: String, - password: String - ): User - - suspend fun signIn(email: String, password: String): User - suspend fun signOut() - fun isSignedIn(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/mapper/AuthMappers.kt b/app/src/main/java/com/sampoom/android/feature/user/data/mapper/AuthMappers.kt new file mode 100644 index 0000000..9108f35 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/mapper/AuthMappers.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.user.data.mapper + +import com.sampoom.android.feature.user.data.remote.dto.LoginResponseDto +import com.sampoom.android.feature.user.domain.model.User + +fun LoginResponseDto.toModel(): User = User(userId, userName, role, accessToken, refreshToken, expiresIn) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt new file mode 100644 index 0000000..ca324e1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt @@ -0,0 +1,36 @@ +package com.sampoom.android.feature.user.data.remote.api + +import com.sampoom.android.core.network.ApiResponse +import com.sampoom.android.core.network.ApiSuccessResponse +import com.sampoom.android.feature.user.data.remote.dto.LoginRequestDto +import com.sampoom.android.feature.user.data.remote.dto.SignUpRequestDto +import com.sampoom.android.feature.user.data.remote.dto.SignUpResponseDto +import com.sampoom.android.feature.user.data.remote.dto.LoginResponseDto +import com.sampoom.android.feature.user.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.user.data.remote.dto.RefreshResponseDto +import com.sampoom.android.feature.user.data.remote.dto.UpdateRequestDto +import com.sampoom.android.feature.user.data.remote.dto.UpdateResponseDto +import com.sampoom.android.feature.user.data.remote.dto.VerifyResponseDto +import retrofit2.http.Body +import retrofit2.http.PATCH +import retrofit2.http.POST + +interface AuthApi { + @POST("auth/login") + suspend fun login(@Body body: LoginRequestDto): ApiResponse + + @POST("auth/logout") + suspend fun logout(): ApiSuccessResponse + + @POST("auth/refresh") + suspend fun refresh(@Body body: RefreshRequestDto): ApiResponse + + @POST("user/signup") + suspend fun signUp(@Body body: SignUpRequestDto): ApiResponse + + @POST("user/verify") + suspend fun verify(@Body body: LoginRequestDto): ApiResponse + + @PATCH("user/update") + suspend fun update(@Body body: UpdateRequestDto): ApiResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginRequestDto.kt similarity index 57% rename from app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt rename to app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginRequestDto.kt index 136b9ac..95427ab 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginRequestDto.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginRequestDto.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.data.remote.dto +package com.sampoom.android.feature.user.data.remote.dto data class LoginRequestDto( val email: String, diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginResponseDto.kt similarity index 66% rename from app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt rename to app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginResponseDto.kt index 3d44982..e48ba2c 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/LoginResponseDto.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/LoginResponseDto.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.data.remote.dto +package com.sampoom.android.feature.user.data.remote.dto data class LoginResponseDto( val userId: Long, @@ -6,5 +6,5 @@ data class LoginResponseDto( val role: String, val accessToken: String, val refreshToken: String, - val expiresIn: Int + val expiresIn: Long ) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshRequestDto.kt new file mode 100644 index 0000000..667e6c6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshRequestDto.kt @@ -0,0 +1,5 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class RefreshRequestDto( + val refreshToken: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshResponseDto.kt new file mode 100644 index 0000000..e9cfd8b --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/RefreshResponseDto.kt @@ -0,0 +1,7 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class RefreshResponseDto( + val accessToken: String, + val expiresIn: Long, + val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpRequestDto.kt new file mode 100644 index 0000000..2f611f8 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpRequestDto.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class SignUpRequestDto( + val email: String, + val password: String, + val workspace: String, + val branch: String, + val userName: String, + val position: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpResponseDto.kt similarity index 64% rename from app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt rename to app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpResponseDto.kt index d683a69..f300491 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/data/remote/dto/SignUpResponseDto.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/SignUpResponseDto.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.data.remote.dto +package com.sampoom.android.feature.user.data.remote.dto data class SignUpResponseDto( val userId: Long, diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateRequestDto.kt new file mode 100644 index 0000000..24b7a03 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateRequestDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class UpdateRequestDto( + val userName: String, + val position: String, + val workspace: String, + val branch: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateResponseDto.kt new file mode 100644 index 0000000..b5b2cd5 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/UpdateResponseDto.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class UpdateResponseDto( + val userId: Long, + val email: String, + val userName: String, + val position: String, + val workspace: String, + val branch: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyRequestDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyRequestDto.kt new file mode 100644 index 0000000..0ee23f1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyRequestDto.kt @@ -0,0 +1,6 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class VerifyRequestDto( + val email: String, + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyResponseDto.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyResponseDto.kt new file mode 100644 index 0000000..9d061f6 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/dto/VerifyResponseDto.kt @@ -0,0 +1,8 @@ +package com.sampoom.android.feature.user.data.remote.dto + +data class VerifyResponseDto( + val userId: Long, + val email: String, + val userName: String, + val role: String +) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..0b7c9f1 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,72 @@ +package com.sampoom.android.feature.user.data.repository + +import com.sampoom.android.core.datastore.AuthPreferences +import com.sampoom.android.feature.user.data.mapper.toModel +import com.sampoom.android.feature.user.data.remote.api.AuthApi +import com.sampoom.android.feature.user.data.remote.dto.LoginRequestDto +import com.sampoom.android.feature.user.data.remote.dto.RefreshRequestDto +import com.sampoom.android.feature.user.data.remote.dto.SignUpRequestDto +import com.sampoom.android.feature.user.domain.model.User +import com.sampoom.android.feature.user.domain.repository.AuthRepository +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val api: AuthApi, + private val preferences: AuthPreferences +) : AuthRepository { + override suspend fun signUp( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ): User { + api.signUp(SignUpRequestDto( + email = email, + password = password, + workspace = workspace, + branch = branch, + userName = userName, + position = position + )) + return signIn(email, password) + } + + override suspend fun signIn( + email: String, + password: String + ): User { + val dto = api.login(LoginRequestDto(email, password)) + val user = dto.data.toModel() + preferences.saveUser(user) + return user + } + + override suspend fun signOut() { + api.logout() + preferences.clear() + } + + override suspend fun refreshToken(): Result { + return runCatching { + val refreshToken = preferences.getRefreshToken() ?: throw Exception("No refresh token available") + val response = api.refresh(RefreshRequestDto(refreshToken)) + val existingUser = preferences.getStoredUser() ?: throw Exception("No user information available") + + val updatedUser = existingUser.copy( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + expiresIn = response.data.expiresIn + ) + preferences.saveUser(updatedUser) + updatedUser + } + } + + override suspend fun clearTokens() { + preferences.clear() + } + + override fun isSignedIn(): Boolean = preferences.hasToken() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt b/app/src/main/java/com/sampoom/android/feature/user/di/AuthModules.kt similarity index 71% rename from app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt rename to app/src/main/java/com/sampoom/android/feature/user/di/AuthModules.kt index 87b7f09..0be2f12 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/di/AuthModules.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/di/AuthModules.kt @@ -1,8 +1,8 @@ -package com.sampoom.android.feature.auth.di +package com.sampoom.android.feature.user.di -import com.sampoom.android.feature.auth.data.remote.api.AuthApi -import com.sampoom.android.feature.auth.data.repository.AuthRepositoryImpl -import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import com.sampoom.android.feature.user.data.remote.api.AuthApi +import com.sampoom.android.feature.user.data.repository.AuthRepositoryImpl +import com.sampoom.android.feature.user.domain.repository.AuthRepository import dagger.Binds import dagger.Module import dagger.Provides diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/AuthValidator.kt similarity index 98% rename from app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt rename to app/src/main/java/com/sampoom/android/feature/user/domain/AuthValidator.kt index b7c4516..5b3bc1e 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/AuthValidator.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/AuthValidator.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.domain +package com.sampoom.android.feature.user.domain import com.sampoom.android.R diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/model/User.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/model/User.kt new file mode 100644 index 0000000..84be2f9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/model/User.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.domain.model + +data class User( + val userId: Long, + val userName: String, + val role: String, + val accessToken: String, + val refreshToken: String, + val expiresIn: Long +) diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..b3035ce --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt @@ -0,0 +1,20 @@ +package com.sampoom.android.feature.user.domain.repository + +import com.sampoom.android.feature.user.domain.model.User + +interface AuthRepository { + suspend fun signUp( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ): User + + suspend fun signIn(email: String, password: String): User + suspend fun signOut() + suspend fun refreshToken(): Result + suspend fun clearTokens() + fun isSignedIn(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt new file mode 100644 index 0000000..53d9f90 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.domain.usecase + +import com.sampoom.android.feature.user.domain.repository.AuthRepository +import javax.inject.Inject + +class CheckLoginStateUseCase @Inject constructor( + private val repository: AuthRepository +) { + operator fun invoke(): Boolean = repository.isSignedIn() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/LoginUseCase.kt similarity index 57% rename from app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt rename to app/src/main/java/com/sampoom/android/feature/user/domain/usecase/LoginUseCase.kt index d31282c..1f01e3d 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/LoginUseCase.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/LoginUseCase.kt @@ -1,7 +1,7 @@ -package com.sampoom.android.feature.auth.domain.usecase +package com.sampoom.android.feature.user.domain.usecase -import com.sampoom.android.feature.auth.domain.model.User -import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import com.sampoom.android.feature.user.domain.model.User +import com.sampoom.android.feature.user.domain.repository.AuthRepository import javax.inject.Inject class LoginUseCase @Inject constructor( diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignOutUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignOutUseCase.kt new file mode 100644 index 0000000..fb896b4 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignOutUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.domain.usecase + +import com.sampoom.android.feature.user.domain.repository.AuthRepository +import javax.inject.Inject + +class SignOutUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke() = repository.signOut() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignUpUseCase.kt similarity index 50% rename from app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt rename to app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignUpUseCase.kt index 9fa4ddd..f64e7f6 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/domain/usecase/SignUpUseCase.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/SignUpUseCase.kt @@ -1,25 +1,25 @@ -package com.sampoom.android.feature.auth.domain.usecase +package com.sampoom.android.feature.user.domain.usecase -import com.sampoom.android.feature.auth.domain.model.User -import com.sampoom.android.feature.auth.domain.repository.AuthRepository +import com.sampoom.android.feature.user.domain.model.User +import com.sampoom.android.feature.user.domain.repository.AuthRepository import javax.inject.Inject class SignUpUseCase @Inject constructor( private val repository: AuthRepository ) { suspend operator fun invoke( - name: String, + email: String, + password: String, workspace: String, branch: String, - position: String, - email: String, - password: String + userName: String, + position: String ): User = repository.signUp( - name = name, + email = email, + password = password, workspace = workspace, branch = branch, - position = position, - email = email, - password = password + userName = userName, + position = position ) } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt new file mode 100644 index 0000000..b8b9ec9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt @@ -0,0 +1,37 @@ +package com.sampoom.android.feature.user.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sampoom.android.feature.user.domain.usecase.CheckLoginStateUseCase +import com.sampoom.android.feature.user.domain.usecase.SignOutUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val checkLoginStateUseCase: CheckLoginStateUseCase, + private val signOutUseCase: SignOutUseCase +) : ViewModel() { + private val _isLoggedIn = MutableStateFlow(checkLoginStateUseCase()) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _logoutEvent = MutableSharedFlow() + val logoutEvent: SharedFlow = _logoutEvent.asSharedFlow() + + fun updateLoginState() { + _isLoggedIn.value = checkLoginStateUseCase() + } + + fun signOut() = viewModelScope.launch { + signOutUseCase() + _isLoggedIn.value = false + _logoutEvent.emit(Unit) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginScreen.kt similarity index 98% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/LoginScreen.kt index a894c8d..c2e758b 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -39,7 +39,6 @@ import com.sampoom.android.core.ui.theme.Main500 import com.sampoom.android.core.ui.component.ShowErrorSnackBar import com.sampoom.android.core.ui.component.rememberCommonSnackBarHostState import com.sampoom.android.core.ui.component.TopSnackBarHost -import com.sampoom.android.core.ui.theme.backgroundColor @Composable fun LoginScreen( diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiEvent.kt similarity index 82% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiEvent.kt index ea2af9a..7b68d9d 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiEvent.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiEvent.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui sealed interface LoginUiEvent { data class EmailChanged(val email: String) : LoginUiEvent diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiState.kt similarity index 91% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiState.kt index 2546cab..fb34a74 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginUiState.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginUiState.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui data class LoginUiState( val email: String = "", diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginViewModel.kt similarity index 92% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/LoginViewModel.kt index e2f404a..0c0f1a9 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/LoginViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/LoginViewModel.kt @@ -1,12 +1,12 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui import android.app.Application import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sampoom.android.feature.auth.domain.AuthValidator -import com.sampoom.android.feature.auth.domain.ValidationResult -import com.sampoom.android.feature.auth.domain.usecase.LoginUseCase +import com.sampoom.android.feature.user.domain.AuthValidator +import com.sampoom.android.feature.user.domain.ValidationResult +import com.sampoom.android.feature.user.domain.usecase.LoginUseCase import com.sampoom.android.core.network.serverMessageOrNull import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpScreen.kt similarity index 97% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/SignUpScreen.kt index b252289..d118ccd 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpScreen.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpScreen.kt @@ -1,7 +1,6 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -19,7 +18,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -42,7 +40,6 @@ import com.sampoom.android.core.ui.component.CommonTextField import com.sampoom.android.core.ui.component.ShowErrorSnackBar import com.sampoom.android.core.ui.component.rememberCommonSnackBarHostState import com.sampoom.android.core.ui.component.TopSnackBarHost -import com.sampoom.android.core.ui.theme.backgroundColor @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiEvent.kt similarity index 91% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiEvent.kt index eb8e77b..2f355e0 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiEvent.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiEvent.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui sealed interface SignUpUiEvent { data class NameChanged(val name: String) : SignUpUiEvent diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiState.kt similarity index 96% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiState.kt index 524c1fe..754be8e 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpUiState.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpUiState.kt @@ -1,4 +1,4 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui data class SignUpUiState( val name: String = "", diff --git a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpViewModel.kt similarity index 93% rename from app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt rename to app/src/main/java/com/sampoom/android/feature/user/ui/SignUpViewModel.kt index 73d29b6..15a5704 100644 --- a/app/src/main/java/com/sampoom/android/feature/auth/ui/SignUpViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/SignUpViewModel.kt @@ -1,12 +1,12 @@ -package com.sampoom.android.feature.auth.ui +package com.sampoom.android.feature.user.ui import android.app.Application import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sampoom.android.feature.auth.domain.AuthValidator -import com.sampoom.android.feature.auth.domain.ValidationResult -import com.sampoom.android.feature.auth.domain.usecase.SignUpUseCase +import com.sampoom.android.feature.user.domain.AuthValidator +import com.sampoom.android.feature.user.domain.ValidationResult +import com.sampoom.android.feature.user.domain.usecase.SignUpUseCase import com.sampoom.android.core.network.serverMessageOrNull import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -137,12 +137,12 @@ class SignUpViewModel @Inject constructor( _state.update { it.copy(loading = true, error = null) } runCatching { singUp( - name = s.name, + email = s.email, + password = s.password, workspace = s.workspace, branch = s.branch, - position = s.position, - email = s.email, - password = s.password + userName = s.name, + position = s.position ) } .onSuccess { _state.update { it.copy(loading = false, success = true) } } From 95ff3c5d0980cd89100fd4360b5a4f450e84eb2f Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 24 Oct 2025 22:25:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=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 --- .../android/app/navigation/AppNavHost.kt | 8 -- .../android/core/datastore/AuthPreferences.kt | 82 +++++++++++++------ .../android/core/datastore/CryptoManager.kt | 62 ++++++++++++++ .../android/core/network/NetworkModule.kt | 29 +++---- .../core/network/TokenAuthenticator.kt | 41 ++++++++++ .../android/core/network/TokenInterceptor.kt | 10 ++- .../core/network/TokenRefreshInterceptor.kt | 40 --------- .../data/repository/AuthRepositoryImpl.kt | 3 +- .../user/domain/usecase/ClearTokensUseCase.kt | 10 +++ .../android/feature/user/ui/AuthViewModel.kt | 15 +++- 10 files changed, 203 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/com/sampoom/android/core/datastore/CryptoManager.kt create mode 100644 app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt delete mode 100644 app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt create mode 100644 app/src/main/java/com/sampoom/android/feature/user/domain/usecase/ClearTokensUseCase.kt diff --git a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt index db35350..d6415c1 100644 --- a/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt +++ b/app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt @@ -79,14 +79,6 @@ fun AppNavHost() { val authViewModel: AuthViewModel = hiltViewModel() val isLoggedIn by authViewModel.isLoggedIn.collectAsState() - LaunchedEffect(Unit) { - authViewModel.logoutEvent.collect { - navController.navigate(ROUTE_LOGIN) { - popUpTo(0) { inclusive = true } - } - } - } - NavHost( navController = navController, startDestination = if (isLoggedIn) ROUTE_HOME else ROUTE_LOGIN, diff --git a/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt index 4c30e3c..5610c0b 100644 --- a/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt +++ b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt @@ -18,7 +18,8 @@ private val Context.authDataStore by preferencesDataStore(name = "auth_prefs") @Singleton class AuthPreferences @Inject constructor( - @param:ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context, + private val cryptoManager: CryptoManager ){ private val dataStore = context.authDataStore @@ -26,7 +27,7 @@ class AuthPreferences @Inject constructor( val ACCESS_TOKEN: Preferences.Key = stringPreferencesKey("access_token") val REFRESH_TOKEN: Preferences.Key = stringPreferencesKey("refresh_token") val TOKEN_EXPIRES_AT: Preferences.Key = longPreferencesKey("token_expires_at") - val USER_ID: Preferences.Key = longPreferencesKey("user_id") + val USER_ID: Preferences.Key = stringPreferencesKey("user_id") val USER_NAME: Preferences.Key = stringPreferencesKey("user_name") val USER_ROLE: Preferences.Key = stringPreferencesKey("user_role") } @@ -34,49 +35,74 @@ class AuthPreferences @Inject constructor( suspend fun saveUser(user: User) { val expiresAt = System.currentTimeMillis() + (user.expiresIn * 1000) dataStore.edit { prefs -> - prefs[Keys.ACCESS_TOKEN] = user.accessToken - prefs[Keys.REFRESH_TOKEN] = user.refreshToken + prefs[Keys.ACCESS_TOKEN] = cryptoManager.encrypt(user.accessToken) + prefs[Keys.REFRESH_TOKEN] = cryptoManager.encrypt(user.refreshToken) prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt - prefs[Keys.USER_ID] = user.userId - prefs[Keys.USER_NAME] = user.userName - prefs[Keys.USER_ROLE] = user.role + prefs[Keys.USER_ID] = cryptoManager.encrypt(user.userId.toString()) + prefs[Keys.USER_NAME] = cryptoManager.encrypt(user.userName) + prefs[Keys.USER_ROLE] = cryptoManager.encrypt(user.role) } } - // Suspend save to avoid blocking thread suspend fun saveToken(accessToken: String, refreshToken: String, expiresIn: Long) { val expiresAt = System.currentTimeMillis() + (expiresIn * 1000) dataStore.edit { prefs -> - prefs[Keys.ACCESS_TOKEN] = accessToken - prefs[Keys.REFRESH_TOKEN] = refreshToken + prefs[Keys.ACCESS_TOKEN] = cryptoManager.encrypt(accessToken) + prefs[Keys.REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken) prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt } } - fun getStoredUser(): User? = runBlocking { - val userId = dataStore.data.first()[Keys.USER_ID] - val userName = dataStore.data.first()[Keys.USER_NAME] - val userRole = dataStore.data.first()[Keys.USER_ROLE] - val accessToken = dataStore.data.first()[Keys.ACCESS_TOKEN] - val refreshToken = dataStore.data.first()[Keys.REFRESH_TOKEN] + suspend fun getStoredUser(): User? { + val prefs = dataStore.data.first() + val userId = prefs[Keys.USER_ID] + val userName = prefs[Keys.USER_NAME] + val userRole = prefs[Keys.USER_ROLE] + val accessToken = prefs[Keys.ACCESS_TOKEN] + val refreshToken = prefs[Keys.REFRESH_TOKEN] + val expiresAt = prefs[Keys.TOKEN_EXPIRES_AT] if (userId != null && userName != null && userRole != null && accessToken != null && refreshToken != null) { - User(userId, userName, userRole, accessToken, refreshToken, 0) - } else null + try { + val remaining = expiresAt?.let { + kotlin.math.max(0L, (it - System.currentTimeMillis()) / 1000) + } ?: 0L + + return User( + cryptoManager.decrypt(userId).toLong(), + cryptoManager.decrypt(userName), + cryptoManager.decrypt(userRole), + cryptoManager.decrypt(accessToken), + cryptoManager.decrypt(refreshToken), + remaining + ) + } catch (e: Exception) { + return null + } + } else return null } - // Synchronous getters backed by runBlocking for minimal surface change - fun getAccessToken(): String? = runBlocking { - dataStore.data.first()[Keys.ACCESS_TOKEN] + suspend fun getAccessToken(): String? { + val encrypted = dataStore.data.first()[Keys.ACCESS_TOKEN] ?: return null + return try { + cryptoManager.decrypt(encrypted) + } catch (e: Exception) { + null + } } - fun getRefreshToken(): String? = runBlocking { - dataStore.data.first()[Keys.REFRESH_TOKEN] + suspend fun getRefreshToken(): String? { + val encrypted = dataStore.data.first()[Keys.REFRESH_TOKEN] ?: return null + return try { + cryptoManager.decrypt(encrypted) + } catch (e: Exception) { + null + } } - fun isTokenExpired(): Boolean { - val expiresAt = runBlocking { dataStore.data.first()[Keys.TOKEN_EXPIRES_AT] } + suspend fun isTokenExpired(): Boolean { + val expiresAt = dataStore.data.first()[Keys.TOKEN_EXPIRES_AT] return expiresAt == null || System.currentTimeMillis() > expiresAt } @@ -84,5 +110,9 @@ class AuthPreferences @Inject constructor( dataStore.edit { it.clear() } } - fun hasToken(): Boolean = !getAccessToken().isNullOrEmpty() && !getRefreshToken().isNullOrEmpty() + fun hasToken(): Boolean = runBlocking { + val accessToken = getAccessToken() + val refreshToken = getRefreshToken() + !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty() + } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/datastore/CryptoManager.kt b/app/src/main/java/com/sampoom/android/core/datastore/CryptoManager.kt new file mode 100644 index 0000000..36d0704 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/datastore/CryptoManager.kt @@ -0,0 +1,62 @@ +package com.sampoom.android.core.datastore + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CryptoManager @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val keyAlias = "AuthTokenKey" + private val keyStore = KeyStore.getInstance("AndroidKeyStore") + + init { + keyStore.load(null) + createKeyIfNeeded() + } + + private fun createKeyIfNeeded() { + if (!keyStore.containsAlias(keyAlias)) { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + } + + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keyStore.getKey(keyAlias, null)) + val iv = cipher.iv + val encrypted = cipher.doFinal(plaintext.toByteArray()) + return Base64.encodeToString(iv + encrypted, Base64.DEFAULT) + } + + fun decrypt(encryptedText: String): String { + val encrypted = Base64.decode(encryptedText, Base64.DEFAULT) + val iv = encrypted.sliceArray(0..11) + val ciphertext = encrypted.sliceArray(12 until encrypted.size) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, keyStore.getKey(keyAlias, null), spec) + return String(cipher.doFinal(ciphertext)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt index 3393b9b..cf79aeb 100644 --- a/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt +++ b/app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt @@ -34,33 +34,28 @@ object NetworkModule { return TokenRefreshService(authPreferences) } - @Provides - @Singleton - fun provideTokenRefreshInterceptor( - authPreferences: AuthPreferences, - tokenRefreshService: TokenRefreshService - ): TokenRefreshInterceptor { - return TokenRefreshInterceptor(authPreferences, tokenRefreshService) - } - @Provides @Singleton fun provideOkHttpClient( tokenInterceptor: TokenInterceptor, - tokenRefreshInterceptor: TokenRefreshInterceptor + tokenAuthenticator: TokenAuthenticator ): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) - .addInterceptor(HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) - HttpLoggingInterceptor.Level.BODY - else - HttpLoggingInterceptor.Level.NONE - }) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) + HttpLoggingInterceptor.Level.BODY + else + HttpLoggingInterceptor.Level.NONE + redactHeader("Authorization") // 토큰 비식별화 + redactHeader("Cookie") // 쿠키 비식별화 + } + ) .addInterceptor(tokenInterceptor) // 토큰 자동 삽입 - .addInterceptor(tokenRefreshInterceptor) // 토큰 갱신 + .authenticator(tokenAuthenticator) // 토큰 갱신 (Interceptor 대신) .build() } diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt new file mode 100644 index 0000000..d0335c9 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt @@ -0,0 +1,41 @@ +package com.sampoom.android.core.network + +import com.sampoom.android.core.datastore.AuthPreferences +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenAuthenticator @Inject constructor( + private val authPreferences: AuthPreferences, + private val tokenRefreshService: TokenRefreshService +) : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + // 이미 재시도된 요청인지 확인 + if (response.request.header("X-Retry-Count") != null) { + return null // 재시도 제한 + } + + return try { + val newUser = runBlocking { + tokenRefreshService.refreshToken().getOrThrow() + } + + // 새로운 토큰으로 요청 재시도 + response.request.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer ${newUser.accessToken}") + .addHeader("X-Retry-Count", "1") // 재시도 표시 + .build() + } catch (e: Exception) { + runBlocking { + authPreferences.clear() + } + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt index 945fc23..290573b 100644 --- a/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt +++ b/app/src/main/java/com/sampoom/android/core/network/TokenInterceptor.kt @@ -1,6 +1,7 @@ package com.sampoom.android.core.network import com.sampoom.android.core.datastore.AuthPreferences +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject @@ -11,11 +12,14 @@ class TokenInterceptor @Inject constructor( override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - if (originalRequest.header("Authorization") == null) { - val accessToken = authPreferences.getAccessToken() + val existingAuth = originalRequest.header("Authorization") + if (existingAuth.isNullOrBlank()) { + val accessToken = runBlocking { + authPreferences.getAccessToken() + } if (!accessToken.isNullOrEmpty()) { val newRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $accessToken") .build() return chain.proceed(newRequest) } diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt b/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt deleted file mode 100644 index ce9720f..0000000 --- a/app/src/main/java/com/sampoom/android/core/network/TokenRefreshInterceptor.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.sampoom.android.core.network - -import com.sampoom.android.core.datastore.AuthPreferences -import kotlinx.coroutines.runBlocking -import okhttp3.Interceptor -import okhttp3.Response -import javax.inject.Inject - -class TokenRefreshInterceptor @Inject constructor( - private val authPreferences: AuthPreferences, - private val tokenRefreshService: TokenRefreshService -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - val response = chain.proceed(request) - - // 401 error - if (response.code == 401) { - try { - val newUser = runBlocking { - tokenRefreshService.refreshToken().getOrThrow() - } - - val newRequest = request.newBuilder() - .removeHeader("Authorization") - .addHeader("Authorization", "Bearer ${newUser.accessToken}") - .build() - - return chain.proceed(newRequest) - } catch (e: Exception) { - runBlocking { - authPreferences.clear() - } - return response - } - } - - return response - } -} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt index 0b7c9f1..244e50a 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt @@ -44,7 +44,8 @@ class AuthRepositoryImpl @Inject constructor( } override suspend fun signOut() { - api.logout() + runCatching { api.logout() } + .onFailure { throw Exception("Failed logout") } preferences.clear() } diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/ClearTokensUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/ClearTokensUseCase.kt new file mode 100644 index 0000000..c46ace8 --- /dev/null +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/ClearTokensUseCase.kt @@ -0,0 +1,10 @@ +package com.sampoom.android.feature.user.domain.usecase + +import com.sampoom.android.feature.user.domain.repository.AuthRepository +import javax.inject.Inject + +class ClearTokensUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke() = repository.clearTokens() +} \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt index b8b9ec9..a05e214 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt @@ -3,6 +3,7 @@ package com.sampoom.android.feature.user.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sampoom.android.feature.user.domain.usecase.CheckLoginStateUseCase +import com.sampoom.android.feature.user.domain.usecase.ClearTokensUseCase import com.sampoom.android.feature.user.domain.usecase.SignOutUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -17,12 +18,16 @@ import javax.inject.Inject @HiltViewModel class AuthViewModel @Inject constructor( private val checkLoginStateUseCase: CheckLoginStateUseCase, - private val signOutUseCase: SignOutUseCase + private val signOutUseCase: SignOutUseCase, + private val clearTokensUseCase: ClearTokensUseCase ) : ViewModel() { private val _isLoggedIn = MutableStateFlow(checkLoginStateUseCase()) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() - private val _logoutEvent = MutableSharedFlow() + private val _logoutEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) val logoutEvent: SharedFlow = _logoutEvent.asSharedFlow() fun updateLoginState() { @@ -34,4 +39,10 @@ class AuthViewModel @Inject constructor( _isLoggedIn.value = false _logoutEvent.emit(Unit) } + + fun handleTokenExpired() = viewModelScope.launch { + clearTokensUseCase() + _isLoggedIn.value = false + _logoutEvent.emit(Unit) + } } \ No newline at end of file From d9036e221bec7cce758422a5a99d7828463b4d05 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 24 Oct 2025 22:26:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=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 --- .../sampoom/android/feature/user/data/remote/api/AuthApi.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt b/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt index ca324e1..21a588d 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/remote/api/AuthApi.kt @@ -10,6 +10,7 @@ import com.sampoom.android.feature.user.data.remote.dto.RefreshRequestDto import com.sampoom.android.feature.user.data.remote.dto.RefreshResponseDto import com.sampoom.android.feature.user.data.remote.dto.UpdateRequestDto import com.sampoom.android.feature.user.data.remote.dto.UpdateResponseDto +import com.sampoom.android.feature.user.data.remote.dto.VerifyRequestDto import com.sampoom.android.feature.user.data.remote.dto.VerifyResponseDto import retrofit2.http.Body import retrofit2.http.PATCH @@ -29,7 +30,7 @@ interface AuthApi { suspend fun signUp(@Body body: SignUpRequestDto): ApiResponse @POST("user/verify") - suspend fun verify(@Body body: LoginRequestDto): ApiResponse + suspend fun verify(@Body body: VerifyRequestDto): ApiResponse @PATCH("user/update") suspend fun update(@Body body: UpdateRequestDto): ApiResponse From 85b24b54e88df3037b0f39a6af265d4516589c4a Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 24 Oct 2025 22:30:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[FIX]=20=EB=B2=84=EC=A0=84=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 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6c89f7..f0bcb85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,7 +33,7 @@ android { minSdk = 24 targetSdk = 36 versionCode = 1 - versionName = "1.0" + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From 3f7780cfeb9b91b3c907dfc05cbf85fe5fc31d58 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 24 Oct 2025 22:54:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=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 --- .../android/core/datastore/AuthPreferences.kt | 4 +- .../core/network/TokenAuthenticator.kt | 63 +++++++++++++++++-- .../data/repository/AuthRepositoryImpl.kt | 2 +- .../user/domain/repository/AuthRepository.kt | 2 +- .../domain/usecase/CheckLoginStateUseCase.kt | 2 +- .../android/feature/user/ui/AuthViewModel.kt | 10 ++- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt index 5610c0b..4c30920 100644 --- a/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt +++ b/app/src/main/java/com/sampoom/android/core/datastore/AuthPreferences.kt @@ -110,9 +110,9 @@ class AuthPreferences @Inject constructor( dataStore.edit { it.clear() } } - fun hasToken(): Boolean = runBlocking { + suspend fun hasToken(): Boolean { val accessToken = getAccessToken() val refreshToken = getRefreshToken() - !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty() + return !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty() } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt index d0335c9..ea4d018 100644 --- a/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt +++ b/app/src/main/java/com/sampoom/android/core/network/TokenAuthenticator.kt @@ -2,6 +2,8 @@ package com.sampoom.android.core.network import com.sampoom.android.core.datastore.AuthPreferences import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response @@ -14,6 +16,8 @@ class TokenAuthenticator @Inject constructor( private val authPreferences: AuthPreferences, private val tokenRefreshService: TokenRefreshService ) : Authenticator { + private val refreshMutex = Mutex() + override fun authenticate(route: Route?, response: Response): Request? { // 이미 재시도된 요청인지 확인 if (response.request.header("X-Retry-Count") != null) { @@ -22,20 +26,67 @@ class TokenAuthenticator @Inject constructor( return try { val newUser = runBlocking { - tokenRefreshService.refreshToken().getOrThrow() + refreshMutex.withLock { + tokenRefreshService.refreshToken().getOrThrow() + } } - // 새로운 토큰으로 요청 재시도 response.request.newBuilder() .removeHeader("Authorization") .addHeader("Authorization", "Bearer ${newUser.accessToken}") - .addHeader("X-Retry-Count", "1") // 재시도 표시 + .addHeader("X-Retry-Count", "1") .build() - } catch (e: Exception) { - runBlocking { - authPreferences.clear() + } catch (e: retrofit2.HttpException) { + // HTTP 오류별 분기 처리 + when (e.code()) { + 400, 401 -> { + // 인증 실패: 토큰 삭제 + runBlocking { authPreferences.clear() } + null + } + 403 -> { + // 권한 없음: 토큰 삭제 + runBlocking { authPreferences.clear() } + null + } + 429 -> { + // Rate Limit: 토큰 보존, 재시도는 호출자 판단 + null + } + in 500..599 -> { + // 서버 오류: 토큰 보존 + null + } + else -> { + // 기타 HTTP 오류: 토큰 보존 + null + } } + } catch (e: java.io.IOException) { + // 네트워크 일시 오류: 토큰 보존, 재시도는 호출자 판단 + null + } catch (e: java.net.SocketTimeoutException) { + // 타임아웃: 토큰 보존 + null + } catch (e: java.net.UnknownHostException) { + // DNS 오류: 토큰 보존 + null + } catch (e: java.net.ConnectException) { + // 연결 오류: 토큰 보존 + null + } catch (t: Throwable) { + // 기타 예외: 토큰 보존 null } } + + private suspend fun isTokenExpired(token: String): Boolean { + // 간단한 토큰 만료 체크 (JWT 디코딩 없이) + return try { + val expiresAt = authPreferences.isTokenExpired() + expiresAt + } catch (e: Exception) { + true // 파싱 실패 시 만료로 간주 + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt index 244e50a..620b6e8 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/data/repository/AuthRepositoryImpl.kt @@ -69,5 +69,5 @@ class AuthRepositoryImpl @Inject constructor( preferences.clear() } - override fun isSignedIn(): Boolean = preferences.hasToken() + override suspend fun isSignedIn(): Boolean = preferences.hasToken() } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt index b3035ce..fd8b5f9 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/repository/AuthRepository.kt @@ -16,5 +16,5 @@ interface AuthRepository { suspend fun signOut() suspend fun refreshToken(): Result suspend fun clearTokens() - fun isSignedIn(): Boolean + suspend fun isSignedIn(): Boolean } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt index 53d9f90..f63ad59 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/domain/usecase/CheckLoginStateUseCase.kt @@ -6,5 +6,5 @@ import javax.inject.Inject class CheckLoginStateUseCase @Inject constructor( private val repository: AuthRepository ) { - operator fun invoke(): Boolean = repository.isSignedIn() + suspend operator fun invoke(): Boolean = repository.isSignedIn() } \ No newline at end of file diff --git a/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt index a05e214..d81a398 100644 --- a/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt +++ b/app/src/main/java/com/sampoom/android/feature/user/ui/AuthViewModel.kt @@ -21,7 +21,7 @@ class AuthViewModel @Inject constructor( private val signOutUseCase: SignOutUseCase, private val clearTokensUseCase: ClearTokensUseCase ) : ViewModel() { - private val _isLoggedIn = MutableStateFlow(checkLoginStateUseCase()) + private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() private val _logoutEvent = MutableSharedFlow( @@ -30,7 +30,13 @@ class AuthViewModel @Inject constructor( ) val logoutEvent: SharedFlow = _logoutEvent.asSharedFlow() - fun updateLoginState() { + init { + viewModelScope.launch { + _isLoggedIn.value = checkLoginStateUseCase() + } + } + + fun updateLoginState() = viewModelScope.launch { _isLoggedIn.value = checkLoginStateUseCase() }