최근 진행하고 있는 프로젝트에서 구글 로그인/회원가입 방식을 사용하고 있고
간단하게 개발자 등록을 하고 키를 발급받아 간단하게 인증을 진행한 내용의 포스팅을 적었었다.
그때 난 우여곡절 끝에 네이티브키를 사용해 구글 로그인에 성공해 간단한 회원정보를 받아왔었다.
그런데 현재 우리 앱 상황은 백엔드 동료가 따로 aws 웹서버를 구축해 내 앱과 서로 통신하며 개발하고 있고, JWT 토큰 방식으로 인증 및 세션 유지 등을 하고 있기 때문에 조금 다른(?) 방식으로 Google Oauth를 사용해야 할 것 같아서 포스팅을 적는다.
무수한 검색과 질문, 삽질을 통해 내가 이해한 우리의 구글 오우ㄸth 프로세스는 아래와 같다.
1. 앱에서 웹클라이언트 키를 사용함
2. 앱에서 사용자가 구글 계정을 선택하도록 하고, 필요한 정보(필요에 따라 계정/auth_code/id_token등 추출 가능)를 받아옴
3. 백엔드에게 auth_code를 넘겨줌
4. 백엔드에서 auth_code + 같은 웹클라이언트키, url 등등(나도 잘 모름)을 함께 잘 처리해 access_token을 발급 받아 나에게 다시 줌
5. 나는 access_token을 shared preference 이런데다가 소듕하게 저장해놓고 사용자 인증이 필요한 서비스 진행시 백에게 access_token을 헤더에 넣어서 보냄
백엔드 친구의 디프만 프론트/백 깃헙 레포를 뒤지고 뒤져서 혼자 터득한 것 치고는 나름 잘 이해했다고 본다.
일단 프로젝트 마감일까지 시간이 없으니 바로 개발에 들어갔는데 나를 혼란스럽게 한 것들을 적어보고 답을 찾으려고 노력해본 과정을 적어봤다.
Step1. Google Oauth2.0 키 발급 및 기본 세팅
여러번 말했지맘 google oauth를 사용하려면 키를 발급받아야 한다. 개발자 등록 및 키 발급 방법에 대해서는 아래 포스팅에서 다룬적이 있으니 참고하길 바란다.
주의 !! 아래 포스팅에는 네이티브 키를 사용하는 방법이 나와있다.
이번 포스팅에서는 aws 같은 외부 웹서버를 따로 구축하고 JWT 인증 방식을 사용하기 때문에 백엔드에서 내가 준 auth code를 검증하고 토큰을 발급받는 과정이 필요하다. 때문에 웹 클라이언트 키를 프론트/백에서 함께 사용한다.
그래서 나도 웹 클라이언트키를 발급받아 사용했었는데 에러가 났다....
이럴때는 웹클라이언트키와 네이티브키를 모두 발급받아 보자!!
https://studyroadmap-kkm.tistory.com/150
Step2. Google Sign In Bilder 생성 및 로그인/로그아웃
// 구글 sign-in 빌더 생성
private fun initGoogleLogin() {
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestScopes(
Scope("https://www.googleapis.com/auth/userinfo.email"),
Scope("https://www.googleapis.com/auth/userinfo.profile"),
Scope("openid")
)
.requestIdToken(getString(R.string.server_client_id))
.requestServerAuthCode(getString(R.string.server_client_id))
.requestEmail()
.build()
googleSignInClient = GoogleSignIn.getClient(this, gso)
}
// 로그인 (기존에 발급받은 googleSignInClient가 있으면 사용)
private fun signIn() {
Log.d("prefs","signIn")
val signInIntent: Intent = googleSignInClient.signInIntent
startActivityForResult(signInIntent, RC_SIGN_IN)
}
// 로그아웃
private fun signOut() {
googleSignInClient.signOut()
.addOnCompleteListener {
//viewModel.requestLogout() //Prefs에 있는 정보 삭제
Toast.makeText(this, "로그아웃 되셨습니다!", Toast.LENGTH_SHORT).show()
}
}
구글로부터 account를 받아 auth code를 추출하기 위해서는 먼저 builder를 생성해야 한다.
requestScope()을 설정해주고, requestServerAuthCode에 발급받은 웹 클라이언트키를 넣어 세팅해준다. (키는 바로 적는 것보다는 Strings 파일에 정의해놓고 사용)
빌더를 사용해 googleSignInClient를 발급받고 이걸 사용해 로그인할 때 사용자가 로그인할 계정을 선택할 수 있는 intent창이 뜨게 할 수 있다.
그래서 초기에 빌더와 클라이언트를 생성하고 로그인할 때마다 클라이언트를 사용해 intent를 추출하면 된다.
만약 세션이 만료되면 클라이언트를 재발급받으면 된다.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == RC_SIGN_IN) {
val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
val account = task.getResult(ApiException::class.java)
val authCode = account.serverAuthCode
val give_name = account.givenName
val family_name = account.familyName
val email = account.email
val profile = account.photoUrl.toString()
Log.d("mail","${email}")
if (authCode != null) {
Log.d("authCode","${authCode}")
prefs.setAuthCode(authCode)
//viewModel.requestLogin(authCode) //뷰모델에서 서버에 aceess 토큰 요청
prefs.setUserInfo(true,0,family_name+give_name,email!!,profile)
// 화면에 사용자 정보 세팅
val navView: NavigationView = binding.navView
val headerView = navView.getHeaderView(0)
val nameView = headerView.findViewById<TextView>(R.id.textView3)
val login = headerView.findViewById<Button>(R.id.button4)
val infoText = headerView.findViewById<TextView>(R.id.userName)
nameView.text = prefs.userName + "\n" + email
nameView.isVisible = true
login.isVisible = false
infoText.text = "이제 나만의 포토가이드를 만들 수 있습니다!!"
val profileView = headerView.findViewById<ImageView>(R.id.userPicture)
Picasso.get()
.load(prefs.userProfileImg)
.into(profileView)
}
} catch (e: ApiException) {
Log.d("ApiException", "handleSignInResult: error" + e.statusCode)
}
}
}
사용자가 원하는 계정을 성공적으로 선택하면 해당 account를 반환해준다. 그리고 이 account에서 안드 개발자가 auth code를 추출하고 백엔드에게 넘겨주기만 하면 된다.
원래는 백엔드로부터 로그인을 요청하고 결과(토큰, 사용자 정보)를 다시 반환 받아 shared preference에 저장해놓고 사용한다.
하지만 이번 프로젝트에서는 백엔드에서 토큰을 발급 받는 과정을 구현하지 못해서 토큰은 사용하지 못했고 사용자 정보도 account에서 직접 뽑아 사용했다.
원래 취지와 목적을 반밖에 달성하지 못했지만 백엔드 부분만 구현된다면 shared preference에 정보를 저장하고 필요에 따라 추출해 사용하는 로직에 그대로 대입하면 되기 때문에 큰 문제는 아니라고 생각한다.
Step3. 백엔드에 로그인 요청하고 결과 받기
class HomeViewModel(private val homeRepository: HomeRepository): ViewModel() {
// 서버에 access 토큰 및 사용자 정보 요청
fun requestLogin(auth_code : String) {
// api 호출
viewModelScope.launch {
val loginInfo = homeRepository.requestLogin(auth_code)
//토큰 정보 세팅
HomeActivity.prefs.setAccessToken(loginInfo.accessToken)
// 사용자 정보 세팅
HomeActivity.prefs.setUserInfo(loginInfo.isNewMember,loginInfo.memberId,loginInfo.memberName,"email",loginInfo.profileImage)
isLogin.value = true
}
}
AAC 구조를 사용하면서 메인 코드에서 바로 백엔드에 로그인 요청을 하는게 아니라 viewmodel 과 repository를 사용하게 되었다.
viewmodel에서 viewModelScope 안에 repository에 정의되어 있는 로그인 요청 메서드를 호출한다.
솔직히 viewModelScope에 대해서 잘 알지 못하는데 이걸 하지 않으면 에러가 났다... 대충 눈치로 봐서는 이 scope 안에 있는 코드들이 백그라운드로 실행되는 것 같았다. 그래서 정보 세팅 같은 코드를 scope 밖에 작성하면 에러가 난다. (정보를 다 가져오지 않았는데 정보를 세팅하려고 해서 나는 에러)
class HomeRepository(private val remoteDataSource: RemoteDataSourceImp) {
suspend fun requestLogin(auth_code:String) : LoginDTO {
return remoteDataSource.requestLogin(auth_code)
}
}
repository에서 data source에 정의되어 있는 로그인 요청 메서드를 호출한다.
사실 이것도 첨엔 repository에서 바로 api를 호출할줄 알았는데 repository는 복잡한 코드 작성 없이 clean하게 어떤 api를 호출하는 메서드를 호출할지만 정의되어있고 DataSource에서 실제로 API 를 호출하는 것 같다.
class RemoteDataSourceImp(private val apiClient : RetrofitInstance) : RemoteDataSource{
override suspend fun requestLogin(auth_code : String) : LoginDTO {
return apiClient.api.requestLogin(auth_code)
}
}
백엔드에 API 호출 후 반환값으로 토큰 정보와 사용자 정보 등을 json 형태로 받게 되는데 retrofit을 사용하면 굳이 일일히 다 파싱할 필요없이 DTO를 정의해 key값에 대응하는 value값을 받아 LoginDTO 객체를 생성해 사용할 수 있다.
@Parcelize
data class LoginDTO(
@SerializedName("accessToken")
val accessToken: String,
@SerializedName("accessTokenExpireTime")
val accessTokenExpireTime: String,
@SerializedName("isNewMember")
val isNewMember: Boolean,
@SerializedName("memberId")
val memberId : Int,
@SerializedName("memberName")
val memberName : String,
@SerializedName("profileImage")
val profileImage : String,
@SerializedName("refreshToken")
val refreshToken : String,
@SerializedName("refreshTokenExpireTime")
val refreshTokenExpireTime : String
) : Parcelable
interface ApiService {
/*
* oauth
*/
//@Headers("content-type: application/json")
@POST("/api/oauth/login")
suspend fun requestLogin(
@Header("Authorization") authCode: String
) : LoginDTO //유저 정보와 토큰을 받아옴
@POST("/api/oauth/logout")
suspend fun logout(
@Header("Authorization") accessToken: String
) // 토큰 삭제를 요청
}
(Api 클라이언트를 생성하는 코드도 필요하지만 그건 Retrofit 포스팅에서 다루도록 하겠다.)
앞서 토큰이나 사용자 정보를 저장해놓고 사용자 인증이 필요할 때 헤더에 토큰을 넣어 api 요청하거나 세션 관리등을 하기 위해 shared preferences를 이용한다고 이야기 했었다.
로그인을 하면 앱 1개당 사용자 정보는 1개만 저장되어야 하고 access token도 안전하게 저장해야 하기 때문에 shared preferences를 사용한다.
Shared Preferences
- 데이터를 파일로 저장을 하고, 파일이 앱 폴더 내에 저장되므로 앱을 삭제하시면 당연히 데이터도 삭제됨
- 간단한 데이터/토큰 같은 정보들을 DB 나 서버에 저장하기 그럴때 shared preference에 저장해 데이터 관리
- 앱의 어디서나 전역적으로 사용할 수 있도록 싱글톤으로 구현하는게 좋음 *싱글톤 : 객체가 오직 1개만 생성됨
- 나도 사용자 정보, 토큰 정보를 싱글톤 객체로 만들어 저장하고 사용할 것
class PreferenceUtil(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("prefs_phodo", Context.MODE_PRIVATE)
val accessToken: String
get() = prefs.getString("access_token", "").toString()
val isMember: String
get() = prefs.getBoolean("is_member", false).toString()
val userId: String
get() = prefs.getInt("user_id", -1).toString()
val userName: String
get() = prefs.getString("user_name", "").toString()
val userEmail: String
get() = prefs.getString("user_email", "").toString()
val userProfileImg: String
get() = prefs.getString("user_profile_img", "").toString()
fun setAuthCode(authCode: String) {
prefs.edit()?.apply {
putString("auth_code", authCode)
}?.apply()
}
fun setAccessToken(str: String) {
prefs.edit().putString("access_token", str).apply()
}
fun setAccessTokenExpTime(str: String) {
prefs.edit().putString("accessToken_expireTime", str).apply()
}
fun setRefreshToken(str: String) {
prefs.edit().putString("refresh_token", str).apply()
}
fun setRefreshTokenExpTime(str: String) {
prefs.edit().putString("refreshToken_expireTime", str).apply()
}
fun setUserInfo(isMember:Boolean, userId: Int, userName: String, userEmail : String, userProfileImg : String) {
prefs.edit().putBoolean("is_member", isMember).apply()
prefs.edit().putInt("user_id", userId).apply()
prefs.edit().putString("user_name", userName).apply()
prefs.edit().putString("user_email", userEmail).apply()
prefs.edit().putString("user_profile_img", userProfileImg).apply()
}
fun deleteAccessToken() {
prefs.edit()?.apply {
remove("access_token")
}?.apply()
}
}
utils 폴더를 하나 만들고 이렇게 코드를 작성하면 안드 코드 어디에서나 전역적으로 접근해 사용할 수 있다.
[참고]
https://sodock00.tistory.com/22
의문점 & 배워보고 싶은 것
💡 궁금증 1. 네이티브키와 웹클라이언트키의 차이점? 정보 추출은 어디서 하느냐에 따라 어떻게 다른가?
일단 네이티브용 전용 키가 따로 있는데 jwt 토큰 방식을 사용시 왜 웹 클라이언트 키를 사용해야 하는가?
=> 무결성을 검증할 때 google 에서 제공하는 API를 사용해야 하기 때문인 것 같다.
네이티브키는 앱끼리 통신할 때 사용한다고 한다. 구글도 다 자체 서버가 있을것이고 무결성 인증은 구글앱에서 해주는 것이 아니라 구글 서버에 요청해야 하는 작업이다. 따라서 웹키를 사용해 구글 서버에 내가 인증하고자 하는 계정의 무결성을 확인해야 한다. 물론 이 확인 작업은 백에서 하지만 같은 키를 사용해야 웹키에 요청한 auth code를 다시 웹키로 무결성 검증하는 것이다.
아래와 같은 요구사항을 만족시키기 위해서 프론트와 백에 같은 웹키를 가지고 서로 역할을 나누어 작업을 처리하고 통신한다.
- 앱 화면에서 사용자가 로그인 하고 싶은 계정을 선택할 수 있어야 함 -> 프론트에서 처리
- 해당 계정에 대해서 무결성 검증 및 세션 토큰을 발급해줘야 함 -> 백에서 처리
그럼 이제 프론트에서 account를 받아오면 그 account로 이메일, 이름, 프로필 사진 등을 추출해올 수 있는데 왜 보통 백에서 추출해서 다시 프론트에 넘겨주는 것일까?
=> 프론트에서 받아온 정보를 사용해 화면에 띄울 수도 '있긴 하다.' But, 이는 아직 무결성 검증을 하지 않은 상태인 것이다. 프론트에서 사용하는 getXXX() 메서드가 공격자에 의해 쉽게 호출될 수도 있기 때문에 안전과 무결성을 위해 Auth_code 무결성 검증이 성공적일때만정보를 추출해 프론트에게 넘겨주는 방식인 것 같다.
https://developers.google.com/identity/sign-in/android/backend-auth?hl=ko
💡 궁금증 2. 토큰 정보 어디까지 어떻게 활용하나? (access token, access 만료일, refresh token, refresh 만료일)
아래 참고 링크를 보면서 공부하면 좋다!
- Access token : 로그인 유지를 할 수 있도록 해주는 토큰 (access 만료일까지 사용 가능)
- Refresh token : access token이 만료되었을때 refresh token 을 서버에 다시 보내 access token 만료일 갱신 (보안을 위해 access token의 유효기간을 짧게 설정)
- Refresh token도 만료시 : 강제 로그아웃 시키고 다시 토큰 요청
[참고]
(이분 답변 완전 좋음)
https://okky.kr/questions/893374
https://velog.io/@mraz3068/Refresh-Token%EC%9D%B4-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0
우리 프젝에서는 시간 없음 + 교수님이 거기까지 안 볼 확률 90% + refresh toke의 필요성에 대해 아직 개발자들 사이에서도 의견 부분하고 나도 약간 불필요해 보임 이기 떄문에 access token만 사용하기루,,,,
*미래를 위해 일단 refresh token 까지 받아 저장하도록 dto를 만들지 아님 지금 하는거에 맞춰서 깔끔하게 access만 줄건지 의논해야 함
💡 궁금증 3. 어차피 백으로 토큰 넘기면 만료일 알아서 확인해서 reponse 주는데 왜 나한테 만료일 주는건가?
그동안 백동료랑 이야기한 것 + 2번 궁금증에서 참고한 블로그를 보면 프론트는 만료일에 대해 굳이 체크할 필요가 없고 그냥 서버에서 응답에 따라 인증 되었구나 or 토큰 만료되어서 다시 인증 요청해야 겠구나를 판단한다.
그런데 백동료의 reponse JwtDTO를 보면왜 굳이 나한테 만료일(Date)를 주는지 모르겠다.
=> 백동료에게 물어봐야지.
💡 궁금증4. 모든 토큰 만료시 재발급을 할 때, auth_code 도 재발급 하는지? Auth code는 늘 한 번 쓰고 버려지나?
=> 발급받았던 모든 token이 만료되었을 때 프론트에서 받아왔던 account로 삭제하고 사용자가 다시 명시적으로 계정을 선택하고 로그인하도록 하여 계정도, auth code도 다시 받아와야 하는 것 같다.
그런데 백동료의 디프만 프젝을 보면 auth code를 따로 저장해두었다. (너무 남의 레포를 뒤지는 것 같아 미안하지만.... 실력자들의 코드를 보고 공부했다 보니....) 위 프로세스에 따르면 어차피 딱 1번 사용하고 말 코드인데 왜 굳이 저장해놓는지 잘 모르겠다...
혹시 토큰 발급 과정에서 통신상의 문제라던가 생겼을 때 다시 요청해야 해서 그런걸까??....
인증코드는 자주 사용하면 안 되는 것 같은데 잘 모르겠다. 이것도 물어봐야겠다.
https://yonghyunlee.gitlab.io/temp_post/oauth-authorization-code/
https://datatracker.ietf.org/doc/html/rfc6749#section-10.5
💡 궁금증5. 대부분 실서비스를 운영하는 앱이라면 무결성 체크와 토큰 발급이 필수일텐데 그렇다면 네이티브키가 실무적으로 사용되는 순간은 언제인까?
=> 개인적으로 너무 궁금해서 안드로이드 오픈채팅방도 들어가보고 공식문서도 들여다보고 여기저기 검색도 했봤는데 아주 명쾌한 답이 나오지는 않았던 것 같다. 오픈채팅방에서 사람들끼리 이야기 하다가 네이티브 키는 활용도가 그렇게 크지는 않아 보인다는 정도?....
💡 궁금증6. 프론트에서 무결성 체크 및 jwt 토큰 발급을 다 진행하는 것은 불가능한 일일까? 안전성에 어떤 문제가 있을까?
=> 아직 찾아보지 않았다... 혹시 알고 있는 분이 계시다면 댓글로 알려주세요!!
[Access Token 관련 참고]
(약간 백엔드용)
https://devvkkid.tistory.com/248
[JWT 관련 참고]
솔직히 말하자면 궁금증이 완전히 풀린 것은 아니다.... 왜냐면 찾아도 시원하게 답변해주는 블로그도 없고 주변에 전문가도 없다보니....
그냥 여러 지식과 개념들을 끼워맞춰 이해해보려고 노력한 것뿐이다. (이렇게 노력하는데 나 좀 안드 외딴섬 탈출하게 해주자 젭알...)
'Android' 카테고리의 다른 글
[안드로이드/아키텍쳐] MVC, MVVM 패턴 그리고 ViewModel (0) | 2023.07.13 |
---|---|
[안드로이드/Activity] Parcelable, Serializable 그리고 Parcelize (1) | 2023.07.13 |
[안드로이드/센서] GPS 사용하기 (0) | 2023.05.16 |
[안드로이드/Oauth/Error] Google Oauth2.0 파이어베이스 없이 사용하기 & ApiException 10 에러 해결 (2) | 2023.05.13 |
[안드로이드/데이터입출력] assets (0) | 2023.04.29 |