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
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ android {
val amplitudeApiKey: String = properties.getProperty("amplitude.api.key")
val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "")
val googleAdmobUnitId: String = properties.getProperty("GOOGLE_ADMOB_UNIT_ID", "")
val googleAuthWebClientId: String = properties.getProperty("GOOGLE_AUTH_WEB_CLIENT_ID", "")
val allowedDomains: String = properties.getProperty("allowed.webview.domains", "notion.so,google.com")

buildConfigField("String", "GOOGLE_ADMOB_APP_ID", "\"$googleAdmobAppId\"")
buildConfigField("String", "GOOGLE_ADMOB_UNIT_ID", "\"$googleAdmobUnitId\"")
buildConfigField("String", "KAKAO_API_KEY", "\"$kakaoApiKey\"")
buildConfigField("String", "AMPLITUDE_API_KEY", "\"$amplitudeApiKey\"")
buildConfigField("String", "GOOGLE_AUTH_WEB_CLIENT_ID", "\"$googleAuthWebClientId\"")
buildConfigField("String", "ALLOWED_WEBVIEW_DOMAINS", "\"$allowedDomains\"")
manifestPlaceholders["kakaoRedirectUri"] = "kakao$kakaoApiKey"
manifestPlaceholders["GOOGLE_ADMOB_APP_ID"] = googleAdmobAppId
Expand Down Expand Up @@ -153,4 +155,8 @@ dependencies {
implementation(libs.coil)
implementation(libs.kakao.user)
implementation(libs.kotlinx.datetime)

implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.google.auth)
implementation(libs.androidx.datastore.preferences)
}
22 changes: 22 additions & 0 deletions app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.sopt.clody.data.datastore

import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

@Provides
@Singleton
fun provideOAuthDataStore(
@ApplicationContext context: Context,
): OAuthDataStore {
return OAuthDataStore(context)
}
}
38 changes: 38 additions & 0 deletions app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt
Copy link
Contributor

Choose a reason for hiding this comment

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

[p4]
preferences 기반 토큰 저장 방식이 아닌 preferences datastore 로 사용한 것 같은데, 추후에 카카오 로그인도 이것과 합치나요? 동일하게 preferences가 아닌 해당 방식을 채택한 이유가 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

음. SharedPreferencesSync I/O 기반이라 UI thread를 막을 수 있어서 구조적으로 위험할 수 잇죠.
반면, DataStore는 Coroutine 기반으로 설계되어 IO 안정성이 좋습니다.
그래서 공식문서에서도 장기적으로 Datastore을 지향하고 있슴다.
카카오같은 경우는 로그인/회원가입에 loginsdk를 통해서 auth token을 발급하는 구조라 구글 로그인과는 조금 다를 수 있습니다.
구글 로그인은 요청하면 또 이메일 선택 UI가 뜨기 때문에 로컬에 캐싱했다가 회원가입 때 쏘는 구조로 했슴다이.

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.sopt.clody.data.datastore

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import javax.inject.Inject

class OAuthDataStore @Inject constructor(@ApplicationContext context: Context) {
private val Context.dataStore by preferencesDataStore(name = "oauth_pref")
private val dataStore = context.dataStore

suspend fun saveIdToken(token: String) {
dataStore.edit { it[OAuthDataStoreKeys.GOOGLE_ID_TOKEN] = token }
}

suspend fun getIdToken(): String? {
return dataStore.data.first()[OAuthDataStoreKeys.GOOGLE_ID_TOKEN]
}
Comment on lines +14 to +20
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Security concern: Consider encrypting sensitive OAuth tokens.

The ID token is stored in plain text. OAuth tokens are sensitive data that should be encrypted at rest. Consider using EncryptedSharedPreferences or Android Keystore for secure token storage.

For example, you could use EncryptedSharedPreferences:

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val encryptedPrefs = EncryptedSharedPreferences.create(
    context,
    "oauth_encrypted_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
🤖 Prompt for AI Agents
In app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt around
lines 14 to 20, the OAuth ID token is currently stored in plain text using
DataStore, which poses a security risk. To fix this, replace the current storage
mechanism with encrypted storage by integrating EncryptedSharedPreferences or
Android Keystore. Initialize a MasterKey and create an
EncryptedSharedPreferences instance, then update the saveIdToken and getIdToken
functions to store and retrieve the token securely using this encrypted
preferences instance.


suspend fun savePlatform(provider: OAuthProvider) {
dataStore.edit { it[OAuthDataStoreKeys.OAUTH_PLATFORM] = provider.name }
}

suspend fun getPlatform(): OAuthProvider? {
return dataStore.data.first()[OAuthDataStoreKeys.OAUTH_PLATFORM]?.let {
runCatching { OAuthProvider.valueOf(it) }.getOrNull()
}
}

suspend fun clear() {
dataStore.edit {
it.remove(OAuthDataStoreKeys.GOOGLE_ID_TOKEN)
it.remove(OAuthDataStoreKeys.OAUTH_PLATFORM)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sopt.clody.data.datastore

import androidx.datastore.preferences.core.stringPreferencesKey

object OAuthDataStoreKeys {
val GOOGLE_ID_TOKEN = stringPreferencesKey("google_id_token")
val OAUTH_PLATFORM = stringPreferencesKey("oauth_platform")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.sopt.clody.data.datastore

enum class OAuthProvider(val apiValue: String) {
GOOGLE("google"),
KAKAO("kakao"),
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sopt.clody.data.remote.api

import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
Expand All @@ -21,4 +22,9 @@ interface AuthService {
@Header("Authorization") authorization: String,
@Body signUpRequestDto: SignUpRequestDto,
): ApiResponse<SignUpResponseDto>

@POST("api/v1/auth/oauth2/google")
suspend fun signUpWithGoogle(
@Body body: GoogleSignUpRequestDto,
): ApiResponse<SignUpResponseDto>
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sopt.clody.data.remote.datasource

import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
Expand All @@ -9,4 +10,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
interface AuthDataSource {
suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): ApiResponse<LoginResponseDto>
suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse<SignUpResponseDto>
suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse<SignUpResponseDto>
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.sopt.clody.data.remote.datasourceimpl
import com.sopt.clody.data.remote.api.AuthService
import com.sopt.clody.data.remote.datasource.AuthDataSource
import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
Expand All @@ -17,4 +18,7 @@ class AuthDataSourceImpl @Inject constructor(

override suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse<SignUpResponseDto> =
authService.signUp(authorization, requestSignUpDto)

override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse<SignUpResponseDto> =
authService.signUpWithGoogle(googleSignUpRequestDto)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.sopt.clody.data.remote.dto.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class GoogleSignUpRequestDto(
@SerialName("idToken") val idToken: String,
@SerialName("fcmToken") val fcmToken: String,
)
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.sopt.clody.data.remote.dto.response

import com.sopt.clody.data.datastore.OAuthProvider
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class UserInfoResponseDto(
@SerialName("email") val email: String,
@SerialName("name") val name: String,
@SerialName("platform") val platform: String,
@SerialName("platform") val platform: OAuthProvider?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class AuthInterceptor @Inject constructor(
private fun shouldAddAuthorization(url: String): Boolean {
return !url.contains("api/v1/auth/signin") &&
!url.contains("api/v1/auth/signup") &&
!url.contains("api/v1/auth/oauth2/google") &&
!url.contains("api/v1/auth/reissue")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sopt.clody.data.repositoryimpl

import com.sopt.clody.data.remote.datasource.AuthDataSource
import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
Expand All @@ -21,4 +22,9 @@ class AuthRepositoryImpl @Inject constructor(
runCatching {
authDataSource.signUp(authorization, requestSignUpDto).handleApiResponse().getOrThrow()
}

override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result<SignUpResponseDto> =
runCatching {
authDataSource.signUpWithGoogle(googleSignUpRequestDto).handleApiResponse().getOrThrow()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.sopt.clody.domain.repository

import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
Expand All @@ -8,4 +9,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
interface AuthRepository {
suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): Result<LoginResponseDto>
suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): Result<SignUpResponseDto>
suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result<SignUpResponseDto>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.sopt.clody.presentation.ui.auth.signup

import android.content.Context
import com.airbnb.mvrx.MavericksState
import com.sopt.clody.data.datastore.OAuthProvider

class SignUpContract {
data class SignUpState(
Expand All @@ -15,8 +16,9 @@ class SignUpContract {
val errorMessage: String? = null,
val serviceChecked: Boolean = false,
val serviceUrl: String = "",
val privacyChecked: Boolean = false,
val privacyUrl: String = "",
val privacyChecked: Boolean = false,
val platform: OAuthProvider = OAuthProvider.KAKAO,
) : MavericksState {
val allChecked: Boolean
get() = serviceChecked && privacyChecked
Expand All @@ -33,7 +35,6 @@ class SignUpContract {
data object ProceedTerms : SignUpIntent()
data class CompleteSignUp(val context: Context) : SignUpIntent()
data object ClearError : SignUpIntent()

data class ToggleAllChecked(val checked: Boolean) : SignUpIntent()
data class ToggleServiceChecked(val checked: Boolean) : SignUpIntent()
data class TogglePrivacyChecked(val checked: Boolean) : SignUpIntent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,10 @@ fun SignUpRoute(
viewModel.sideEffects.collect { effect ->
when (effect) {
is SignUpContract.SignUpSideEffect.NavigateToTimeReminder -> navigateToHome()
is SignUpContract.SignUpSideEffect.ShowMessage -> {
// TODO: Snackbar나 Dialog로 에러 메시지 처리
}

is SignUpContract.SignUpSideEffect.NavigateToWebView -> {
navigateToWebView(effect.url) // ✅ WebView 이동 처리
}
is SignUpContract.SignUpSideEffect.ShowMessage -> {}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Address the empty error message handler.

The ShowMessage side effect handler is currently empty, meaning error messages won't be displayed to users. This could result in silent failures and poor user experience.

Consider implementing proper error message display:

-is SignUpContract.SignUpSideEffect.ShowMessage -> {}
+is SignUpContract.SignUpSideEffect.ShowMessage -> {
+    // TODO: Show toast, snackbar, or other user feedback
+    // Example: Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
is SignUpContract.SignUpSideEffect.ShowMessage -> {}
is SignUpContract.SignUpSideEffect.ShowMessage -> {
// TODO: Show toast, snackbar, or other user feedback
// Example: Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
🤖 Prompt for AI Agents
In app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt
at line 35, the handler for the ShowMessage side effect is empty, so error
messages are not shown to users. Update this handler to display the error
message appropriately, for example by showing a Toast, Snackbar, or dialog with
the message content to ensure users are informed of errors.

}
}
}
Expand Down Expand Up @@ -83,7 +80,9 @@ fun SignUpScreen(
NickNamePage(
nickname = state.nickname,
onNicknameChange = { onIntent(SignUpContract.SignUpIntent.SetNickname(it)) },
onCompleteClick = { onIntent(SignUpContract.SignUpIntent.CompleteSignUp(context)) },
onCompleteClick = {
onIntent(SignUpContract.SignUpIntent.CompleteSignUp(context))
},
onBackClick = { onIntent(SignUpContract.SignUpIntent.BackToTerms) },
isLoading = state.isLoading,
isValidNickname = state.isValidNickname,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
import com.airbnb.mvrx.withState
import com.sopt.clody.core.fcm.FcmTokenProvider
import com.sopt.clody.core.login.LoginSdk
import com.sopt.clody.data.datastore.OAuthDataStore
import com.sopt.clody.data.datastore.OAuthProvider
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
import com.sopt.clody.data.remote.util.NetworkUtil
import com.sopt.clody.domain.repository.AuthRepository
import com.sopt.clody.domain.repository.TokenRepository
Expand All @@ -31,6 +34,7 @@ class SignUpViewModel @AssistedInject constructor(
private val authRepository: AuthRepository,
private val tokenRepository: TokenRepository,
private val fcmTokenProvider: FcmTokenProvider,
private val oAuthDataStore: OAuthDataStore,
private val networkUtil: NetworkUtil,
private val languageProvider: LanguageProvider,
) : MavericksViewModel<SignUpContract.SignUpState>(initialState) {
Expand Down Expand Up @@ -139,39 +143,67 @@ class SignUpViewModel @AssistedInject constructor(
}
}

private fun signUp(context: Context) {
viewModelScope.launch {
val state = withState(this@SignUpViewModel) { it }
if (!networkUtil.isNetworkAvailable()) {
setState { copy(errorMessage = "네트워크 연결을 확인해주세요.") }
return@launch
private suspend fun signUp(context: Context) {
val state = withState(this@SignUpViewModel) { it }

if (!networkUtil.isNetworkAvailable()) {
setState { copy(errorMessage = "네트워크 연결을 확인해주세요.") }
return
}

setState { copy(isLoading = true) }

val platform = oAuthDataStore.getPlatform()
val fcmToken = fcmTokenProvider.getToken().orEmpty()

if (platform == OAuthProvider.GOOGLE) {
val idToken = oAuthDataStore.getIdToken()
if (idToken.isNullOrBlank()) {
setState { copy(errorMessage = "Google ID Token이 없습니다.", isLoading = false) }
return
}
setState { copy(isLoading = true) }
val request = SignUpRequestDto(
platform = OAuthProvider.GOOGLE.apiValue,
name = state.nickname,
fcmToken = fcmToken,
)

val result = authRepository.signUp("Bearer $idToken", request)
handleSignUpResult(result, isGoogle = true)
} else {
loginSdk.login(context).fold(
onSuccess = { accessToken ->
launch {
val fcm = fcmTokenProvider.getToken().orEmpty()
val req = SignUpRequestDto("kakao", state.nickname, fcm)
authRepository.signUp("Bearer ${accessToken.value}", req).fold(
onSuccess = {
tokenRepository.setTokens(it.accessToken, it.refreshToken)
_sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToTimeReminder)
},
onFailure = {
setState { copy(errorMessage = it.message ?: "알 수 없는 오류") }
},
)

setState { copy(isLoading = false) }
}
onSuccess = { token ->
val request = SignUpRequestDto(
platform = OAuthProvider.KAKAO.apiValue,
name = state.nickname,
fcmToken = fcmToken,
)
val result = authRepository.signUp("Bearer ${token.value}", request)
handleSignUpResult(result, isGoogle = false)
},
onFailure = {
setState { copy(errorMessage = it.message ?: "로그인 실패", isLoading = false) }
setState { copy(errorMessage = "로그인에 실패했어요~", isLoading = false) }
},
)
}
}

private suspend fun handleSignUpResult(
result: Result<SignUpResponseDto>,
isGoogle: Boolean,
) {
result.fold(
onSuccess = {
tokenRepository.setTokens(it.accessToken, it.refreshToken)
if (isGoogle) oAuthDataStore.clear()
_sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToTimeReminder)
},
onFailure = {
setState { copy(errorMessage = "회원가입에 실패했어요~") }
},
)
setState { copy(isLoading = false) }
}
private fun validateNickname(nickname: String): Boolean {
val state = withState(this@SignUpViewModel) { it }
setState { copy(nicknameMaxLength = languageProvider.getNicknameMaxLength()) }
Expand Down
Loading