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

❗️오류가 발생해서 리포스팅 했습니다.

https://jinudmjournal.tistory.com/88

 

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

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

jinudmjournal.tistory.com

개념 정리 : 재인증 구현 1

수정 코드 : 재인증 구현 2

 

Refresh Token으로 기존 Access Token을  갱신하는 방법

 

 

로그인 인증으로 토큰을 사용하기 위해서 Access 토큰이 리소스로 접근,

일정 시간이 지나면 Refesh 토큰으로 Access 토큰을 갱신하도록 해야한다.

 

API JWT 기반 로그인 통신은 아래의 로직을 따른다.

 

1. 로그인 성공  : access 토큰 , refresh 토큰 발행

 

2. Authorization Headder에 access token 을 세팅해서 API 호출 

 

3. 응답에 따른 결과 처리

 

   - access token이 유효함 - > 정상 결과 반환

   - access token 만료 - > 401 인증 에러 반환

   - access token이 변조 - > 403 에러 반환 

   - access token이 없음  - > 401 에러 반환

   - refresh tokendl 유효함 - > Authorization Header에 신규 access token 세팅 후 API 반환

                                            - > access token 만료 된 경우 refresh token으로 재발급

 

 

 

 

 

authenticate를 통한 토큰 인증

 

[Token.kt]

 

- 토큰 반환 모델

 

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

 

[TokenAuthenticator.kt]

 

- 요청을 가로채서 인증 관련 오류 탐색 

- getUpdateToken을 통해서 토큰 재인증

- authenticate 함수에서 토큰 재인증을 받아서 새로 토큰 저장

- 실패시 null 반환  

class TokenAuthenticator constructor(
    private val activity: Activity,
    private val tokenApi: TokenRefreshApi,
) : Authenticator, BaseRepository() {
    //BaseRepository : 인증 실패시 로그아웃 전송

    // authenticate :  401 에러가 발생 될 때마다 호출
    // 토큰이 유효하지 않음을 의미
    override fun authenticate(route: Route?, response: Response): Request? {
        return runBlocking {
            when(val token =getUpdateToken()){
                is Resource.Success ->{
                    val sp  =activity.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
                    val editor = sp.edit()
                    val tokenValue = token.value!!
                    val accessToken = tokenValue.accessToken
                    val refreshToken =tokenValue.refreshToken
                    editor.putString("accessToken", accessToken)
                    editor.putString("refreshToken", refreshToken)
                    editor.apply()
                    response.request.newBuilder()
                        .header("Authorization","Bearer $accessToken")
                        .build()
                }
                else ->null
                // 오류가 발생하면 null을 반환해서 api가 무한 새로고침에 빠지지 않도록 함
            }


        }
    }


    //토큰 업데이트
    //새로 토큰을 가져와서 업데이트함
    //토큰을 성공적으로 얻을 경우 업데이트
    //오류 발생 시 null 반환
    private suspend fun getUpdateToken() : Resource<Token?> {
        val refreshToken = activity.getSharedPreferences("login_sp",Context.MODE_PRIVATE)
            .getString("refreshToken",null)
        //safeApiCall을 통한 api 요청
        // refresh token이 비었을 경우에는 null 전송을 통해서 에러 반환을 받음
        return safeApiCall {tokenApi.patchToken(refreshToken)}
    }
}

 

 

[TokenRefreshApi.kt]

 

- Refresh Api 호출을 위한 인터페이스

- 새로운 토큰 반환

interface TokenRefreshApi :BaseApi{
    @FormUrlEncoded
    @POST("token")
    suspend fun patchToken(
        @Body refreshToken:String?
    ) : Token
}

 

[ safeApiCall.kt]

 

- 안전한 api 전송을 위한 코드

- Resource.Success( apiCall.invoke())를 반환하는 것이 목표 

- 실패할 경우 에러 반환

interface SafeApiCall {
    suspend fun <T> safeApiCall(
        apiCall: suspend () -> T
    ): Resource<T> {
        return withContext(Dispatchers.IO) {
            try {
                Resource.Success(apiCall.invoke())
            } catch (throwable: Throwable) {
                when (throwable) {
                    is HttpException -> {
                        Resource.Failure(false, throwable.code(), throwable.response()?.errorBody())
                    }
                    else -> {
                        Resource.Failure(true, null, null)
                    }
                }
            }
        }
    }
}

 

[Resource.kt]

 

- 통신의 에러를 구분하는 처리 

- 성공적인 통신일 경우 Success를 반환

- 실패할 경우 Failure을 통한 에러 조사

- sealed를 통해 자식 클래스 제한 (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>()
}

 

[BaseRepository.kt]

 

- 로그아웃 기능이 있는 api

- 토큰 인증 실패 시 null 값이 넘어오는데, 이를 통해 사용자 로그아웃 진행

- 작성만 해놓고 아직 미구현 

- SafeApiCall의 응답용으로만 구현 

abstract class BaseRepository() : SafeApiCall

 

[BaseApi.kt]

 

- 로그아웃 요청을 위한 api

- 일단 선언하고 사용 x 

interface BaseApi {
    @POST("logout")
    suspend fun logout(): ResponseBody
}

 

[MasterApplication.kt]

 

- 레트로핏을 생성

- 토큰 인증을 적용하고 새로 레트로핏 생성

- buildTokenApi()를 통해서 TokenRefreshApi 탐색 

- getRerofitClient()로 빌드 ,인터셉터를 통한 request 제작

class MasterApplication : Application() {
    lateinit var service: WegglerRetrofitService
    private val baseUrl = "http://dev-api.kooru.be/api/v1"
    private lateinit var header : Interceptor
    override fun onCreate() {
        super.onCreate()
        Stetho.initializeWithDefaults(this)
   
    }

    private fun createRetrofit(activity: Activity){
        //토큰을 위한 인증
        //TokenAuthenticator으로 새 토큰 발행
        val authenticator = TokenAuthenticator(activity,buildTokenApi())

        //레트로핏 생성
        val retrofit = Retrofit.Builder()
            .baseUrl("$baseUrl/")
            .client(getRetrofitClient(authenticator))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        service = retrofit.create(WegglerRetrofitService::class.java)

    }

    //TokenRefreshApi를 빌드
    private fun buildTokenApi() : TokenRefreshApi {
        return Retrofit.Builder()
            .baseUrl("$baseUrl/")
            .client(getRetrofitClient())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(TokenRefreshApi::class.java)
    }

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

    private fun getRetrofitClient(authenticator: Authenticator? = null): OkHttpClient{
        return OkHttpClient.Builder()
            .addInterceptor { chain->
                chain.proceed(chain.request().newBuilder().also {
                    it.addHeader("Accept", "application/json")
                }.build())
            }.also { clint->
                authenticator?.let { clint.authenticator(it) }
                if (BuildConfig.DEBUG){
                    //오류 관련 인터셉터도 등록 (오류 출력 기능 )
                    val logInterceptor  = HttpLoggingInterceptor()
                    logInterceptor.level = HttpLoggingInterceptor.Level.BODY
                    clint.addInterceptor(logInterceptor)
                }
            }.build()
    }

    // 401 에러 발생 시 인증자가 토큰을 새로 갱신하려고 시도
    // 새로 고침에 성공하면 사용자가 로그아웃 되지 않음
}

 

 

이제 MasterApplication을 통한 API 통신을 진행하면 토큰이 유효한지 검사한다.

토큰이 유효할 경우 성공적으로 API 통신을 진행하고,  

토큰이 만료되었을 경우 (401 에러) 새로운 토큰을 갱신하려고 시도한다.

인증자는 새로 고침을 통해서 새로운 인증을 진행하며 성공하면 API 통신을 진행한다.

 

만약 새로 고침을 통한 인증도 실패할 경우 갱신할 토큰이 없으므로 로그아웃을 진행한다.

(로그아웃 코드는 아직 작성하지 않음 )