[Android/Kotlin] EncryptedSharedPreferences로 Token 관리

jmi·2024년 11월 18일
post-thumbnail

Android 앱 개발을 하면서 사용자 로그인 처리를 해주려면 Token 관리가 필수적이다.

보통 로그인 API를 통해 Access Token, Refresh Token을 Response 값으로 받게 되고, 이 데이터를 로컬 데이터베이스에 저장해둔다. 그리고 이후 다른 API Request에, Access Token을 Header로 넣는 형태로 많이 사용된다.

이때 Token을 로컬에 저장할 때 많이 쓰는 것이 SharedPreferences인데, Token은 중요한 정보인 만큼 단순 SharedPreferences만으로는 보안이 떨어질 수 있다.

이때 사용할 수 있는 게 바로 EncryptedSharedPreferences이다. EncryptedSharedPreferences는 값을 암호화해서 저장하기 때문에 보안적 측면에서 더 좋은 방식으로 볼 수 있다.


EncryptedSharedPreferences를 사용하는 방법?

우선, 프로젝트가 클린 아키텍처, 멀티 모듈, DI가 세팅되어 있다는 가정을 하고 설명하려고 한다. 만약 클린 아키텍처를 따르지 않거나, 단일 모듈이더라도 비슷하게 적용해보면 쉽게 사용할 수 있다.

DataStore를 만들어서 Token 저장 및 사용 등의 관리를 하는 방식으로 구현할 것인데, 이때 나는 domain 모듈에 DataStore interface를 만들고, data 모듈에 구현체를 만들어줬다. Token은 Repository, UseCase, ViewModel 등 모든 곳에서 접근을 할 수 있다고 생각을 했기 때문에 이런식으로 설계를 하게 됐다.

EncryptedSharedPreferences

라이브러리에 대한 추가적인 설명은 위 공식문서를 통해 살펴볼 수 있다.
이제 EncryptedSharedPreferences를 이용해 Token을 암호화해 관리하는 방법을 차례로 설명해보려 한다.

1. 라이브러리 의존성 추가

implementation "androidx.security:security-crypto:1.0.0"

data 모듈에서 구현을 할 것이기 때문에 data 모듈에 의존성을 추가해주면 된다.

2. domain 모듈에 DataStore interface


interface AuthTokenDataStore {
    fun saveAccessToken(accessToken: String)
    fun saveRefreshToken(refreshToken: String)

    fun getAccessToken(): String?
    fun getRefreshToken(): String?

    fun deleteAccessToken()
    fun deleteToken()

    fun isLogin(): Boolean
}

Access Token이 만료되면 Refresh Token을 이용해 재발급 해줘야 하기 때문에 각각 save, get, delete 함수를 따로 두었다. 단, deleteToken 함수의 경우 로그아웃 처리를 할 때 토큰을 다 삭제해주기 위해 둘다 한 번에 삭제하게 했다.
isLogin은 없어도 되는데, 나는 Token의 저장 유무로 로그인 유무를 판단하기 위해 함수를 따로 만들어줬다.

3. data 모듈에 DataStore 구현체

@Singleton
class AuthTokenDataStoreImpl @Inject constructor(
    @ApplicationContext private val context: Context,
) : AuthTokenDataStore {

    private val sharedPreferences: SharedPreferences = try {
        EncryptedSharedPreferences.create(
            "secret_shared_prefs",
            MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
            context,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    } catch (e: Exception) {
        Log.e("AuthTokenDataStoreImpl", "Error initializing EncryptedSharedPreferences, falling back to default SharedPreferences", e)
        context.getSharedPreferences("fallback_prefs", Context.MODE_PRIVATE)
    }

    override fun saveAccessToken(accessToken: String) {
        sharedPreferences.edit().putString(ACCESS_TOKEN, accessToken).apply()
    }

    override fun saveRefreshToken(refreshToken: String) {
        sharedPreferences.edit().putString(REFRESH_TOKEN, refreshToken).apply()
    }


    override fun getAccessToken(): String? {
        val accessToken = sharedPreferences.getString(ACCESS_TOKEN, null)

        if (accessToken.isNullOrEmpty()) return null
        return BEARER + accessToken
    }

    override fun getRefreshToken(): String? {
        return sharedPreferences.getString(REFRESH_TOKEN, null)
    }


    override fun deleteAccessToken() {
        sharedPreferences.edit().remove(ACCESS_TOKEN).apply()
    }

    override fun deleteToken() {
        sharedPreferences.edit().remove(ACCESS_TOKEN).apply()
        sharedPreferences.edit().remove(REFRESH_TOKEN).apply()
    }

    override fun isLogin(): Boolean { // 로그인 유무 = Token 존재 유무
        val accessToken = sharedPreferences.getString(ACCESS_TOKEN, null)
        val refreshToken = sharedPreferences.getString(REFRESH_TOKEN, null)

        return !accessToken.isNullOrEmpty() && !refreshToken.isNullOrEmpty()
    }

    private companion object {
        private const val BEARER = "Bearer "

        private const val ACCESS_TOKEN = "access_token"
        private const val REFRESH_TOKEN = "refresh_token"
    }
}

DataStore가 애플리케이션 전반에서 사용되도록 @Singleton으로 구현했다.
Context 객체가 필요한 곳이 있어서 Hilt로 의존성을 주입했다.

암호화 키는 MasterKeys를 사용해 생성된 AES256_GCM_SPEC으로 관리하고, 데이터 키와 값은 각각 AES256_SIVAES256_GCM으로 암호화했다.
EncryptedSharedPreferences 초기화 실패로 에러가 나는 경우가 있어서, 그 경우 기본 SharedPreferences로 대체하게 해뒀다. (이 부분은 추후 다른 방법으로 해결 가능한지 더 고민해봐야 할 것 같다.)

값을 저장하고 사용하는 방법은 SharedPreferences를 쓸 때처럼 putString, getString을 해주면 된다. key 값은 오타를 방지하고, 수정하기 쉽게 companion object로 관리해줬다.

4. DataSource 의존성 모듈

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
    @Provides
    @Singleton
    fun provideAuthTokenDataStore(@ApplicationContext context: Context): AuthTokenDataStore {
        return AuthTokenDataStoreImpl(context)
    }
}

AuthTokenDataStore 구현체를 전역적으로 사용하기 위해 의존성 모듈로 관리해준다.

5. 사용

class TestTokenRepositoryImpl @Inject constructor(
    private val authTokenDataSource: AuthTokenDataStore,
) : TestTokenRepository {

    private fun getAuthorization(): String {
        return authTokenDataSource.getRefreshToken() ?: throw ApiException.InvalidTokenException()
    }
}
@HiltViewModel
class TestViewModel @Inject constructor(
    private val authTokenDataStore: AuthTokenDataStore,
) : ViewModel() {
	...
}

이런식으로 원하는 곳에 DI로 주입하여 사용할 수 있다

profile
안드로이드 개발자가 되자

2개의 댓글

comment-user-thumbnail
2024년 11월 18일

와 열심히 하시네용

1개의 답글