안드로이드에서 JWT 재발급 받기

KEH·2023년 4월 7일
1

프로젝트를 통해 경험했던 Refresh Token을 활용한 access token 재발급 과정 을 공유하고자 합니다.

기존에는?

처음 프로젝트를 시작할 때도 JWT를 사용해 사용자의 권한을 확인했습니다.
하지만 단순히 accessToken의 만료 시간을 최대한 길게 하여 유효시간 만료로 인한 이슈를 방지했습니다.


사실 이때 보안상 좋지 않은 방법이라는 걸 알고도 사용했지요 ㅎㅎ,,,


아무튼! 반성하며(?) refreshToken을 활용하여 accessToken을 재발급 받을 수 있도록 로직을 수정하기 시작했습니다.


1. Hilt - @Qualifier

로직 수정 전 서버에서는 JWT가 필요하지 않은 api에서 header에 accessToken이 담겨 있는지 확인하지 않았습니다.
그래서 저는 따로 구분하지 않고 Request header에 accessToken을 추가하는 Interceptor 클래스를 작성해 모든 api에 해당 Interceptor 클래스를 추가하여 요청했습니다.
이번 로직을 수정하면서 서버 상에서 JWT가 필요하지 않는 api에 accessToken이 전달될 경우 에러가 발생하게 변경 되었더라구요.

첫번째 시련이 찾아왔습니다.

저는 DI 라이브러리로 Hilt를 사용하고 있었고, accessToken을 추가하는 Interceptor가 포함된 OkHttpClient 인스턴스와 포함되지 않은 OkHttpClient 인스턴스 두 종류를 생성해야 했습니다.

다행히 Hilt의 @Qualifier 어노테이션을 통해 각각 생성이 가능했습니다. 휴 ~
이 부분과 관련해서는 제가 따로 작성한 글을 참고해주세요!

Hilt - @Qualifier


2. TokenInterceptor

1. Request Header에 accessToken을 추가해준다.
2. 401(토큰 만료) 에러 발생 시 refreshToken을 활용해 accessToken을 재발급 받고 api를 재요청한다.

@Singleton
class TokenInterceptor @Inject constructor(private val authRepository: AuthRepository): Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        /* Request Header에 accessToken을 추가한다. */
        val request = chain.request().putTokenHeader(SpfUtils.getStrEncryptedSpf(ACCESS_TOKEN)?: "")
        var response: Response = chain.proceed(request)

        /* 토큰 유효 시간 만료 에러 발생 */
        if (response.code()==401) {
            runBlocking {
                /* accessToken 재발급 api 요청 */
                val accessToken: String = SpfUtils.getStrEncryptedSpf(ACCESS_TOKEN)?: ""
                val refreshToken: String = SpfUtils.getStrEncryptedSpf(REFRESH_TOKEN)?: ""
                val reissueResEntity: BaseResEntity<ReissueResEntity?> = authRepository.reissue(ReissueReqEntity(accessToken = accessToken, refreshToken = refreshToken))

                /* accessToken 재발급 api가 성공적으로 응답이 왔을 때 */
                if (reissueResEntity.data!=null) {
                    /* EncryptedSharedPreferences에 새로운 accessToken과 refreshToken을 저장한다. */
                    SpfUtils.writeEncryptedSpf(ACCESS_TOKEN, reissueResEntity.data!!.accessToken)
                    SpfUtils.writeEncryptedSpf(REFRESH_TOKEN, reissueResEntity.data!!.refreshToken)

                    /* 새로운 accessToken을 Request Header에 추가하고, 기존에 요청하고자 했던 api를 재요청한다. */
                    val refreshRequest = chain.request().putTokenHeader(reissueResEntity.data!!.accessToken)
                    response.close()
                    response = chain.proceed(refreshRequest)
                }
            }

            /* Response Data를 전달한다. */
            return response
        } else {
            return response
        }
    }
}

다음의 과정을 처리하기 위해 작성한 코드입니다.


3. 두 번째 문제 발생

두번째 문제가 발생합니다.


여러 개의 API가 한번에 요청될 경우, 토큰 재발급 API가 여러번 호출됩니다.
.
.
.
서버에서는 accessToken과 refreshToken을 새로 발급해주면 기존의 refreshToken은 사용이 불가능해지는데요, 동일한 refreshToken으로 토큰 재발급 API가 여러번 호출되고, 이로인해 '유효하지 않은 refreshToken 입니다.' 라는 응답을 전달해줍니다.


문제를 해결해 봅시다.


1. 기존에 동작하던 비동기 방식에서 동기적 방식으로 변경합니다.
2. 401 에러가 발생했을 때 사용된 accessToken과 EncryptedSharedPreferences에 저장돼 있는 accessToken을 비교합니다.
💡 두 토큰이 같으면 토큰 재발급 요청을 하지 않은 상황이므로 토큰 재발급 api를 호출합니다.
💡 두 토큰이 다르면 토큰 재발급이 된 상황입니다. 따라서 EncryptedSharedPreferences에 저장돼 있는 accessToken으로 기존 api를 재요청 합니다.


@Singleton
class TokenInterceptor @Inject constructor(private val authRepository: AuthRepository): Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        /* Request Header에 accessToken을 추가한다. */
        val accessToken: String = SpfUtils.getStrEncryptedSpf(ACCESS_TOKEN)?: ""
        val request = chain.request().putTokenHeader(accessToken)
        var response: Response = chain.proceed(request)

        /* 토큰 유효 시간 만료 에러 발생 */
        if (response.code == 401) {
            /* synchronized: 괄호 안에 작성된 코드는 동기적 프로그래밍 방식으로 실행된다. */
            synchronized(this) {   
                /* 기존 API 호출 시 사용된 accessToken과 EncryptedSharedPreferences에 사용된 accessToken을 비교한다. */
                val newAccessToken: String = SpfUtils.getStrEncryptedSpf(ACCESS_TOKEN)?: ""

                /* 같으면 토큰 재발급 API 호출한다. */
                if (accessToken==newAccessToken) {  
                    val refreshToken: String = SpfUtils.getStrEncryptedSpf(REFRESH_TOKEN)?: ""
                    val reissueResEntity: BaseResEntity<ReissueResEntity?> = runBlocking {
                        authRepository.reissue(ReissueReqEntity(accessToken = accessToken, refreshToken = refreshToken))
                    }

                    /* 재발급 받은 accessToken과 refreshToken을 EncryptedSharedPreferences에 저장한다. */
                    return if (reissueResEntity.data!=null) {
                        SpfUtils.writeEncryptedSpf(ACCESS_TOKEN, reissueResEntity.data!!.accessToken)
                        SpfUtils.writeEncryptedSpf(REFRESH_TOKEN, reissueResEntity.data!!.refreshToken)

                        response.close()

                        /* 재발급 받은 accessToken을 활용해 기존 API를 재호출한다. */
                        chain.proceed(chain.request().putTokenHeader(reissueResEntity.data!!.accessToken))
                    } else {
                        response
                    }
                } 
                
                /* 다르면 이미 재발급 요청이 완료된 상태이다. 따라서 재발급 받은 accessToken을 활용해 기존 API를 재호출한다. */
                else {    
                    response.close()
                    return chain.proceed(chain.request().putTokenHeader(newAccessToken))
                }
            }
        } else {
            return response
        }
    }

    ...
}

위와 같이 코드가 수정되면, 토큰 재발급 로직이 정상적으로 작동합니다. 👏👏👏


4. 좀 더 개선해보고 싶어요 🤷‍♀️

성공적으로 로직을 변경했지만 그럼에도 아쉬운 점은 존재합니다.
토큰 재발급이 완료되면 기존 api를 재요청합니다. 이때 동기적으로 진행되기 때문에 기존 api가 여러 개일 경우 속도가 느려질 가능성이 있습니다.
따라서 저는 토큰 재발급 후 동기적 방식에서 비동기적 방식으로 변경하여 재요청 상황 시 발생할 수 있는 속도 이슈를 개선하고 싶습니다.
계획하고 있는 리팩토링 과정에서 이 부분을 좀 더 수정해보고자 합니다.






읽어주셔서 감사합니다

profile
개발을 즐기고 잘하고 싶은 안드로이드 개발자입니다 :P

0개의 댓글