
Android 앱 개발을 하면서 사용자 로그인 처리를 해주려면 Token 관리가 필수적이다.
보통 로그인 API를 통해 Access Token, Refresh Token을 Response 값으로 받게 되고, 이 데이터를 로컬 데이터베이스에 저장해둔다. 그리고 이후 다른 API Request에, Access Token을 Header로 넣는 형태로 많이 사용된다.
이때 Token을 로컬에 저장할 때 많이 쓰는 것이 SharedPreferences인데, Token은 중요한 정보인 만큼 단순 SharedPreferences만으로는 보안이 떨어질 수 있다.
이때 사용할 수 있는 게 바로 EncryptedSharedPreferences이다. EncryptedSharedPreferences는 값을 암호화해서 저장하기 때문에 보안적 측면에서 더 좋은 방식으로 볼 수 있다.
우선, 프로젝트가 클린 아키텍처, 멀티 모듈, DI가 세팅되어 있다는 가정을 하고 설명하려고 한다. 만약 클린 아키텍처를 따르지 않거나, 단일 모듈이더라도 비슷하게 적용해보면 쉽게 사용할 수 있다.
DataStore를 만들어서 Token 저장 및 사용 등의 관리를 하는 방식으로 구현할 것인데, 이때 나는 domain 모듈에 DataStore interface를 만들고, data 모듈에 구현체를 만들어줬다. Token은 Repository, UseCase, ViewModel 등 모든 곳에서 접근을 할 수 있다고 생각을 했기 때문에 이런식으로 설계를 하게 됐다.
라이브러리에 대한 추가적인 설명은 위 공식문서를 통해 살펴볼 수 있다.
이제 EncryptedSharedPreferences를 이용해 Token을 암호화해 관리하는 방법을 차례로 설명해보려 한다.
implementation "androidx.security:security-crypto:1.0.0"
data 모듈에서 구현을 할 것이기 때문에 data 모듈에 의존성을 추가해주면 된다.
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의 저장 유무로 로그인 유무를 판단하기 위해 함수를 따로 만들어줬다.
@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_SIV와 AES256_GCM으로 암호화했다.
EncryptedSharedPreferences 초기화 실패로 에러가 나는 경우가 있어서, 그 경우 기본 SharedPreferences로 대체하게 해뒀다. (이 부분은 추후 다른 방법으로 해결 가능한지 더 고민해봐야 할 것 같다.)
값을 저장하고 사용하는 방법은 SharedPreferences를 쓸 때처럼 putString, getString을 해주면 된다. key 값은 오타를 방지하고, 수정하기 쉽게 companion object로 관리해줬다.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideAuthTokenDataStore(@ApplicationContext context: Context): AuthTokenDataStore {
return AuthTokenDataStoreImpl(context)
}
}
AuthTokenDataStore 구현체를 전역적으로 사용하기 위해 의존성 모듈로 관리해준다.
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로 주입하여 사용할 수 있다
와 열심히 하시네용