RiverMoon Tech Blog
  • Android DataStore로 토큰 처리하기 [Kotlin]
    2023년 11월 14일 17시 08분 26초에 업로드 된 글입니다.
    작성자: Moonsu99

    토큰 저장 방법 3가지 비교

    SharedPreferences

    쉽게 설명하자면 가벼운 데이터를 Key-Value로 저장하기 위한 매커니즘이라고 생각하면 된다.

    앱을 종료해도 데이터가 계속 유지되서 앱 설정이나 토큰같은 간단한 정보를 저장하는데 좋음.

    // SharedPreferences 객체 가져오기
    SharedPreferences sharedPreferences = getSharedPreferences("my_prefs", Context.MODE_PRIVATE);
    
    // 데이터 저장
    SharedPreferences.Editor editor = sharedPreferences.edit();
    editor.putString("username", "JohnDoe");
    editor.apply();
    
    // 데이터 검색
    String username = sharedPreferences.getString("username", "DefaultUsername");
    
    

    근데 요즘 왜 토큰을 저장하는데 잘 사용하지 않는 이유?

    암호화가 안됨 그래서 루팅된 기기에서 접근이 된다고 함.

    KeyStore

    암호화 키를 보다 안전하게 관리할 수 있다. 키는 하드웨어 보안 모듈에 의해 보호되며, 앱 외부에서는 접근할 수 없어서 보안에 좋다.

    fun createKey(alias: String): SecretKey {
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "TestKeyStore")
        keyGenerator.init(
            KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .build()
        )
        return keyGenerator.generateKey()
    }
    
    // 키 생성 예시
    val secretKey = createKey("MyKeyAlias")
    
    

    사용하긴 하는데 왜? dataStore보다 사용하지 않는가?

    구현이 복잡하며, 안드로이드 버전과 호환성을 고려를 해야 하기 때문에 사용하기에 어려움이 있음.

    DataStore

    SharedPreferences의 대안으로, Coroutine과 Flow를 사용하여 비동기적이고 반응형의 데이터 저장 및 관리를 제공함.

    데이터 일관성과 트랜잭션이 보장되며, 암호화와 같은 추가 보안 조치를 쉽게 구현할 수 있다.

    val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "token_preferences")
    
    class TokenRepository(private val context: Context) {
        private val TOKEN_KEY = stringPreferencesKey("jwt_token")
    
        val token: Flow<String?>
            get() = context.dataStore.data.map { preferences ->
                preferences[TOKEN_KEY]
            }
    
        suspend fun saveToken(token: String) {
            context.dataStore.edit { preferences ->
                preferences[TOKEN_KEY] = token
            }
        }
    }
    // 사용 예시
    val tokenRepository = TokenRepository(context)
    // 토큰 저장
    tokenRepository.saveToken("your_jwt_token")
    // 토큰 가져오기
    val tokenFlow = tokenRepository.token
    
    

    단점으로는 비교적 최근에 나온 API이기 때문에 기존 SharedPreferences 사용자들에게는 학습 곡선이 있을 수 있다고 함.

    결론적으로 왜? dataStore가 많이 사용 되는 것 일까?

    1. DataStore는 데이터의 비동기적 처리와 보다 효율적인 데이터 관리를 가능하게 하는 코루틴과 플로우를 지원하기 때문에.
    2. 보안적인 측면에서 DataStore는 SharedPreferences보다 강력한 옵션을 제공해서.
    3. DataStore는 데이터 일관성과 트랜잭션 관리에 있어서 SharedPreferences보다 더 나은 해결책을 제공해서.

    파일 기반 토큰 인증시스템 흐름

    dataStore, Interceptor, Authenticator을 이용한 토큰 인증-재발급 구현

    1. 초기 설정 및 Retrofit 인스턴스 생성 (RetrofitInstance.kt)

    • 앱 시작 시 RetrofitInstance 객체의 init 메소드를 호출.
    • 초기화 하지 않으면 “lateinit property has not been initialized” 오류가 발생함.
    • **UserTokenDataStore**를 사용하여 TokenManager 인스턴스를 초기화.
    • **OkHttpClient**를 생성하고 여기에 AuthInterceptor 및 Log를 확인 할 수 있게**loggingInterceptor**을 추가.
    • Retrofit 인스턴스를 생성하고, RenewTokenApiService 및 기타 필요한 API 서비스 인터페이스를 초기화.
    • **AuthAuthenticator**를 **OkHttpClient**에 설정하여 토큰 만료 시 자동 재발급 로직을 활성화.
    • **ApiServiceTest**와 같은 추가적인 서비스 인스턴스를 생성.

    2-1. 로그인 및 토큰 저장 (AuthInterceptor.kt)

    • 사용자가 로그인을 시도할 때, **AuthInterceptor**는 로그인 요청을 감지하고 Authorization에 AccessToken 없이 요청을 진행.
    • 또한 로그인이 성공하면 서버로부터 받은 set-cookie Header에서 RefreshToken을 추출하고 **TokenManager**를 사용하여 로컬 저장소에 저장.

    2-2. API 요청 및 액세스 토큰 적용 (AuthInterceptor.kt)

    • API 요청이 발생하면 **AuthInterceptor**가 해당 요청을 가로채 accessToken을 요청 헤더에 추가.
    • 토큰 재발급 요청(/member/renew-access-token)은 예외 처리하여 Authorization 헤더를 추가하지 않게 하기.

    3. 토큰 만료 및 자동 재발급 (AuthAuthenticator.kt)

    • 서버로부터 401 Unauthorized 응답을 받으면 **AuthAuthenticator**가 호출되게 설정.
    • **AuthAuthenticator**는 **TokenManager**를 사용하여 refreshToken을 가져온 후, **RenewAccessTokenApiService**를 통해 새 accessToken을 요청.
    • 새 토큰을 받으면 **TokenManager**를 사용하여 저장하고, 해당 토큰으로 요청을 재구성하여 재시도.Authenticator 과정 상세보기 
      •   1. 인증 실패 감지 
        • **AuthAuthenticator**는 네트워크 요청이 서버로부터 401 Unauthorized 응답을 받았을 때 자동으로 호출함.
      • 2. 리프레시 토큰 검색
        • **AuthAuthenticator**는 **TokenManager**의 getRefreshTokenBlocking() 메서드를 사용하여 저장된 refreshToken을 동기적으로 검색.
        • 만약 refreshToken이 없거나 가져오기에 실패하면, 토큰 재발급 과정은 중단되고, null이 반환.
      • 3. 새 액세스 토큰 요청
        • refreshToken이 있는 경우, **AuthAuthenticator**는 **RenewAccessTokenApiService**의 renewAccessTokenSync() 메서드를 호출하여 새 accessToken을 요청.
        • 이 때, refreshToken은 HTTP 요청의 Cookie 헤더를 통해 서버에 전달.
        interface RenewAccessTokenApiService {
            @POST("/test")
            fun renewAccessTokenSync(
                @Header("Cookie") refreshToken: String
            ): Call<RenewAccessTokenResponse>
        }
      • 4. 응답 처리 및 토큰 저장
        • 서버로부터 새 accessToken, refreshToken을 포함한 응답을 받으면, **AuthAuthenticator**는 이를 **TokenManager**를 통해 저장.
        • **TokenManager**의 saveAccessTokenBlocking() 메서드를 사용하여 token을 저장.
        5. 요청 재구성 및 재시도
        • 새 토큰을 저장한 후, **AuthAuthenticator**는 원래 실패했던 네트워크 요청을 새 accessToken으로 업데이트하여 재구성.
    댓글