[Kotlin][Firebase] Kakao Login 구현 #3

2021. 8. 24. 16:46
728x90

 

*제가 공부한걸 기록하는 것이 목적이므로

중간에 빼먹은 프로세스가 있을 수 있으니 다른 블로그도 참고하세요.

 

드디어 카카오 로그인 마지막 포스팅이다!

이번에는 코틀린을 사용해서 클라이언트가 서버로부터 access token, custom token을 받아오고 다시 전달해서 사용자 인증 및 로그인 진행하는 방법을 알아보도록 하겠다.

 

전체적인 카카오 로그인 프로세스를 먼저 살펴보자.

 

<서버 (app.js)>

1. node 서버 돌리기 (git bash창에서 $ node app.js  입력)

*나중에 종료할 때는 ctrl+c 를 누르면 된다

 

<안드로이드 스튜디오 (앱)>

2. LoginActivity.kt (로그인 버튼 클릭 시 kakaoLoginStart() 호출)

3. SessionCallback.kt (onSessionOpened() > onSuccess() - accessToken 획득, accessToken을 getFirebaseJwt() 함수 파라미터로 전달)

4. LoginActivity.kt (getFirebaseJwt() - API서버로 accessToken 전달(HTTP post 방식))

 

<서버 (app.js)> 

*받아온 사용자 정보, 토큰 인증 진행상황 등은 git bash에서 확인하면 됨

5. app.post() - accesstoken이 유효하면 custom token 생성

6. createFirebaseToken() - requestMe() 호출

7. requestMe() - Kakao API 로부터 사용자 정보를 json 형태로 받아옴 (HTTP get 방식), 

8. updateOrCreateUser() - 파이어베이스에 이미 등록된 사용자면 update, 아니면 create user

9. 등록되었거나 새로 생성된 user의 id (uid)를 사용해서 custom token 생성

10. app.post() - res.send(생성된 custom token을 client에게 전달)

 

<안드로이드 스튜디오 (앱)>

11. LoginActivity.kt (getFirebaseJwt() - listener가 custom token 받아와서 인증 서버 호출, custom token을 회수하여 세션에 전달)

12. SessionCallback.kt (FirebaseAuth.getInstance().signInWithCustomToken() - custom token 받아서 로그인 처리 완료)

13. SessionCallback.kt (LoginActivity의 startMainActivity() 호출)

14. LoginActivity.kt (startMainActivity() - MainActivity로 화면 전환, LoginActivity 파괴)

 

 

이제 진짜로 시작하기 전에 꿀팁을 알려주자면 :

/* build.gradle(Module:app) 파일 */

android{
    buildTypes {
        release {
            ...
        }
        // 앱 실행 시 디버그 가능
        customDebugType {
            debuggable true
        }
    }

    ...
    
    // 뷰바인딩 사용 (코드가 간결해지고, item id가 매칭되지 않는 위험을 줄임)
    buildFeatures{
        viewBinding true
    }
}

 

 


 

이제 소스코드를 살펴보도록 하겠다. 혹시 몰라서 부연 설명을 소스코드 전후로 달았으니 참고하면서 읽어보자.

 

1. build.gradle(Module:app)

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'com.google.gms.google-services'
}

.
.
.

dependencies {

	...
    
    // 카카오 SDK 모듈 설정
    implementation "com.kakao.sdk:v2-user:2.5.2" // 카카오 로그인
    implementation group: 'com.kakao.sdk', name: 'usermgmt', version: '1.27.0'

    // Import the BoM for the Firebase platform
    implementation platform('com.google.firebase:firebase-bom:28.2.1')
    implementation 'com.google.firebase:firebase-database-ktx' // Realtime Database library
    implementation 'com.google.firebase:firebase-auth-ktx' // Authentication library
    implementation 'com.google.firebase:firebase-core:19.0.0'
    implementation "com.firebaseui:firebase-ui-auth:7.2.0" // gradle_firebase_ui_auth
    implementation 'com.google.android.gms:play-services-auth:19.2.0' // Google Play services library
    
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    implementation "org.jetbrains.kotlin:kotlin-reflect:1.5.20"

    implementation 'com.android.volley:volley:1.2.0'
    
}

 


 

2. build.gradle(Project)

allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
    }
}

 


 

3. string.xml

  • app>res>values>strings.xml
  • 카카오 개발자 홈페이지 > 내 애플리케이션 > "앱 키" 중에서 네이티브 앱 키를 복사해서 ****** 부분에 저장
<resources>
	...
    
    <!-- 네이티브 앱 키 (예시) -->
    <string name="kakao_app_key">******</string>
    
</resources>

 


 

4. AndroidManifest.xml

  • 인터넷 권한 설정 해주기
  • <application> 안에 android:name=".App" (본인이 4번에서 지은 class명을 여기에 쓰기 ".클래스명" (클래스명 앞에 마침표 잊지말기!)
  • 카카오 SDK App Key, AuthCodeHandlerActivity 액티비티까지 추가해주기
  • AuthCodeHandlerActivity 안에 : 만약 네이티브 앱 키 값이 12345 라면, kakao12345 라고 쓰기
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.tripplanner">
    
    <!-- 인터넷 권한 설정 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:name=".App"
        android:allowBackup="true"
        android:fullBackupContent="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar"
        android:usesCleartextTraffic="true">
        
        ...

        <meta-data
            android:name="com.kakao.sdk.AppKey"
            android:value="@string/kakao_app_key" />

        <activity android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <!-- Redirect URI: "kakao{NATIVE_APP_KEY}://oauth" -->
                <data android:host="oauth"
                    android:scheme="kakao{NATIVE_APP_KEY}" />
            </intent-filter>
        </activity>

    </application>

</manifest>

 


 

5. App.kt (GlobalApplication)

  • 전역으로 사용 가능한 context (GlobalApplication)를 singleton을 활용하여 정의
  • onCreate() - 카카오 SDK 초기화

 

package com.example.tripplanner

import android.app.Application
import com.kakao.auth.KakaoSDK

class App : Application() {
    companion object{
        var instance : App? = null
    }

    override fun onCreate() {
        super.onCreate()
        instance = this
        if(KakaoSDK.getAdapter() == null){
            KakaoSDK.init(KakaoSDKAdapter(getAppContext()))
        }
    }

    override fun onTerminate() {
        super.onTerminate()
        instance = null
    }
    fun getAppContext() : App{
        checkNotNull(instance){
            "This Application does not inherit com.example.App"
        }
        return instance!!
    }
}

 

 


 

6. KakaoSDKAdapter.kt

  • Auth Type:
    • KAKAO_TALK  : 카카오톡 로그인 타입
    • KAKAO_STORY : 카카오스토리 로그인 타입
    • KAKAO_ACCOUNT : 웹뷰 다이얼로그를 통한 계정연결 타입
    • KAKAO_TALK_EXCLUDE_NATIVE_LOGIN : 카카오톡 로그인 타입과 함께 계정생성을 위한 버튼을 함께 제공
    • KAKAO_LOGIN_ALL : 모든 로그인 방식을 제공

 

package com.example.tripplanner

import com.kakao.auth.*

class KakaoSDKAdapter(context: App) : KakaoAdapter(){
    override fun getApplicationConfig(): IApplicationConfig {
        return IApplicationConfig {
            App.instance?.getAppContext()
        }
    }

    override fun getSessionConfig() : ISessionConfig {
        return object : ISessionConfig{
            override fun getAuthTypes(): Array<AuthType> {
                return arrayOf(AuthType.KAKAO_LOGIN_ALL)
            }

            override fun isUsingWebviewTimer(): Boolean {
                return false
            }

            override fun isSecureMode(): Boolean {
                return true
            }

            override fun getApprovalType(): ApprovalType {
                return ApprovalType.INDIVIDUAL
            }

            override fun isSaveFormData(): Boolean {
                return true
            }

        }
    }
}

 


 

7. SessionCallback.kt

  • LoginActivity.kt - getFirebaseJwt() 함수에 kakao access token 전달하고, firebase custom token까지 잘 받아왔다면 continueWithTask 진행
  • task 객체 : 사용자 인증하는 서버 호출 및 custom token을 받아옴
  • signInWithCustomToken() : custom token을 파이어베이스에 전달하여 최종적으로 로그인 처리
  • onSessionOpenFailed() : session 연결 실패 시 LoginActivity 로 이동

 

package com.example.tripplanner

import android.util.Log
import android.widget.Toast
import com.google.firebase.auth.FirebaseAuth
import com.kakao.auth.ISessionCallback
import com.kakao.auth.Session
import com.kakao.network.ErrorResult
import com.kakao.usermgmt.UserManagement
import com.kakao.usermgmt.callback.MeV2ResponseCallback
import com.kakao.usermgmt.response.MeV2Response
import com.kakao.util.exception.KakaoException


class SessionCallback(val context : LoginActivity): ISessionCallback {
    private val TAG : String = "로그/SessionCallback"

    override fun onSessionOpened() {
        Toast.makeText(App.instance, "Successfully logged in to Kakao. 
        			Now creating or updating a Firebase User.", Toast.LENGTH_LONG).show()

        UserManagement.getInstance().me(object : MeV2ResponseCallback(){
            override fun onSuccess(result: MeV2Response?) {
                if(result != null){
                    Log.d(TAG, "세션 오픈")
                    val accessToken = Session.getCurrentSession().tokenInfo.accessToken

                    context.getFirebaseJwt(accessToken).continueWithTask { task ->
                        val firebaseToken = task.result
                        val auth = FirebaseAuth.getInstance()
                        auth.signInWithCustomToken(firebaseToken!!)
                    }.addOnCompleteListener { task ->
                        if (task.isSuccessful) {
                            Log.d(TAG, "Successfully created a Firebase user")
                            context.startMainActivity()
                        }
                        else {
                            Toast.makeText(App.instance,"Failed to create a Firebase user.", 
                            				Toast.LENGTH_LONG).show()
                            if (task.exception != null) {
                                Log.e(TAG, task.exception.toString())
                            }
                            context.updateUI()
                        }
                    }
                }
            }

            override fun onSessionClosed(errorResult: ErrorResult?) {
                Log.e(TAG, "세션 종료")
            }

            override fun onFailure(errorResult: ErrorResult?) {
                val errorCode = errorResult?.errorCode
                val clientErrorCode = -777

                if(errorCode == clientErrorCode){
                    Log.e(TAG, "카카오톡 서버의 네트워크가 불안정합니다. 잠시 후 다시 시도해주세요.")
                }else{
                    Log.e(TAG, "알 수 없는 오류로 카카오로그인 실패 \n${errorResult?.errorMessage}")
                }

            }

        })
    }

    override fun onSessionOpenFailed(exception: KakaoException?) {
        Log.e(TAG, "onSessionOpenFailed ${exception?.message}")
        context.onStart() // session 연결 실패 시 LoginActivity 로 이동
    }

}

 

 

 


 

8. LoginActivity.kt

*설명은 밑에 있음

 

package com.example.tripplanner

import android.content.Intent
import android.os.Bundle
import android.util.Base64
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.android.volley.Request
import com.android.volley.Response
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.example.tripplanner.databinding.ActivityLoginBinding
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
import com.kakao.auth.AuthType
import com.kakao.auth.Session
import com.kakao.sdk.common.util.Utility
import org.json.JSONObject

open class LoginActivity : AppCompatActivity() {

    private var _binding : ActivityLoginBinding? = null
    private val binding get() = _binding!!
    private val TAG: String = "로그"
    
    // 로그인 공통 callback (login 결과를 SessionCallback.kt 으로 전송)
    private lateinit var callback : SessionCallback
    private lateinit var fbAuth : FirebaseAuth // Firebase Auth

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        Log.d(TAG, "LoginActivity - onCreate() called")

        fbAuth = Firebase.auth // Initialize Firebase Auth
        callback = SessionCallback(this) // Initialize Session

        binding.btnKakaoLogin.setOnClickListener {
            kakaoLoginStart()
        }
        binding.btnStart.setOnClickListener {
            startMainActivity()
        }
    }

    public override fun onStart() {
        super.onStart()
        Log.d(TAG, "LoginActivity - onStart() called")
        updateUI()
    }

    fun updateUI() {
        Log.d(TAG, "LoginActivity - updateUI() called")
        val user = fbAuth.currentUser
        if(user != null) { // UI ver.1
            binding.btnStart.visibility = View.VISIBLE
            binding.btnKakaoLogin.visibility = View.GONE
        } else{ // UI ver.2
            binding.btnStart.visibility = View.GONE
            binding.btnKakaoLogin.visibility = View.VISIBLE
        }
    }


    /* KAKAO LOGIN */
    private fun kakaoLoginStart(){
        Log.d(TAG, "LoginActivity - kakaoLoginStart() called")
        
        val keyHash = Utility.getKeyHash(this) // keyHash 발급
        Log.d(TAG, "KEY_HASH : $keyHash")

        Session.getCurrentSession().addCallback(callback) 
        Session.getCurrentSession().open(AuthType.KAKAO_LOGIN_ALL, this)
    }

    open fun getFirebaseJwt(kakaoAccessToken: String): Task<String> {
        Log.d(TAG, "LoginActivity - getFirebaseJwt() called")
        val source = TaskCompletionSource<String>()
        val queue = Volley.newRequestQueue(this)
        val url = "http://IP주소:8000/verifyToken" // validation server
        val validationObject: HashMap<String?, String?> = HashMap()
        validationObject["token"] = kakaoAccessToken

        val request: JsonObjectRequest = object : JsonObjectRequest(
            Request.Method.POST, url,
            JSONObject(validationObject as Map<*, *>),
            Response.Listener { response ->
                try {
                    val firebaseToken = response.getString("firebase_token")
                    source.setResult(firebaseToken)
                } catch (e: Exception) {
                    source.setException(e)
                }
            },
            Response.ErrorListener { error ->
                Log.e(TAG, error.toString())
                source.setException(error)
            }) {
            override fun getParams(): Map<String, String> {
                val params: MutableMap<String, String> = HashMap() 
                params["Authorization"] = String.format("Basic %s", Base64.encodeToString(
                    String.format("%s:%s", "token", kakaoAccessToken)
                    	.toByteArray(), Base64.DEFAULT)
                )
                return params
            }
        }
        queue.add(request)
        return source.task // call validation server and retrieve firebase token
    }

    // Login Result
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        Log.d(TAG, "LoginActivity - onActivityResult() called")

        if(Session.getCurrentSession().handleActivityResult(requestCode, resultCode, data)){
            Log.i(TAG, "Session get current session")
            return
        }
        super.onActivityResult(requestCode, resultCode, data)
    }

    fun startMainActivity(){
        Log.d(TAG, "LoginActivity - startMainActivity() called")

        val intent = Intent(this, MainActivity::class.java)
        startActivity(intent)
        finish()
    }


    override fun onDestroy() {
        super.onDestroy()
        Session.getCurrentSession().removeCallback(callback)
    }
}

 

*kakaoLoginStart() : 로그에서 해시키를 확인 -> 해시키 등록을 앱 실행 전에 미리 해주어야 kakao api로부터 토큰 받아오고 서버로 전달하는 데에 문제 생기지 않음!

 

onCreate()

  • firebase auth 초기화
  • 세션 초기화
  • 버튼 클릭 시 실행될 함수

 

updateUI()

  • firebase current user가 있는지 확인
  • UI ver.1 : 이미 파이어베이스에 등록된 사용자라면 시작하기 버튼 하나만 구성
  • UI ver.2 : 등록되지 않은 사용자라면 카카오로 로그인하기 버튼만 화면에 보임

 

kakaoLoginStart()

  • 해시키 발급 - kakao developers 홈페이지 > 내 애플리케이션 > 플랫폼 > Android 키 해시란에 등록
  •  
  • addCallback() - 세션 상태 변화 콜백을 받고자 할때 콜백을 등록
  • open() - 세션 오픈을 진행
  • Session : login 상태를 유지시켜주는 객체 (accessToken을 관리함)
  • authType - 인증받을 타입; callerActivity - 세션오픈을 호출한 activity

 

getFirebaseJwt()

  • 로컬 url 지정 : 해당 url을 통해서 카카오 access token 인증을 진행하게 됨
  • 카카오 깃허브에서 로컬 url 지정해주는 부분을 resources.getString(R.string.validation_server_domain) 이라고 해주었는데, 난 그냥  "http://IP주소:포트번호/verifyToken" 라고 작성함 ("http://localhost:포트번호/verifyToken" 도 가능)
  • 포트번호 8000으로 지정해준 건 이전 포스트 #2에 app.js 파일 제일 하단에 보면 나와있음
const server = app.listen(process.env.PORT || '8000', () => {
  console.log('KakaoLoginServer for Firebase listening on port %s',
  server.address().port);
});
  • request 변수 : HTTP의 post 방식을 사용해서 사용자 정보(JSON object)를 요청함. 그리고 API 서버로부터 firebase custom token 값을 받아오는 역할까지 수행.
  • return source.task : 인증 서버 호출 및 custom token 받음

*참고사항#1

request 의 response.listener{...} 부분에서 firebase token을 못 받아오고 Cannot find local variable 'request' 또는 Unresolved reference : request 라고 뜨는 오류가 생겼다.

 

해결책 : 요청 보낼 api 서버 주소를 잘못 기입하여 발생한 문제

[app.js]  const requestMeUrl = 'https://kapi.kakao.com/v2/user/me?secure_resource=true';
Kakao API request url : 서버단에서 access token을 사용해 카카오 계정 사용자 프로필을 요청하는 카카오 api url

 

[LoginActivity.kt]  val url = "http://IP주소:8000/verifyToken"

url : 토큰 인증을 위해 사용되는 로컬 url

 

처음에 kakao api request url이 똑같은거라 생각해서 LoginActivity.kt 파일의 url 변수에 requestMeUrl과 같은 문자열을 지정해주었다. url 변수에 로컬 경로 문자열로 바꿔주었다. 그리고 방화벽 8000포트도 열어주고나서 어플리케이션을 실행시켜보니 성공적으로 작동했다.

 

*참고사항#2

참고사항 #1 오류가 해결되지 않았을 때, 디버깅으로 한 줄 씩 천천히 분석하다가 발견한 부분이다.

JsonObjectRequest를 살펴보자.

  1. HTTP method to use : POST
  2. url to fetch JSON from : API Server (로컬)
  3. jsonRequest : 사용자의 정보 (JSON형태) 요청함
    -> json object to post with request ("Null" == no parameters to post with request)
    -> request 값을 살펴보니 아래와 같이 나왔다.
    secure_resource=true/verifyToken 0x38f09477 NORMAL null
    여기 request 값 맨 끝에 "null" 의 이미는 no parameters sent along with the request 라는 뜻이다.
  4. listener to receive JSON response
    -> listener : kakao access token 값을 사용해서 사용자 정보를 JSON 형태로 받아오는 역할을 함
  5. Error Listener
    -> null to ignore errors

 

onActivityResult()

  • handleActivityResult() - 카카오 로그인 결과 처리, boolean 값을 반환함
  • 반환값 true = Kakao 로그인창에서 화면전환(intent) 된 경우; false = 그 외
  • Kakao login 창으로부터 결과를 세션이 받아서 다음 처리를 할 수 있도록 onActivityResult에서 해당 method를 호출. 그리고 sdk에서 필요로 하는 activity (LoginActivity)를 띄움. 


startMainActivity()

  • intent : 토큰 인증을 마치고 로그인 성공하면 MainActivity 로 화면 전환
  • finish() : MainActivity로 바뀌면 LoginActivity를 파괴시켜서 다시 켜지지 않도록 함. 즉, MainActivity 화면에서 뒤로가기 누르면 Login 화면이 뜨지 않고 바로 앱이 종료됨

 

onDestroy()

  • removeCallback() : 현재 activity (LoginActivity) 제거 시 콜백도 같이 제거함
  • 부연 설명 - 네이버, 구글 등의 다른 로그인 API를 같이 사용하는 경우, 이 콜백 제거를 안 해주면 로그아웃 작업에서 문제 생김

 


 

9. MainActivity.kt

  • navigation view를 inflate 해주어야 접근 가능
  • navigation 헤더의 item (카카오 계정 사용자 닉네임, 이메일, 프로필 사진)에 데이터값 전달

 

class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {

    private var mBinding : ActivityMainBinding? = null
    private val binding get() = mBinding!!
    private val TAG : String = "로그"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        Log.d(TAG, "MainActivity - onCreate() called")

        setProfile()
    }

    @SuppressLint("ResourceType")
    private fun setProfile() {
        try{
            val user = Firebase.auth.currentUser
            user?.let {
                val header : View = binding.naviView.getHeaderView(0)
                val uName = header.findViewById<TextView>(R.id.text_userName)
                val uEmail = header.findViewById<TextView>(R.id.text_userEmail)
                val uPhoto = header.findViewById<ImageView>(R.id.profilepic)

                uName.text = user.displayName
                uEmail.text = user.email
                uPhoto.setImageURI(user.photoUrl)
            }
        }
        catch(e: NullPointerException){
            Log.d(TAG, "NullPointerException", e)
            Toast.makeText(App.instance, "NullPointerException: $e", Toast.LENGTH_LONG).show()
        }
    }
}

 


 

*마지막 체크사항:

1. 방화벽 포트 8000 이 열려 있는지

2. 로컬 url이 알맞게 설정되어 있는지

 

이 2가지만큼은 꼭 체크하는게 좋다.

나처럼 2주동안 헤메는 사람이 없길 바라면서 이번 포스팅을 마무리한다.

 

 [ 참고 자료 ] 

 

GitHub - FirebaseExtended/custom-auth-samples: Samples showcasing how to sign in Firebase using additional Identity Providers

Samples showcasing how to sign in Firebase using additional Identity Providers - GitHub - FirebaseExtended/custom-auth-samples: Samples showcasing how to sign in Firebase using additional Identity ...

github.com

 

728x90

'Android Studio > Kotlin' 카테고리의 다른 글

[Kotlin] KakaoLogin KeyHash 발급  (0) 2021.08.27
[Kotlin][Firebase] Kakao Login 구현 #2  (1) 2021.08.24
[Kotlin][Firebase] Kakao Login 구현 #1  (0) 2021.08.23
Intent 예제  (0) 2021.08.10
Singleton  (0) 2021.08.05

BELATED ARTICLES

more