본문 바로가기

안드로이드/라이브러리 써보기

[안드로이드] Github OAuth를 이용한 로그인 로그아웃 구현해보기

오늘은 Github RestAPI를 이용한 인증 및 로그인/로그아웃을 구현해 보도록 하자.

 

- Github OAuth App 생성해주기

가장 먼저 Github에 들어가서 OAuth App을 생성해줘야 한다. 해당 OAuth App을 생성해주려면, 아래 경로로 들어가주면 된다.

 

* Github 홈페이지 >> Settings >> Developer Settings

https://github.com/settings/developers

 

GitHub: Where the world builds software

GitHub is where over 73 million developers shape the future of software, together. Contribute to the open source community, manage your Git repositories, review code like a pro, track bugs and feat...

github.com

 

해당 페이지에 이제 새로운 앱 생성하기를 해주면 아래 사진과 같이 새로운 앱에 대한 설명을 적어달라고 한다.

 

각 부분을 간단하게 설명해주면 아래와 같다.

  • Application name : 안드로이드 프로젝트 이름 맨 뒷자리 넣어주면 편하다. (ex. kr.uni.test면 test를 넣어준다.)
  • Homepage URL : 개인 홈페이지가 대부분 없으니까 깃허브 페이지 넣어줘도 된다.
  • Authorization callback URL : 인증후에 돌아갈 callback URL이다. 이 부분을 적어줄 때 나는 'test://github-auth'라고 적어 넣어줬다. 나중에 해당 callback을 AndroidManifest에 인텐트 필터에 추가하긴 할건데, 좀 더 설명하자면 test라고 적은 부분은 data의 scheme이고 github-auth는 host에 해당한다. 어쨌든 그냥 '앱이름://github-auth' 라고 적어주면 편하다.

 

이렇게 다 만들고 나면 Client ID와 Client secrets을 얻을 수 있다. 이 두 값을 잘 복사해서 메모장에 붙여넣기 해 두자.

 

- 이제 AndroidManifest를 설정해주자.

이제 사용할 준비는 다 끝났다 실질적 사용을 위하여 설정을 해주자. 넣어줘야 하는 것은 2가지로, 첫 번째는 인터넷 사용 설정과 두 번째는 실질적으로 github oauth 인증을 구동할 activity에 intent-filter를 넣어줘서 인증이 완료되고 나면, 돌아올 callback 값을 넣어 준 것이다. 이때 host와 scheme은 일전에 github OAuth App 생성할 때 넣었던 callback 부분을 참고해서 넣어주면 된다.

 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="패키지이름">

	//인터넷 사용 설정해주기
    <uses-permission android:name="android.permission.INTERNET" />
    
    <application
        ...
        <activity
            android:name="kr.uni.test.ui.activity.main.ui.MainActivity"
            android:launchMode="singleTask"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

			//Github 인증 후 앱으로 돌아올 callback 넣어주기
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data
                    android:host="github-auth"
                    android:scheme="test"/>
            </intent-filter>

        </activity>

    </application>

</manifest>

 

- 다음은 Project Properties에 Client id, secret을 넣어주자.

 

일전에 메모장에 적어둔 Client id와 secret을 프로젝트 수준의 gradle.properties에 넣어두자. 그러면 BuildConfig로 간편하게 해당 값들을 불러올 수 있게 된다.

 

# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

GITHUB_CLIENT_ID="Client id 값"
GITHUB_CLIENT_SECRET="Client Secret 값"


- 인증하기

준비는 다 마쳤으니 실질적으로 인증을 진행해보자.

 

먼저 intent를 통하여 브라우저에서 github를 로그인하고 callback으로 돌아와서 인증 성공 여부를 알아낸다. 이때 Uri를 이용하여 Url을 build하고, path와 query 파라미터 구성요소를 아래와 같이 넣어준다.

 

callback을 통하여 인증의 실패 여부와 상관없이 앱으로 돌아오면 onNewIntent로 결과가 들어오게 된다. 그래서 QueryParameter를 통해 code를 받아와 성공여부를 체크 한 다음, code를 이용하여 Github RestAPI에 Access Token을 가져온다.

    // activity Context로 진행
    fun login(context: Context) {
        val loginUrl = Uri.Builder().scheme("https").authority("github.com")
            .appendPath("login")
            .appendPath("oauth")
            .appendPath("authorize")
            .appendQueryParameter("client_id", BuildConfig.GITHUB_CLIENT_ID)
            .build()

        CustomTabsIntent.Builder().build().also {
            it.launchUrl(context, loginUrl)
        }
    }
    
    
    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        // ViewModel에서 로그인한거 받아서 처리
        intent?.data?.getQueryParameter("code")?.let {
        
            // 엑세스 토큰 받아와야함
            launch(coroutineContext) {
                viewModel.getAccessToken(it)
                Toast.makeText(this@MainActivity, "로그인 되었습니다.", Toast.LENGTH_SHORT).show()
            }
        }
    }

 

- Access Token 가져오기

 

Github OAuth 인증을 통하여 성공한다면 Github에서는 파라미터 값에 code를 넣어준다. 그리고 이 코드를 이용하여 Access Token을 불러올 수 있다.

 

이 포스팅에서는 Retrofit2 + hilt + coroutine을 이용하여 가져올 것이다. 이때 AppModule에서 Access를 위한 URL과 Api 호출을 위한 URL이 달라서 @Qulifer 어노테이션을 이용하여 클라이언트를 2개 만들어서 사용했다. 여기서 사용할 어노테이션은 typeAccess 어노테이션이다.

 

- AppModule

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

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class typeAccess

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class typeApi

    @Provides
    fun provideGithubUrl() = Constants.getGithubUrl()

    @Provides
    fun provideGithubApiUrl() = Constants.getGithubApiUrl()

    @Singleton
    @Provides
    @typeAccess
    fun provideAccessOkHttpClient() = if (BuildConfig.DEBUG) {
        // Debug에서는 로깅
        val loggingInterceptor = HttpLoggingInterceptor()
        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
        OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .build()
    } else {
        OkHttpClient.Builder().build()
    }

    @Singleton
    @Provides
    @typeApi
    fun provideOkHttpClient() = if (BuildConfig.DEBUG) {
        // Debug에서는 로깅
        val loggingInterceptor = HttpLoggingInterceptor()
        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
        OkHttpClient.Builder()
            .addInterceptor(interceptor)
            .build()
    } else {
        OkHttpClient.Builder().build()
    }

    private val interceptor = Interceptor { chain ->
        val token:String? = LoginController.getAccessToken()
        val req = chain.request()
            .newBuilder()
            .addHeader("Authorization", "token $token")
            .build()
        chain.proceed(req)
    }

    @Singleton
    @Provides
    @typeAccess
    fun provideAccessRetrofit(@typeAccess okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl(provideGithubUrl())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }


    @Provides
    @Singleton
    @typeAccess
    fun provideAccessService(@typeAccess retrofit: Retrofit): AccessService {
        return retrofit.create(AccessService::class.java)
    }

    @Singleton
    @Provides
    @typeApi
    fun provideApiRetrofit(@typeApi okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl(provideGithubApiUrl())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    @typeApi
    fun provideApiService(@typeApi retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }

    @Singleton
    @Provides
    @typeAccess
    fun provideAccessRepository(@typeAccess accessService: AccessService)= AccessRepository(accessService)

    @Singleton
    @Provides
    @typeApi
    fun provideApiRepository(@typeApi apiService: ApiService)= ApiRepository(apiService)


}

 

- AccessService

interface AccessService {

    @FormUrlEncoded
    @POST("login/oauth/access_token")
    @Headers("Accept: application/json")
    suspend fun getAccessToken(
        @Field("client_id") clientId: String,
        @Field("client_secret") clientSecret: String,
        @Field("code") code: String
    ): Response<GithubAccessTokenResponse>
}

 

- AccessRepository.kt

class AccessRepository @Inject constructor(
@AppModule.typeAccess private val accessService: AccessService) {

    suspend fun getAccessToken(
    clientId: String, 
    clientSecret: String, code: String) 
    = accessService.getAccessToken(clientId, clientSecret, code)
}

 

-MainViewModel.kt

 

아래 코드는 ViewModel에 넣어주었다. 먼저 설명을 하자면, Api Call을 만들 때 suspend로 만들어 주었기 때문에, 여기서도 suspend로 진행했다. 그리고 coroutine을 사용하여 순차적으로 작업을 진행하게 해 주었다.

    suspend fun getAccessToken(code: String) =
        withContext(Dispatchers.IO) {
            val response = accessRepository.getAccessToken(
                clientId = BuildConfig.GITHUB_CLIENT_ID,
                clientSecret = BuildConfig.GITHUB_CLIENT_SECRET,
                code = code
            )

            if (response.isSuccessful) {
                val accessToken = response.body()?.accessToken
                LoginController.isLogin.postValue(true)
                LoginController.accessToken.postValue(accessToken)
            }
        }

 

- 결과 확인

 

나는 로그인 버튼을 누르면 아래와 같이 작동하게 flow를 짰다. 해당 flow는 정상적으로 작동되었으며, Access Token을 가져오는 것 또한 확인했다.

  • 버튼을 누른다.
  • login 메소드를 호출해 인증을 진행한다.
  • 인증이 끝난 후 onNewIntent 메소드를 타면서 인증 성공 여부를 체크한다.
  • 인증이 성공했으면 ViewModel에 있는 getAccessToken 메소드를 호출한다.
  • 이때 getAccessToken은 Github에 Access Token을 요청하는 Api를 호출한다.

 

2021-12-20 22:17:30.232 27830-8043/kr.uni.test E/Login: access soccuess. token value : 토큰값은 숨기겠습니다.

 

 

솔직하게 Github API와 관련된 포스팅이 정말 드릅게 없다.

 

공식문서 보면서 악으로 깡으로 버티면서 진행했다.

 

그래서 혹시라도 도움이 되고자 포스팅을 했다. 혹시라도 인증에 관련된 공식문서를 찾는다면 아래 링크를 참고해보길 바란다.

 

https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps

 

Authorizing OAuth Apps - GitHub Docs

GitHub's OAuth implementation supports the standard authorization code grant type and the OAuth 2.0 Device Authorization Grant for apps that don't have access to a web browser. If you want to skip authorizing your app in the standard way, such as when test

docs.github.com