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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ android {
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionName = "1.0.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
38 changes: 30 additions & 8 deletions app/src/main/java/com/sampoom/android/app/navigation/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -70,9 +76,8 @@ sealed class BottomNavItem(
@Composable
fun AppNavHost() {
val navController = rememberNavController()

// TODO: 임시 로그인 상태 확인 -> AuthRepository에서 확인하도록 변경
val isLoggedIn = true
val authViewModel: AuthViewModel = hiltViewModel()
Comment thread
Sangyoon98 marked this conversation as resolved.
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()

NavHost(
navController = navController,
Expand All @@ -82,6 +87,7 @@ fun AppNavHost() {
composable(ROUTE_LOGIN) {
LoginScreen(
onSuccess = {
authViewModel.updateLoginState()
navController.navigate(ROUTE_HOME) {
popUpTo(ROUTE_LOGIN) { inclusive = true } // 로그인 화면 스택 제거
}
Expand Down Expand Up @@ -163,7 +169,11 @@ fun MainScreen(
composable(ROUTE_DASHBOARD) {
DashboardScreen(
paddingValues = innerPadding
)
) {
parentNavController.navigate(ROUTE_LOGIN) {
popUpTo(0) { inclusive = true }
}
}
}
composable(ROUTE_OUTBOUND) {
OutboundListScreen(
Expand Down Expand Up @@ -249,8 +259,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("로그아웃")
}
}

}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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 cryptoManager: CryptoManager
){
private val dataStore = context.authDataStore

private object Keys {
val ACCESS_TOKEN: Preferences.Key<String> = stringPreferencesKey("access_token")
val REFRESH_TOKEN: Preferences.Key<String> = stringPreferencesKey("refresh_token")
val TOKEN_EXPIRES_AT: Preferences.Key<Long> = longPreferencesKey("token_expires_at")
val USER_ID: Preferences.Key<String> = stringPreferencesKey("user_id")
val USER_NAME: Preferences.Key<String> = stringPreferencesKey("user_name")
val USER_ROLE: Preferences.Key<String> = stringPreferencesKey("user_role")
}

suspend fun saveUser(user: User) {
val expiresAt = System.currentTimeMillis() + (user.expiresIn * 1000)
dataStore.edit { prefs ->
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] = cryptoManager.encrypt(user.userId.toString())
prefs[Keys.USER_NAME] = cryptoManager.encrypt(user.userName)
prefs[Keys.USER_ROLE] = cryptoManager.encrypt(user.role)
}
}
Comment thread
Sangyoon98 marked this conversation as resolved.

suspend fun saveToken(accessToken: String, refreshToken: String, expiresIn: Long) {
val expiresAt = System.currentTimeMillis() + (expiresIn * 1000)
dataStore.edit { prefs ->
prefs[Keys.ACCESS_TOKEN] = cryptoManager.encrypt(accessToken)
prefs[Keys.REFRESH_TOKEN] = cryptoManager.encrypt(refreshToken)
prefs[Keys.TOKEN_EXPIRES_AT] = expiresAt
}
}

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) {
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
}

suspend fun getAccessToken(): String? {
val encrypted = dataStore.data.first()[Keys.ACCESS_TOKEN] ?: return null
return try {
cryptoManager.decrypt(encrypted)
} catch (e: Exception) {
null
}
}

suspend fun getRefreshToken(): String? {
val encrypted = dataStore.data.first()[Keys.REFRESH_TOKEN] ?: return null
return try {
cryptoManager.decrypt(encrypted)
} catch (e: Exception) {
null
}
}

suspend fun isTokenExpired(): Boolean {
val expiresAt = dataStore.data.first()[Keys.TOKEN_EXPIRES_AT]
return expiresAt == null || System.currentTimeMillis() > expiresAt
}

suspend fun clear() {
dataStore.edit { it.clear() }
}

suspend fun hasToken(): Boolean {
val accessToken = getAccessToken()
val refreshToken = getRefreshToken()
return !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty()
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
56 changes: 43 additions & 13 deletions app/src/main/java/com/sampoom/android/core/network/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,56 @@ 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 provideOkHttpClient(
tokenInterceptor: TokenInterceptor,
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
redactHeader("Authorization") // 토큰 비식별화
redactHeader("Cookie") // 쿠키 비식별화
}
)
.addInterceptor(tokenInterceptor) // 토큰 자동 삽입
.authenticator(tokenAuthenticator) // 토큰 갱신 (Interceptor 대신)
.build()
}

@Provides @Singleton
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
val gson = GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.IDENTITY)
Expand Down
Loading