Google Login Access Token 얻는 방법을 알아보자!

이지훈·2021년 10월 22일
6

[Android]

목록 보기
5/8
post-thumbnail
post-custom-banner

구글 공식 문서와, StackOverflow의 레거시 코드를 대체

ID 토큰 얻어오기

그동안 파이어베이스를 통해서 소셜로그인을 구현하다,
처음으로 백엔드 서버로 인증을 통해 구글 소셜 로그인(OAuth)을 구현하기 위해 해당 문서를 참고하였다.

Google Sign-In for Android Guide

Access 토큰 얻어오기(문제 발생)

ID 토큰을 얻는 방법까지는 파이어베이스 를 통해 ID 토큰을 얻는 방법과 유사하였기 때문에 문서의 흐름을 따라 문제 없이 구현하여 ID 토큰을 얻을 수 있었다.

그리고 이제, 백엔드 서버로 인증하는 코드를 보면 다음과 같이 쓰여 있는 것을 확인할 수 있다.

HttpClient httpClient = new DefaultHttpClient();
HttpPost httpPost = new HttpPost("https://yourbackend.example.com/tokensignin");

try {
  List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(1);
  nameValuePairs.add(new BasicNameValuePair("idToken", idToken));
  httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs));

  HttpResponse response = httpClient.execute(httpPost);
  int statusCode = response.getStatusLine().getStatusCode();
  final String responseBody = EntityUtils.toString(response.getEntity());
  Log.i(TAG, "Signed in as: " + responseBody);
} catch (ClientProtocolException e) {
  Log.e(TAG, "Error sending ID token to backend.", e);
} catch (IOException e) {
  Log.e(TAG, "Error sending ID token to backend.", e);
}

음... 이 부분에서 상당히 멘붕이 왔는데, 일단 Android Studio에 해당 코드를 붙혀넣기 하면

자바코드인건 상관이 없어도 다음과 같이 상당히 손봐줘야할 부분이 많다.
또한 안드로이드 개발자들은 네트워크 통신에 있어서 기존의 OkHttpClient를 사용하는 방법이 아닌 Retrofit 라이브러리를 주로 사용하기 때문에, 상당히 레거시하고 불친절하다고 느껴졌다.

그리고 제일 큰 문제는 해당 코드가 쓸모가 없다는 것인데,
백엔드 팀에서의 요구사항은 구글에 로그인을 요청하여 AccessToken을 확보하고 이를 request의 Header에 넣어 API 호출하라는 것이었기 때문이었다.

한마디로 요약하면,
1) 공식문서에 써있는 방법을 통해 ID 토큰을 얻는다,
2) 이를 구글에 보내서 AccessToken을 얻는다
3) AccessToken을 request에 header에 담아 로그인API를 호출한다.

여기까지가 클라이언트단에서 해야할 일이고, 위의 (2)번의 방법은 공식문서에 나와있지 않았다.

뭐 이제는 다른 쉬운 방법을 쓰는건지, 공식문서가 업데이트가 안되고 있는건지, Retrofit 은 Google 공식 라이브러리가 아니기때문에 예제코드에 포함해주지 않는지 뭐 이런 생각들은 차차 해두고(코드랩에서 Retrofit moshi 언급하던데?),

AccessToken을 가져오는 방법을 구글링해보았다.

문제 해결 과정

StackOverFlow - How to get access token after user is signed in from gmail in android

OkHttpClient client = new OkHttpClient();
    RequestBody requestBody = new FormEncodingBuilder()
            .add("grant_type", "authorization_code")
            .add("client_id", "812741506391-h38jh0j4fv0ce1krdkiq0hfvt6n5amrf.apps.googleusercontent.com")
            .add("client_secret", "{clientSecret}")
            .add("redirect_uri","")
            .add("code", "4/4-GMMhmHCXhWEzkobqIHGG_EnNYYsAkukHspeYUk9E8")
            .build();
    final Request request = new Request.Builder()
            .url("https://www.googleapis.com/oauth2/v4/token")
            .post(requestBody)
            .build();
    client.newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(final Request request, final IOException e) {
            Log.e(LOG_TAG, e.toString());                
        }

        @Override
        public void onResponse(Response response) throws IOException {
            try {
                JSONObject jsonObject = new JSONObject(response.body().string());
                final String message = jsonObject.toString(5);
                Log.i(LOG_TAG, message);                    
            } catch (JSONException e) {
                e.printStackTrace();
            }
        }
    });

해당 코드를 사용하여 accessToken을 성공적으로 반환받을 수 있었다는 덧글을 읽고 나도 이 코드를 통하여 accessToken을 얻을 수 있었다.

하지만...

더 나아가 개선

이 질문과 답안 역시 대략 6년전에 올라왔던 것이고, 그때는 Retrofit과 okHttp3 버전이 없었을(?)것 이기 때문에 코드 자체가 레거시한 것이 문제였다.

나는 이 코드를 구글 권장 앱 아키텍처에 맞는 코드로 리팩토링하여 사용하기로 하였다.

OKHttp3 형태로 바꿔 body에 넣을 parameter들을 위에 코드의 방법처럼 직접 add 하는 방식도 안되는 것은 아니지만 Retrofit 공식 문서에서도 body는 Service interface 에 @Body 형태로 선언하는 것을 권장하기 때문에 전면 수정하였다.

코드 개선 결과

Architecture Pattern: Google recommend architecture

NetworkModule.kt

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
        return HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT)
            .apply {
                level = if (BuildConfig.DEBUG) {
                    HttpLoggingInterceptor.Level.BODY
                } else {
                    HttpLoggingInterceptor.Level.NONE
                }
            }
    }

    @Provides
    fun provideOkHttpClient(
        httpLoggingInterceptor: HttpLoggingInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(httpLoggingInterceptor)
            .connectTimeout(CONNECT_TIME_OUT, TimeUnit.SECONDS)
            .readTimeout(READ_TIME_OUT, TimeUnit.SECONDS)
            .writeTimeout(WRITE_TIME_OUT, TimeUnit.SECONDS)
            .build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
    ): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .client(okHttpClient)
            .baseUrl(BuildConfig.GOOGLE_BASE_URL)
            .build()
    }

    @Singleton
    @Provides
    fun provideLoginService(retrofit: Retrofit): LoginService {
        return retrofit.create(LoginService::class.java)
    }

LoginService

interface LoginService {

	/*구글 서버에 IdToken을 포함하는 Request를 Body에 담아 보냄*/	
    @POST("oauth2/v4/token")
    suspend fun fetchGoogleAuthInfo(
        @Body request: LoginGoogleRequest
    ): Response<LoginGoogleResponse>?
    
}

LoginRepository

interface LoginRepository {
    suspend fun fetchGoogleAuthInfo(authCode: String): Result<LoginGoogleResponse>
}

LoginRepositoryImpl

class LoginRepositoryImpl @Inject constructor(
    private val preferences: Preferences,
    private val service: LoginService
) : LoginRepository {

    override suspend fun fetchGoogleAuthInfo(
        authCode: String
    ): Result<LoginGoogleResponse> {
        service.fetchGoogleAuthInfo(
            LoginGoogleRequest(
                grant_type = GRANT_TYPE,
                client_id = BuildConfig.GOOGLE_CLIENT_ID,
                client_secret = BuildConfig.GOOGLE_CLIENT_SECRET,
                redirect_uri = DEFAULT_STRING_VALUE,
                code = authCode
            )
        )?.run {
            return Result.Success(this.body() ?: LoginGoogleResponse())
        } ?: return Result.Error(Exception("Retrofit Exception"))
    }
}

LoginGoogleRequest

data class LoginGoogleRequest (
    @SerializedName("grant_type")
    private val grant_type: String,
    @SerializedName("client_id")
    private val client_id: String,
    @SerializedName("client_secret")
    private val client_secret: String,
    @SerializedName("redirect_uri")
    private val redirect_uri: String,
    @SerializedName("code")
    private val code: String
)

LoginGoogleResponse

/**
 *   LoginGoogleResponse
 *
 *  "access_token": "string",
 *  "expires_in": 0,
 *  "scope": "string",
 *  "token_type": "string"
 *  "id_token": "string"
 */

data class LoginGoogleResponse(
    var access_token: String = "",
    var expires_in: Int = 0,
    var scope: String = "",
    var token_type: String = "",
    var id_token: String = "",
)

Result

sealed class Result<out T> {
    object Loading : Result<Nothing>()
    object UnLoading : Result<Nothing>()
    data class Success<T>(val data: T) : Result<T>()
    data class Unauthorized(val throwable: Throwable) : Result<Nothing>()
    data class Error(val throwable: Throwable) : Result<Nothing>()
}

LoginViewModel

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
) : ViewModel() {
   
    suspend fun fetchGoogleAuthInfo(authCode: String) =
        withContext(viewModelScope.coroutineContext) {
            loginRepository.fetchGoogleAuthInfo(authCode = authCode)
        }
}

Activity or Fragment (현재 로그인 버튼이 BottomSheetFragment에 존재하므로 Fragment)

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentLoginBottomBinding.inflate(inflater, container, false)
        binding = _binding!!

        val googleClientId = BuildConfig.GOOGLE_CLIENT_ID
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestScopes(Scope(Scopes.DRIVE_APPFOLDER))
            .requestIdToken(googleClientId)
            .requestServerAuthCode(googleClientId)
            .requestEmail()
            .build()

        loginLauncher = registerForActivityResult(
            ActivityResultContracts.StartActivityForResult()
        )
        { result ->
            if (result.resultCode == Activity.RESULT_OK) {
                val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
                handleSignInResult(task)
            }
        }

        mGoogleSignInClient = GoogleSignIn.getClient(requireActivity(), gso)

        binding.clLoginButtonGoogle.setOnClickListener { view ->
            when (view.id) {
                R.id.cl_login_button_google -> loginWithGoogle()
            }
        }

        return binding.root
    }

 private fun loginWithGoogle() {
        val signInIntent: Intent = mGoogleSignInClient.signInIntent
        loginLauncher.launch(signInIntent)
    }

    private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
        try {
            val authCode = completedTask.getResult(ApiException::class.java)?.serverAuthCode
            // 코루틴 스코프 
            mainScope {
                authCode?.run {
                    var accessToken = "userToken"
                    loginViewModel.fetchGoogleAuthInfo(this).let { result ->
                        when (result) {
                            is Result.Success<LoginGoogleResponse> -> {
                                Timber.tag("Success").d("${result.data}")
                                accessToken = result.data.accessToken
                            }
                            is Result.Error -> {
                                Timber.tag("Error").d(result.throwable)
                                loginListener.onError()
                            }
                            else -> {}
                        }
                    }
                } ?: Timber.tag("구글 서버 인증 실패").e("Authentication failed")
            }
        } catch (e: ApiException) {
            // The ApiException status code indicates the detailed failure reason.
            // Please refer to the GoogleSignInStatusCodes class reference for more information.
            Timber.tag("로그인 실패").e("signInResult:failed code=%s", e.statusCode)
        }
    }

공부 방향

1) AccessToken만 던져줘도 성공적으로 Reponse를 받아왔는데 그럼 RefreshToken의 존재이유는 무엇인지
-> accessToken 만료시 유효기간이 더 긴 refreshToken을 통해 api통신, accessToken 재발급을 위한 용도

2) 앱 사용중에 accessToken이 만료되면 어떻게 되는지
-> refreshToken으로 api통신, refreshToken을 통한 api 호출 재시도, refresh토큰까지 만료된 경우 로그인 만료 -> 로그인 페이지로 돌아감
authenticator를 이용한 재시도 참고글

참고로 백엔드단의 정책으로 accessToken의 만료시간이 얼마남지않은 경우 api 호출시 그 시간을 계산해 토큰을 refresh(갱신 시간 늘림)해준다고 한다

3) 백엔드서버에 accessToken 및 refreshToken을 던져줬는데 다시 돌아오는 건 같음, JwtToken(accessToken, refreshToken) -> 백엔드 서버에선 어떤식으로 토큰를 검증하는지 -> 백엔드에서 하는 일(?)이라 몰루 ㅎㅎ;;

4) repository 내에서 preference 를 호출해서 UI layer 에서 preference 객체를 생성하지 않는 방향으로 수정 (참고: https://blog.gangnamunni.com/post/mvvm_anti_pattern/) -> 해결
Activity / Fragment 단 코드 추가 개선 여부
ㄴ현재는 로그인을 수행하는 로직 자체를 뷰에서 하고 있으므로 이를 뷰모델에서 하는 방향으로 수정

등등 추가적으로 공부해봐야겠다.

근데 왜 구글은 IdToken 을 이용하여 AccessToken을 가져오는 방법의 대한 문서를 제공해주지 않는걸까? 쉽게 구현할 방법이 새로운 문서에 나와있는 걸까? 사실 이게 제일 궁금하다. 구글 일해라

적용한 프로젝트: https://github.com/depromeet/sloth-android
(리팩토링을 진행해서 본문의 코드와 다를 수 있습니다.)

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

5개의 댓글

comment-user-thumbnail
2022년 8월 7일

안녕하세요! 혹시 실례가 아니라면 ServiceGenetor 클래스 코드도 올려주실 수 있을까요? 이 부분이 없어서 공부하다가 막혔습니다 ㅠㅠ

2개의 답글
comment-user-thumbnail
2023년 4월 20일

안녕하세요 혹시 redirect_uri를 어떻게 설정하셨는지 알 수 있을까요?

1개의 답글