[Android, Kotlin] Interceptor + Refresh 토큰을 활용한 재인증 구현 3 - 문제 해결, 최종 코드

 

[문제 상황]

기존에 Interceptor와 Refresh 토큰을 활용한 재인증 코드를 작성했는데, 

 포스팅 1에서 TokenAuthenticator를 활용한 토큰 인증을 구현했다.

 

https://jinudmjournal.tistory.com/86

 

[Android, Kotlin] Interceptor + Refresh 토큰을 활용한 재인증 구현 1

❗️오류가 발생해서 리포스팅 했습니다. https://jinudmjournal.tistory.com/88 [Android, Kotlin] Interceptor + Refresh 토큰을 활용한 재인증 구현 2 이전에 작성한 포스팅에서 오류가 발생해서 리포스팅 합니다.

jinudmjournal.tistory.com

하지만 위의 방식은 성공적으로 토큰을 불러오나 저장하는 방식에서 문제가 발생했기에 아래 방식으로 재구현하고 포스팅했다.

 

https://jinudmjournal.tistory.com/88

 

[Android, Kotlin] Interceptor + Refresh 토큰을 활용한 재인증 구현 2

이전에 작성한 포스팅에서 오류가 발생해서 리포스팅 합니다. 인증을 진행하는 TokenAuthenticator.kt 클래스에서 오류가 발생했다. header를 재정의하고 테스팅을 진행하였다. [MasterApplication.kt] - 토큰

jinudmjournal.tistory.com

 

더 이상 문제가 없을 줄 알았으나, 토큰을 재인증 할 때 여러 번 시도하는 것을 발견했다.

뿐만 아니라 토큰 재인증이 되었음에도 불구하고 새로운 API에 접근할 때 다시 인증을 진행하는 것을 발견했다.

 

전체적으로 코드를 수정하고 테스트 해봤다.

 

토큰 재인증을 위해서 2가지 인터셉터를 사용 했었는데,

1. [authenticate를 활용한 TokenAuthenticator 방식]

2. [intercept를 활용한 AuthInterceptor 방식] 이다.

 

1번 방식은 어떤 분이 블로그에 정리한 글을 참조해서 작성했다.

 

https://www.simplifiedcoding.net/retrofit-authenticator-refresh-token/

 

Retrofit Authenticator Refresh Token Tutorial

In this post we will learn about retrofit authenticator refresh token. If you are using a Refresh Token in your backend API then you can use it.

www.simplifiedcoding.net

위 방식을 사용했을 때 토큰을 재인증 할 때 여러 번 시도하는 것을 발견했다.

뿐만 아니라 토큰 재인증이 되었음에도 불구하고 새로운 API에 접근할 때 다시 인증을 진행하는 것을 발견했다.

토큰을 통해서 재인증이 이루어졌을 때,  새로 토큰을 발급하는 것은 성공했지만,

제대로 된 저장을 못하고 API 요청 시마다 토큰을 재인증 하는 것을 발견했다.

해당 포스팅의 댓글을 확인해보니 같은 문제를 겪고있는 개발자를 발견하였고, 수정이 필요했다.

 

2번 방식은 1번 방식을 참조해서 intercept 처리를 직접 구현한 것이다.

2번 방식은 토큰 재인증을 통해서 성공적으로 새로운 토큰 값이 저장되는 것을 확인했다.

하지만 토큰 재인증 방식은 새로운 API 요청 시점에 토큰이 만료되면 새로 토큰을 저장하고,

요청한 API에 대한 성공적인 응답을 받아야한다.

 

토큰이 재인증 되는 것 뿐 아니라 토큰 재인증 과정에서 요청한 API에 대한 응답을 성공적으로 반환해야 한다.

이러한 방식에서 2번 방식에 문제가 있었다.

새로운 Product를 불러오는 API 요청 시 토큰이 만료되면 새로운 토큰을 저장하고 요청한 API를 받아야하는데,

토큰 인증만 성공하고 새로운 API를 받는데 실패했다.

 

 

[문제 해결]

Log를 찍어보니, 새로운 인증을 진행하는 과정에서 헤더가 두개인 부분을 발견했다.

토큰을 새로 저장하고, 해당 요청에 대한 새로운 토큰을 발급하는 과정에서 실수를 했다.

기존의 토큰을 가지고있는 헤더를 삭제하고 새로운 토큰을 담은 헤더를 넣어야 하는데, 

기존의 토큰을 담은 헤더를 삭제하지 않아서 2가지 헤더를 담은 채로 요청을 보내고 있었다.

 

//기존 토큰 지우고 새로 response 반환 *** 중요 /////
val newRequest = chain.request().newBuilder().removeHeader("Authorization")
newRequest.addHeader("Authorization","Bearer $accessToken")
Log.d("qazwsxedc response 1",newRequest.toString())
return@runBlocking chain.proceed(newRequest.build())

 

 

해당 부분으로 수정을 해보니 성공적으로 토큰을 재인증하는 부분의 API 처리가 이루어졌다.

 

최종적인 재인증 인터셉터는 아래와 같다.

class AuthInterceptor(
    private val context: Context,
    private val tokenApi: TokenRefreshApi,
    ) : Interceptor,BaseRepository(){

    override fun intercept(chain: Interceptor.Chain): Response{
        val request = chain.request()
        val response = chain.proceed(request)

        when(response.code){
            401 ->{
                // 토큰 재인증
                return runBlocking {
                    when (val token = getUpdateToken()) {
                        is Resource.Success -> {
                            val sp  =context.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
                            val editor = sp.edit()
                            // 기존 토큰
                            Log.d("test code ists sssss 2",sp.getString("accessToken","no token").toString())
                            val tokenValue = token.value!!
                            // new 토큰
                            Log.d("test code ists sssss 3",tokenValue.accessToken)
                            val accessToken = tokenValue.accessToken
                            val refreshToken =tokenValue.refreshToken
                            editor.putString("accessToken", accessToken)
                            editor.putString("refreshToken", refreshToken)
                            editor.apply()

                            //기존 토큰 지우고 새로 response 반환 *** 중요 /////
                            val newRequest = chain.request().newBuilder().removeHeader("Authorization")
                            newRequest.addHeader("Authorization","Bearer $accessToken")
                            Log.d("qazwsxedc response 1",newRequest.toString())
                            return@runBlocking chain.proceed(newRequest.build())

                        }
                        else ->{
                            Log.d("qazwsxedc response 2",response.request.toString())

                            return@runBlocking response
                        }
                    }
                }
            }
        }
        Log.d("qazwsxedc response 3",response.request.toString())
        // 에러가 아니라면 정상 response 반환
        return response
    }

    private suspend fun getUpdateToken() : Resource<Token?> {
        val refreshToken = context.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
            .getString("refreshToken","").toString()

        //safeApiCall을 통한 api 요청
        // refresh token이 비었을 경우에는 null 전송을 통해서 에러 반환을 받음
        return safeApiCall { tokenApi.patchToken(refreshToken) }
    }


}

 

401 에러가 발생할 때 새로운 토큰을 발급하고 그 과정에서 전송되는 API에 새로 받은 토큰을 담아서 통신을 마무리하는 과정이다.

 

수정하면서 다른 클래스도 주석을 달고 깔끔하게 정리했다.

 

Resource.kt (통신의 본체 담당하는 부분)

//Resource : 통신의 에러를 구분
// 성공적인 통신일 경우 Success
// 실패한 경우 Failure을 통한 에러 조사
sealed class Resource<out T> {
    // 통신 성공
    data class Success<out T>(val value: T) : Resource<T>()

    //통신 실패 데이터
    data class Failure(
        val isNetworkError: Boolean,
        val errorCode: Int?,
        val errorBody: ResponseBody?
    ) : Resource<Nothing>()
}

 

SafeApiCall.kt (안전한 API 전송을 위한 코드)

//안전한 api 전송을 위한 코드
// Resource.Success(apiCall.invoke()) 코드를 반환하는 것이 목표
// 실패할 경우 에러 반환
interface SafeApiCall {

    suspend fun <T> safeApiCall(
        apiCall: suspend () -> T
    ): Resource<T> {
        return withContext(Dispatchers.IO) {
            try {
                Log.d("qazwsxedc safeapi 1","is ok ~ ")
                // 통신에 성공하면 전달 받은 값 전송
                Resource.Success(apiCall.invoke())
            } catch (throwable: Throwable) {
                when (throwable) {
                    is HttpException -> {
                        Log.d("qazwsxedc safeapi 1",throwable.response()?.errorBody()!!.string())
                        Resource.Failure(false, throwable.code(), throwable.response()?.errorBody())
                    }
                    else -> {
                        Log.d("qazwsxedc safeapi 1","else error")
                        Resource.Failure(true, null, null)
                    }
                }
            }
        }
    }
}

 

Token.kt (토큰 반환 모델)

 

data class Token (
    val accessToken : String,
    val refreshToken : String,
)

 

TokenRefreshApi.kt (토큰 재인증 요청  API)

 

//Refresh Api 호출을 위한 인터페이스
// 새로운  access , refresh token 반환
interface TokenRefreshApi {
    @PATCH("token")
    suspend fun patchToken(
        @Query("refreshToken") refreshToken:String
    ) : Token
}

 

MasterApplication.kt (retrofit을 생성하고 API 모든 통신을 담당하는 앱 클래스)

class MasterApplication : Application() {
    lateinit var service: RetrofitService
    private val baseUrl = "http://app - base - url"
    private lateinit var activity: Activity
    override fun onCreate() {
        super.onCreate()
        Stetho.initializeWithDefaults(this)
    }

    fun createRetrofit(currentActivity: Activity){
        activity = currentActivity

        val header = Interceptor{
            val original = it.request()
            if (checkIsLogin()){
                getUserToken().let { token->
                    Log.d("token test 1",token.toString())
                    val request = original.newBuilder()
                        .header("Authorization","Bearer $token")
                        .build()
                    it.proceed(request)
                }
            }else{
                it.proceed(original)
            }
        }

        // 레트로핏 생성 1
        val retrofit = Retrofit.Builder()
            .baseUrl("$baseUrl/")
            .addConverterFactory(ScalarsConverterFactory.create())
            .addConverterFactory(GsonConverterFactory.create())
            .client(getRetrofitClient(header))
            .build()

        service = retrofit.create(RetrofitService::class.java)

    }

    //TokenRefreshApi를 빌드 
    private fun buildTokenApi() : TokenRefreshApi {
        //임시 클라이언트
        val client = OkHttpClient.Builder().build()
        return Retrofit.Builder()
            .baseUrl("$baseUrl/")
            .client(client)
            .addConverterFactory(ScalarsConverterFactory.create())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(TokenRefreshApi::class.java)
    }

    //레트로핏 클라이언트 생성 함수 1
    // 빌드 :  okhttp client
    // 인터셉터를 통한 request를 보냄

    private fun getRetrofitClient(header:Interceptor):OkHttpClient{
        return OkHttpClient.Builder()
            .addInterceptor {chain->
                chain.proceed(chain.request().newBuilder().also {
                    it.addHeader("Accept", "application/json")
                }.build())
            }.also { client->
                client.addInterceptor(header)
                client.addInterceptor(AuthInterceptor(activity,buildTokenApi()))
                //오류 관련 인터셉터도 등록 (오류 출력 기능 )
                val logInterceptor = HttpLoggingInterceptor()
                logInterceptor.level = HttpLoggingInterceptor.Level.BODY
                client.addInterceptor(logInterceptor)
            }.build()
    }



    private fun checkIsLogin() : Boolean{
        val sp = activity.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
        val token = sp.getString("accessToken","null")
        return token!="null"
        //is not default
    }
    private fun getUserToken(): String?{
        val sp = activity.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
        val token = sp.getString("accessToken","null")
        return if (token=="null") null
        else token
    }
}

 

이제 API를 사용할 때 matsterApplication을 선언해서 사용하면 된다.

//MaterApplication : 로그인 + API 연동////
val masterApp = MasterApplication()
///////////////////////////////////////

 

 

정상적으로 토큰 만료 시 재인증 되는 것을 확인했다.

총 3번의 포스팅을 거쳐서 수정할 만큼 복잡한 과정이었다.

토큰을 통한 인증은 서버와 API 연동할 때 필수인 과정이기에 상세하게 공부했다.

 

해당 부분을 git에 따로파서 재인증 방식을 등록해두는 것이 좋을 것 같다.