Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[AN/USER] feat: 카카오 로그인 및 토큰 관리(#861) #901

Merged
merged 26 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
977a991
feat(SignInActivity): 로그인 화면 구현
SeongHoonC Apr 15, 2024
ddfcb9e
feat(SplashEvent): 로그인 했거나 로그인을 거부했다면 앱 시작 시 로그인 화면이 다시 뜨지 않는다.
SeongHoonC Apr 16, 2024
b681ffc
feat(SignInViewModel): 로그인 이벤트 관리
SeongHoonC Apr 16, 2024
e86c611
feat(FakeAuthRepository): 가짜 Auth 저장소 정의
SeongHoonC Apr 16, 2024
78f0d49
feat: auth response 객체 정의
SeongHoonC Apr 24, 2024
b922c08
feat: 토큰 저장을 위한 엔티티 정의
SeongHoonC Apr 24, 2024
cb865fc
feat: 토큰 저장소 정의
SeongHoonC Apr 24, 2024
cb971df
feat: 토큰 도메인 객체 정의
SeongHoonC Apr 24, 2024
e9663a7
feat: UserRetrofitService 임시 정의
SeongHoonC Apr 24, 2024
bde05c5
feat: AuthRepository rename to UserRepository & 재정의
SeongHoonC Apr 24, 2024
653c748
Merge branch 'dev' into feat/#861
SeongHoonC Apr 27, 2024
fdc27b3
feat(AuthRetrofitService): 유저 인증 API 요청 서비스 정의
SeongHoonC Apr 28, 2024
5634a61
feat(DefaultUserRepository): 유저 저장소는 회원가입, 리프레시, 로그아웃, 회원탈퇴 기능을 수행한다.
SeongHoonC Apr 28, 2024
d92622c
feat(UnauthorizedException): 인증 정보 오류에 대한 Result 확장함수 정의
SeongHoonC Apr 28, 2024
717d9ae
feat(DataSourceModule): 토큰 관리 데이터 저장소 정의
SeongHoonC Apr 28, 2024
17d5a1f
feat: Kakao 인증 방식을 code 에서 IdToken 으로 변경한다
SeongHoonC Apr 29, 2024
0b53474
feat: AuthRetrofit 은 AuthInterceptor 를 사용해 토큰과 함께 요청한다.
SeongHoonC May 1, 2024
1af4539
feat: Token util 제거
SeongHoonC May 1, 2024
9ca8dfe
feat: 카카오 로그아웃 회원탈퇴 로직 이동
SeongHoonC May 1, 2024
a26213b
chore: DataSource 패키지 정리
SeongHoonC May 1, 2024
15ed793
feat: KakaoAuth 를 common 으로 이동
SeongHoonC May 1, 2024
a1f5870
feat: 로그인 화면 및 User 저장소에 kakao Auth 주입
SeongHoonC May 1, 2024
3fc1dd0
feat: shardPref 에 object 넣기 Util 화
SeongHoonC May 1, 2024
3e6b178
feat: UserInfo 를 저장할 수 있다.
SeongHoonC May 1, 2024
e9e3c3b
feat: 유저 정보를 가져올 수 있다.
SeongHoonC May 1, 2024
2a6afbd
fix(AuthInterceptor): API 요청 token 인터셉트 로직 수정
SeongHoonC May 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.festago.festago.data.datasource

import com.festago.festago.data.model.TokenEntity

interface TokenDataSource {
var accessToken: TokenEntity?
var refreshToken: TokenEntity?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.festago.festago.data.datasource

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.festago.festago.data.model.TokenEntity
import com.google.gson.GsonBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class TokenLocalDataSource @Inject constructor(
@ApplicationContext context: Context,
) : TokenDataSource {

private val sharedPreference: SharedPreferences by lazy {
val masterKeyAlias = MasterKey
.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

EncryptedSharedPreferences(context, ENCRYPTED_PREF_FILE, masterKeyAlias)
}

override var accessToken: TokenEntity?
get() = sharedPreference.getToken(ACCESS_TOKEN_KEY, null)
set(value) {
sharedPreference.putToken(ACCESS_TOKEN_KEY, value)
}

override var refreshToken: TokenEntity?
get() = sharedPreference.getToken(REFRESH_TOKEN_KEY, null)
set(value) {
sharedPreference.putToken(REFRESH_TOKEN_KEY, value)
}

private fun SharedPreferences.putToken(key: String, token: TokenEntity?) {
val jsonString = GsonBuilder().create().toJson(token)
edit().putString(key, jsonString).apply()
}

private fun SharedPreferences.getToken(key: String, default: TokenEntity?): TokenEntity? {
val token = getString(key, null) ?: return default
return GsonBuilder().create().fromJson(token, TokenEntity::class.java)
}

companion object {
private const val ENCRYPTED_PREF_FILE = "encrypted_pref_file"
private const val ACCESS_TOKEN_KEY = "access_token_key"
private const val REFRESH_TOKEN_KEY = "refresh_token_key"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.festago.festago.data.di.singletonscope

import com.festago.festago.data.datasource.TokenDataSource
import com.festago.festago.data.datasource.TokenLocalDataSource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
interface DataSourceModule {

@Binds
fun bindsTokenDataSource(localDataSource: TokenLocalDataSource): TokenDataSource
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import com.festago.festago.data.repository.DefaultFestivalRepository
import com.festago.festago.data.repository.DefaultRecentSearchRepository
import com.festago.festago.data.repository.DefaultSchoolRepository
import com.festago.festago.data.repository.DefaultSearchRepository
import com.festago.festago.data.repository.DefaultUserRepository
import com.festago.festago.data.repository.FakeBookmarkRepository
import com.festago.festago.domain.repository.ArtistRepository
import com.festago.festago.domain.repository.BookmarkRepository
import com.festago.festago.domain.repository.FestivalRepository
import com.festago.festago.domain.repository.RecentSearchRepository
import com.festago.festago.domain.repository.SchoolRepository
import com.festago.festago.domain.repository.SearchRepository
import com.festago.festago.domain.repository.UserRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
Expand Down Expand Up @@ -44,4 +46,8 @@ interface RepositoryModule {
@Binds
@Singleton
fun bindsBookmarkRepository(bookmarkRepository: FakeBookmarkRepository): BookmarkRepository

@Binds
@Singleton
fun bindsUserRepository(userRepository: DefaultUserRepository): UserRepository
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.festago.festago.data.di.singletonscope

import com.festago.festago.data.service.ArtistRetrofitService
import com.festago.festago.data.service.AuthRetrofitService
import com.festago.festago.data.service.BookmarkRetrofitService
import com.festago.festago.data.service.FestivalRetrofitService
import com.festago.festago.data.service.SchoolRetrofitService
Expand Down Expand Up @@ -54,4 +55,12 @@ object ServiceModule {
): BookmarkRetrofitService {
return retrofit.create(BookmarkRetrofitService::class.java)
}

@Provides
@Singleton
fun providesAuthRetrofitService(
@NormalRetrofitQualifier retrofit: Retrofit,
): AuthRetrofitService {
return retrofit.create(AuthRetrofitService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.festago.festago.data.dto.user

import kotlinx.serialization.Serializable

@Serializable
class RefreshRequest(
val refreshToken: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.festago.festago.data.dto.user

import kotlinx.serialization.Serializable

@Serializable
data class RefreshResponse(
val accessToken: TokenResponse,
val refreshToken: TokenResponse,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.festago.festago.data.dto.user

import kotlinx.serialization.Serializable

@Serializable
class SignInRequest(
val socialType: String,
val code: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.festago.festago.data.dto.user

import kotlinx.serialization.Serializable

@Serializable
data class SignInResponse(
val nickName: String,
val profileImageUrl: String,
val accessToken: TokenResponse,
val refreshToken: TokenResponse,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.festago.festago.data.dto.user

import com.festago.festago.data.model.TokenEntity
import com.festago.festago.domain.model.token.Token
import kotlinx.serialization.Serializable
import java.time.LocalDateTime

@Serializable
data class TokenResponse(
val token: String,
val expiredAt: String,
) {
fun toDomain() = Token(
token = token,
expiredAt = LocalDateTime.parse(expiredAt),
)

fun toEntity() = TokenEntity(
token = token,
expiredAt = expiredAt,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.festago.festago.data.model

import com.festago.festago.domain.model.token.Token
import java.time.LocalDateTime

data class TokenEntity(
val token: String,
val expiredAt: String,
) {
fun toDomain() = Token(
token = token,
expiredAt = LocalDateTime.parse(expiredAt),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.festago.festago.data.repository

import android.content.Context
import android.content.SharedPreferences
import com.festago.festago.data.datasource.TokenDataSource
import com.festago.festago.data.dto.user.RefreshRequest
import com.festago.festago.data.dto.user.SignInRequest
import com.festago.festago.data.service.AuthRetrofitService
import com.festago.festago.data.util.format
import com.festago.festago.data.util.onSuccessOrCatch
import com.festago.festago.data.util.runCatchingResponse
import com.festago.festago.domain.model.token.Token
import com.festago.festago.domain.repository.UserRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class DefaultUserRepository @Inject constructor(
private val authRetrofitService: AuthRetrofitService,
private val tokenDataSource: TokenDataSource,
@ApplicationContext context: Context,
) : UserRepository {

private val authPref: SharedPreferences by lazy {
context.getSharedPreferences(AUTH_PREF, Context.MODE_PRIVATE)
}

override suspend fun isSigned() = getRefreshToken().isSuccess

override suspend fun isSignRejected() = authPref.getBoolean(IS_SIGN_REJECTED, false)

override suspend fun getAccessToken(): Result<Token> {
val token = tokenDataSource.accessToken?.toDomain()
?: return Result.failure(NullPointerException("Access token is null"))

if (!token.isExpired()) {
return Result.success(token)
}

return try {
val refreshToken = getRefreshToken().getOrThrow()
refresh(refreshToken).getOrThrow()
Result.success(tokenDataSource.accessToken?.toDomain()!!)
} catch (e: Exception) {
Result.failure(e)
}
}

override suspend fun getRefreshToken(): Result<Token> {
val refreshToken = tokenDataSource.refreshToken?.toDomain()
?: return Result.failure(NullPointerException("Refresh token is null"))

if (refreshToken.isExpired()) {
return Result.failure(Exception("Refresh token is expired"))
}

return Result.success(refreshToken)
}

override suspend fun signIn(code: String): Result<Unit> {
return runCatchingResponse {
authRetrofitService.signIn(SignInRequest(SOCIAL_TYPE, code))
}.onSuccessOrCatch { signInResponse ->
tokenDataSource.accessToken = signInResponse.accessToken.toEntity()
tokenDataSource.refreshToken = signInResponse.refreshToken.toEntity()
}
}

override suspend fun rejectSignIn() {
if (isSigned() || isSignRejected()) return
authPref.edit().putBoolean(IS_SIGN_REJECTED, true).apply()
}

override suspend fun signOut(): Result<Unit> {
return runCatchingResponse {
authRetrofitService.signOut(getAccessToken().getOrThrow().format())
}.onSuccessOrCatch {
tokenDataSource.accessToken = null
tokenDataSource.refreshToken = null
}
}

override suspend fun deleteAccount(): Result<Unit> {
return runCatchingResponse {
authRetrofitService.deleteAccount(getAccessToken().getOrThrow().format())
}.onSuccessOrCatch {
tokenDataSource.accessToken = null
tokenDataSource.refreshToken = null
}
}

private suspend fun refresh(refreshToken: Token): Result<Unit> {
return runCatchingResponse {
authRetrofitService.refresh(RefreshRequest(refreshToken.token))
}.onSuccessOrCatch { refreshTokenResponse ->
tokenDataSource.accessToken = refreshTokenResponse.accessToken.toEntity()
}
}

companion object {
private const val SOCIAL_TYPE = "KAKAO"
private const val AUTH_PREF = "auth_pref"
private const val IS_SIGN_REJECTED = "is_sign_rejected"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.festago.festago.data.repository

import com.festago.festago.domain.model.token.Token
import com.festago.festago.domain.repository.UserRepository
import java.time.LocalDateTime
import javax.inject.Inject

class FakeUserRepository @Inject constructor() : UserRepository {

override suspend fun isSignRejected(): Boolean {
return false
}

override suspend fun isSigned(): Boolean {
return true
}

override suspend fun getRefreshToken(): Result<Token> {
return Result.success(Token("", LocalDateTime.now()))
}

override suspend fun getAccessToken(): Result<Token> {
return Result.success(Token("", LocalDateTime.now()))
}

override suspend fun signIn(code: String): Result<Unit> {
return Result.success(Unit)
}

override suspend fun signOut(): Result<Unit> {
return Result.success(Unit)
}

override suspend fun rejectSignIn() {
// handle reject sign in
}

override suspend fun deleteAccount(): Result<Unit> {
return Result.success(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.festago.festago.data.service

import com.festago.festago.data.dto.user.RefreshRequest
import com.festago.festago.data.dto.user.RefreshResponse
import com.festago.festago.data.dto.user.SignInRequest
import com.festago.festago.data.dto.user.SignInResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Header
import retrofit2.http.POST

interface AuthRetrofitService {
@POST("api/v1/auth/login/oauth2")
suspend fun signIn(
@Body signInRequest: SignInRequest,
): Response<SignInResponse>

@POST("api/v1/auth/refresh")
suspend fun refresh(
@Body refreshRequest: RefreshRequest,
): Response<RefreshResponse>

@POST("api/v1/auth/logout")
suspend fun signOut(
@Header("Authorization") token: String,
): Response<Unit>

@DELETE("api/v1/auth")
suspend fun deleteAccount(
@Header("Authorization") token: String,
): Response<Unit>
}
Loading
Loading