구글, 페이스북, 카카오, 네이버 등은 소셜 로그인 기능을 구현하려는 안드로이드 앱 개발자를 위해 네이티브 API를 제공한다. 그러나 애플은 안드로이드를 위한 API를 제공하지 않는다. 때문에 안드로이드에서 애플 로그인 기능을 구현하려면 앱에다 웹 화면을 얹은 뒤 그 안에서 애플의 웹용 API로 로그인 절차를 진행해야 한다. 총 3가지 방식으로 이를 구현해 보았는데 아마 다른 방법도 더 있을 것이다.
모든 로그인 API가 그렇지만 로그인 프로세스를 규정하는 프로토콜 OAuth 2.0 에 대한 이해가 필수적이다. 또한 구현에 앞서 iOS 개발자로 등록 후 개발자 사이트 콘솔에서 앱을 등록하고 service ID를 발급받아야 한다. 링크
가장 쉽고 간단한 방법.
1) 웹 로그인 화면을 (아마 Custom Tabs를 써서) 띄우고,
2) 유저가 로그인한 후 로그인 정보를 리다이렉트시키고,
3) 그 정보를 앱으로 가져오는 것
까지 API가 알아서 다 해 준다. 그래서 OAuth 2.0을 잘 모르더라도 이 방법을 쓰는 데는 큰 문제가 없다. 아마 공식 문서와 구글링을 통해 어렵지 않게 구현해낼 수 있을 것이다.
dependencies {
...
implementation platform('com.google.firebase:firebase-bom:파이어베이스 버전')
implementation 'com.google.firebase:firebase-auth-ktx'
}
class AppleLoginWithFirebaseActivity: Activity() {
private lateinit var mProvider: OAuthProvider.Builder
private lateinit var mAuth: FirebaseAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initAuth()
checkPending()
}
// 1. 인증 API 초기화
private fun initAuth(){
mProvider = OAuthProvider.newBuilder("apple.com")
mProvider.scopes = listOf("email", "name") //로그인 후 받고 싶은 유저 정보 범위
mProvider.addCustomParameter("locale", "ko")
mAuth = FirebaseAuth.getInstance()
}
// 2. 이미 받은 응답이 있는지 확인
private fun checkPending(){
val pending = mAuth.pendingAuthResult
if (pending != null) {
pending.addOnSuccessListener { authResult ->
Log.d("TTT", "checkPending:onSuccess:$authResult")
//로그인 결과 및 유저 정보가 AuthResult 객체에 담겨서 받아짐
//이 객체로 후속 작업 진행
}.addOnFailureListener { e ->
Log.d("TTT", "checkPending:onFailure", e)
}
} else {
startAuth()
}
}
// 3. 이미 받은 응답이 없다면 로그인 절차 시작
private fun startAuth(){
mAuth.startActivityForSignInWithProvider(this, mProvider.build())
.addOnSuccessListener { authResult ->
//로그인 결과 및 유저 정보가 AuthResult 객체에 담겨서 받아짐
//이 객체로 후속 작업 진행
}.addOnFailureListener { e ->
Log.d("TTT", "activitySignIn:onFailure", e)
}
}
}
2와 3은 1에서 Firebase API가 해 줬던 작업들을 개발자가 직접 구현하는 방식이다. 그 중 웹 화면을 WebView를 사용해서 띄울 것인지 Custom Tabs를 사용해서 띄울 것인지가 다르다. 또한 애플은 리다이렉트 URI로 반드시 https 프로토콜을 사용하는 실제 서버 URL을 요구하기 때문에 리다이렉트 서버를 따로 만들어야 한다는 난점이 있다. 내 경우 회사의 백엔드 개발자 분께서 만들어주신 서버를 활용했다. 일단 앱 단의 코드만 적고 백엔드 쪽 코드는 추후 더 배우고 나서 보충하겠다.
로그인 정보를 담은 리다이렉트 URL를 어떻게 앱에서 받을지가 관건인데, 여기서는 웹뷰가 다른 URL을 로드하려 할 때 발동하는 콜백 메소드 shouldOverrideUrlLoading() 가 로드 중인 URL을 인자로 받아오는 것을 이용해서 정보를 받았다. 애플은 response_type을 code와 token 둘 다 받는 것으로 설정할 수 있으므로 애플 서버로부터 정보를 받는 것은 한 번으로 끝낼 수 있다.
리다이렉트 서버는 없지만 연습을 해보고 싶다면 애플 대신 깃헙 로그인 API를 추천한다. 실제 서버가 없어도 jonghwan://testcallback 등 아무렇게나 리다이렉트URI를 적으면 된다 (중간에 :// 기호는 있어야 함). 단 애플과 달리 response_type이 code만 응답하는 것으로 고정되어 있어서, token까지 얻으려면 응답받은 code를 다시 요청파라미터에 담아 깃헙 서버와 통신을 한 번 더 해야 한다. 이 때는 웹뷰가 필요없으므로 평범하게 Retrofit 등의 통신 API를 쓰면 된다.
class AppleLoginWithWebviewActivity : AppCompatActivity() {
private lateinit var webView: WebView
// URL에 붙여서 애플 서버에 보낼 요청파라미터
private val mAuthEndpoint = "https://appleid.apple.com/auth/authorize"
private val mResponseType = "code%20id_token"
private val mResponseMode = "form_post"
private lateinit var mClientId: String
private val mScope = "name%20email"
private val mState = UUID.randomUUID().toString()
private val mRedirectUrl = "https://XXX.XXX" // 리다이렉트 서버 URL
// 리다이렉트 서버에서 응답을 보낼 URL
private val mResponseUrl = "XXXXX"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
// 웹뷰 세팅
val webViewSettings = webView.settings
webViewSettings.javaScriptEnabled = true //웹사이트 표시를 위해 필요
webView.webViewClient = AppleWebViewClient() //콜백메소드 사용을 위해 필요 (하단에 있는 이너클래스)
// 웹뷰 동작
val url = createUrl()
webView.loadUrl(url)
// 웹뷰 하나만 있는 액티비티이므로 XML 따로 만들 것 없이 이렇게 화면 구현 가능
setContentView(webView)
}
// 요청 URL에 파라미터 붙이기
private fun createUrl(): String{
mClientId = getString(R.string.apple_service_id)
return (mAuthEndpoint
+ "?response_type=$mResponseType"
+ "&response_mode=$mResponseMode"
+ "&client_id=$mClientId"
+ "&scope=$mScope"
+ "&state=$mState"
+ "&redirect_uri=$mRedirectUrl")
}
}
class AppleWebViewClient: WebViewClient(){
// 웹뷰에서 URL이 로드되려 할 때 발동하는 콜백 메소드.
// API level 21 이상에서만 사용 가능
@RequiresApi(21)
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// 로드하려는 URL 받기
val url = request?.url
// 리턴값이 true: 여기서 세팅한 대로 동작 명령
// 리턴값이 false: 웹뷰 기본 설정대로 동작 명령
return when {
// url null 체크용
url == null -> {
false
}
// 로드하려는 URL이 리다이렉트 서버의 응답URL이면 로그인 정보 추출하기
url.toString().contains("mResponseUrl") -> {
val idTokenParam = url.getQueryParameter("id_token")
if (idTokenParam != null){
// 받은 IdToken 디코딩
val decodedIdToken = decodeJWT(idTokenParam)
// 추출한 id token으로 후속 작업
}
true
}
else -> false
}
}
// 애플의 IdToken은 Java Web Token으로, Base64로 인코딩되어 있어 디코딩을 거쳐야 함
private fun decodeJWT(JWT: String): String {
var decodedJson = ""
try {
// 토큰의 header와 body를 분리
val split = JWT.split("\\.".toRegex()).toTypedArray()
// header 디코딩 (안 해도 됨)
val decodedHeader = Base64.decode(split[0], Base64.URL_SAFE)
Log.d("TTT", "header: ${String(decodedHeader, charset("UTF-8"))}")
// body 디코딩
val decodedBody = Base64.decode(split[1], Base64.URL_SAFE)
decodedJson = String(decodedBody, charset("UTF-8"))
Log.d("TTT", "body: $decodedJson")
}catch (e: UnsupportedEncodingException){
e.printStackTrace()
}
return decodedJson
}
}
웹브라우저와 연동하여 브라우저를 통해 앱 화면을 띄우는 API. 웹 화면이 있는 곳이 다른 앱이므로 로그인 정보를 가져오는 것이 웹뷰보다 약간 더 어렵지만, 대신 웹뷰보다 훠어어얼씬 빠르고 구글에서 웹뷰를 점점 퇴출시키려 하고 있기 때문에 가능하면 웹뷰보다는 커스텀탭스를 쓰는 것이 좋다. 대부분의 웹브라우저를 지원하지만 안 되는 브라우저도 있으므로 해당 브라우저 유저를 위해 대체 수단을 마련해 주는 것이 권장된다. 특히 블루스택을 제외한 앱플레이어에 내장된 기본 브라우저들은 커스텀탭스를 지원하지 않으므로 반드시 대체 수단이 필요하다.
웹뷰는 로그인 정보를 받아올 때 웹뷰에서 기본 제공하는 콜백메소드를 이용했지만, 커스텀 탭스는 기본 제공하는 콜백메소드가 웹뷰와 달라서 리다이렉트 서버에서 쏴준 URL을 인자로 가져오지 않는다. 그래서 로그인 정보를 받을 액티비티에 딥 링크 세팅을 해주고, 리다이렉트 서버에서 직접 액티비티의 URL로 로그인 정보를 쏴주도록 백엔드 작업을 한다. 앱에서 정보를 받을 때는 onNewIntent() 메소드를 오버라이드해서, 로그인 정보가 쿼리파라미터로 포함되어 있는 URL을 인텐트를 통해서 받아온다. 이 방법론은 이 글을 참고했다.
2와 마찬가지로 이 글에서는 백엔드 코드 없이 앱 쪽 코드만 쓰겠다. 백엔드 없이 연습을 하고 싶다면 깃헙 로그인 API으로 연습해 보는 것을 추천한다. 깃헙 서버에서 로그인 정보를 리다이렉트 서버가 아닌 앱으로 직접 리다이렉트시키도록, 깃헙 개발자 사이트에서 리다이렉트URI를 액티비티의 URL로 작성하면 된다. (애플은 리다이렉트URI가 실제 서버 URL인지 검증하는 절차가 있어서 이것이 불가능)
dependencies {
implementation 'androidx.browser:browser:버전'
}
<!-- 로그인 정보를 받을 액티비티 -->
<activity
android:name="패키지주소.AppleLoginWithCustomTabsActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- 리다이렉트 서버에서 딥 링크로 앱에 직접 접근하기 위한 인텐트 설정 -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<!-- 리다이렉트 서버가 쓸 딥 링크용 URL (jonghwan://testcallback 일 경우) -->
<data
android:host="testcallback"
android:scheme="jonghwan"/>
</intent-filter>
</activity>
class AppleLoginWithCustomTabsActivity : AppCompatActivity() {
private val mAuthEndpoint = "https://appleid.apple.com/auth/authorize"
private val mResponseType = "code%20id_token"
private val mResponseMode = "form_post"
private lateinit var mClientId: String
private val mScope = "name%20email"
private val mState = UUID.randomUUID().toString()
private val mRedirectUrl = "https://XXX.XXX" // 리다이렉트 서버 주소
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mClientId = getString(R.string.apple_service_id)
val uri = Uri.parse(mAuthEndpoint
+ "?response_type=$mResponseType"
+ "&response_mode=$mResponseMode"
+ "&client_id=$mClientId"
+ "&scope=$mScope"
+ "&state=$mState"
+ "&redirect_uri=$mRedirectUrl")
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(this, uri)
}
// 로그인 정보가 담긴 인텐트를 받는 메소드
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.data?.let { uri ->
parseUri(uri)
}
}
private fun parseUri(url: Uri){
val stateParam = url.getQueryParameter("state")
val idTokenParam = url.getQueryParameter("id_token")
val error = url.getQueryParameter("error")
if (idTokenParam != null){
// 받은 IdToken 디코딩
val decodedIdToken = decodeJWT(idTokenParam)
// 추출한 id token으로 후속 작업
}
finish()
}
// 애플의 IdToken은 Java Web Token으로, Base64로 인코딩되어 있어 디코딩을 거쳐야 함
private fun decodeJWT(JWT: String): String {
var decodedJson = ""
try {
// 토큰의 header와 body를 분리
val split = JWT.split("\\.".toRegex()).toTypedArray()
// header 디코딩 (안 해도 됨)
val decodedHeader = Base64.decode(split[0], Base64.URL_SAFE)
Log.d("TTT", "header: ${String(decodedHeader, charset("UTF-8"))}")
// body 디코딩
val decodedBody = Base64.decode(split[1], Base64.URL_SAFE)
decodedJson = String(decodedBody, charset("UTF-8"))
Log.d("TTT", "body: $decodedJson")
}catch (e: UnsupportedEncodingException){
e.printStackTrace()
}
return decodedJson
}
}
안녕하세요! 안드로이드 앱 개발 중 애플로그인 기능을 구현해야할 일이 생겨서 글을 읽게 되었는데 정말 도움이 많이 됐습니다!!! 그런데 혹시 3번 방법에서 "리다이렉트 서버에서 직접 액티비티의 URL로 로그인 정보를 쏴주도록 백엔드 작업을 한다." 이 부분을 백엔드에서 정확히 어떻게 구현하는지 알 수 있을까요?.... 저도 백엔드 개발자분도 처음 개발하는 부분인데 안드로이드 코드는 알려주신대로 일단 다 동일하게 작성했고 깃허브 로그인으로 테스트 할 때도 잘 됐었는데 실제 서버와 연결해서 하니까 제가 준 URL이 연결이 안 된다고 뜨네요...? 백쪽에서도 딥링크로 정보를 쏴주는 방법을 잘 모르시는 것 같아 혹시나 하고 여쭤봅니다...!