본문 바로가기

안드로이드 앱개발

Android에서 Spring Security를 이용한 자동 로그인 구현

반응형

안녕하세요. 오늘은 Spring에서 Springs Security 적용 방법과 자동 로그인 구현에 대하여 작성했던 이전 게시물에 이어서

이번에는 Android에서 Spring Security를 이용한 자동 로그인 구현에 대해여 작성해보겠습니다.

https://pinlib.tistory.com/entry/SpringSecuritywithAutoLogin

 

Spring에서 security 적용 방법과 자동 로그인 구현

안녕하세요. 오늘은 Spring에서 Springs Security 적용 방법과 자동 로그인 구현에 대하여 작성해보겠습니다. 1. Spring Security란Spring Security는 스프링 프레임워크의 보안 모듈로, 애플리케이션의 Au

pinlib.tistory.com

 

 

자동 로그인 구현을 위해서는 이전 게시물에서 알아보았던 JWT를 서버로 부터 발급받은 것을 client에서 저장해야 합니다.

클라이언트인 안드로이드에서 토큰을 저장하기에 적합한 방법은 SharedPreferences를 이용하는 것입니다.

 

1. SharePreferences란

SharedPreferences는 Android에서 간단한 데이터 저장을 위해 사용되는 클래스입니다. 주로 앱의 설정 정보나 간단한 상태 정보(로그인 상태, 토큰 등)를 저장하는 데 사용됩니다. 데이터를 key-value 쌍으로 저장하며, 영구적으로 저장되지만 파일 시스템에 저장되므로 큰 데이터를 저장하는 데는 적합하지 않습니다.

 

만약 데이터의 크기가 크다면 이전에 배운 room이 적합하지만 단지 토큰을 저장한다면 가볍고 간단하게 저장할 수 있는 SharedPreferences가 더 적합합니다.

 

2. 안드로이드에서 로그인, 자동 로그인, 로그아웃 구현하기

해당 예시들은 저의 프로젝트인 Secret_Diary를 예시로 작성하겠습니다.

 

1) 로그인

우선적으로 자동로그인을 하려면 클라이언트에게 토큰이 존재해야 하므로 토큰을 받을 수 있는 일반 로그인부터 보여드리겠습니다.

 

UI전환과 같은 내용들은 자동로그인과 방식이 같으니 자동로그인 항목에 작성한 내용을 참조해주시길 바랍니다.

 

fun onLogin() {

        var roomEmail : String?

        val model = LoginModel(loginEmail, loginPassword)
        viewModelScope.launch(Dispatchers.IO) {
            val response = SecretDiaryObject.getRetrofitSDService.loginUser(model)
            if(response.isSuccessful){
                Log.d("Login","Login Success")
                Log.d("null test", email + password)
                
                val token = response.body()
                Log.d("auto login in login", "Token saved: $token")

                if(token != null){
                    saveToken(token)
                }

                result.value = true

                //room
                val user = User(userName = loginEmail, autoLogin = true, lastLoginTime = Date())
                userRepository.insertUser(user)
                roomEmail = userRepository.getMostRecentUserName()
                Log.d("recent user", "userEmail = $roomEmail")


            } else {
                val errorBody = response.errorBody()?.string()
                Log.e("Login Error", "Failed to send login dat a. Error response: $errorBody")
                result.value = false

            }
        }

    }

 

해당 로그인 method에서 다른 내용은 패스하고 자동 로그인을 구현하는 것이 목적이므로 sharedPreferences와 서버통신을 위주로 설명하겠습니다.

 

로그인을 성공하게 되면 spring 서버로 부터 response.body로 JWT를 받아오게 됩니다. 이 token을 parameter로 하여 saveToken method를 호출하게 됩니다. 

spring 서버에서의 과정은 링크를 달아드린 이전 게시물을 참조 부탁드립니다.

 

//sharedPreferences에 token 저장
    private fun saveToken(token: String){
        val sharedPreferences = context.getSharedPreferences("prefs",Context.MODE_PRIVATE)
        sharedPreferences.edit().putString("jwt_token", token).apply()
        Log.d("auto login saveToken", "Token saved: $token")
    }

 

 

context.getSharedPreferences("prefs", Context.MODE_PRIVATE)는 "prefs"라는 이름의 SharedPreferences 파일을 가져오거나 없으면 새로 생성합니다.

 

sharedPreferences.edit().putString("jwt_token", token).apply()

 

 

sharedPreferences.edit()를 통해 SharedPreferences.Editor 객체를 가져옵니다. 이 객체는 SharedPreferences에 데이터를 저장하거나 수정할 수 있는 역할을 합니다.

 

putString("jwt_token", token) "jwt_token"이라는 키에 token 값을 저장하는 작업입니다.

 

apply()를 호출하면 비동기적으로 저장 작업을 처리합니다. 즉, commit()과 달리 UI의 응답성을 유지하면서 데이터를 저장합니다. 저장 성공 여부를 확인할 필요가 없거나, 빠른 처리를 원할 때 apply()를 사용하는 것이 일반적입니다.

 

이러한 방식으로 sharedPreferences를 통해 서버로 부터 받아온 JWT를 안드로이드에 저장하게 됩니다.

 

 

2) 자동 로그인 

@Composable
fun SDScreen(
    viewModel: SecurityViewModel
) {
    val navController = rememberNavController()

    val context = LocalContext.current
    val userDao = UserDatabase.getDatabase(context).userDao()
    val usersRepository: UsersRepository = OfflineUsersRepository(userDao)

    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route

    val requestResult by viewModel.requestResult.collectAsState()



    LaunchedEffect(Unit) {
        viewModel.autoLogin()
    }

    LaunchedEffect(requestResult) {
        if(requestResult == true){
            navController.navigate("myNav")
            viewModel.resetResult()
        } else if(requestResult == false){
            viewModel.resetResult()
        }
    }

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            if(currentRoute != "securityNav" && currentRoute != "login" && currentRoute != "join"){
                MainTopAppBar(navController = navController)
            }
        },
        bottomBar = {
            MyBottomNavigation(
                containerColor = mediumBlue,//Color.Red,
                contentColor = Color.White,
                indicatorColor = lightBlue,
                navController = navController
            )
        }
    ) { innerPadding ->
        Box(modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding)) {

            RootNavHost(
                navController = navController,
                startDestination = "securityNav",
                usersRepository = usersRepository
            )

        }
    }
}

 

App을 제일 먼저 실행하게 되면 구성되는 composable인 SDScreen에서 LaunchedEffect를 이용해, 해당 composable이 구성될 때 바로 SecurityViewModel에 정의한 autoLogin() method가 수행되게 합니다.

 

//validate token
    private fun validationToken(token: String){
        viewModelScope.launch(Dispatchers.IO) {
            //val response = SecretDiaryObject.getRetrofitSDService.autoLogin(token)
            val response = SecretDiaryObject.getRetrofitSDService.autoLogin("Bearer $token")
            if(response.isSuccessful){
                Log.d("auto login", "validation success")
                result.value = true
                //_tokenResult.value = true
            } else {
                val errorBody = response.errorBody()?.string()
                val statusCode = response.code()
                Log.e("auto login", "Token validation failed, prompting user to login.")
                Log.d("auto login", "Failed: StatusCode = $statusCode, Error = $errorBody")
                result.value = false
                //_tokenResult.value = false
            }
        }
    }

    //auto login
    fun autoLogin(){
        val sharedPreferences = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
        val token = sharedPreferences.getString("jwt_token", null)

        Log.d("autoLogin", "Token retrieved: $token")

        if(token != null){
            Log.d("auto login","token is exist")
            validationToken(token)
        } else {
            Log.d("auto login","token is null")
            _tokenResult.value = false
        }
    }

 

이후 autoLogin method에서는 

val sharedPreferences = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)

 

"prefs"라는 이름의 SharedPreferences 파일을 가져오거나 없으면 새로 생성합니다.

MODE_PRIVATE는 이 파일이 앱 내에서만 접근 가능하다는 의미입니다.

 

val token = sharedPreferences.getString("jwt_token", null)

 

이후에는 가져온 파일에서 "jwt_token"이라는 키로 저장된 토큰 값을 가져옵니다.

만약, 저장된 값이 없다면 기본값으로 null을 반환합니다.

 

이렇게 가져온 토큰 값을 validationToken method를 호출하여 서버에게 retrofit2로 구현한 autoLogin method를 이용하여 autoLogin 허가 요청을 하게 됩니다.

아래는 retrofit2를 이용해 구현한 autoLogin method입니다.

 

@POST("security/autoLogin")
    suspend fun autoLogin(@Header("Authorization") token: String): Response<Void>

 

이 코드와 validationToken method에 대해 설명을 하자면, 링크를 달아드린 이전 게시물에서 말한바와 같이

인증이 필요한 경로이므로 client에서 인증정보를 서버에 전달하는 표준 헤더 필드인 "Authorization"을 HTTP 요청 헤더로 보내기 위해 @Header에 "Authorization"을 담아 보내게 됩니다.

 

또한 retrofit2로 구현한 autoLogin method의 parameter로 validationToken에서 "Bearer $token"을 사용하고 있습니다.

여기서 Bearer의 경우 서버에서 클라이언트에게 JWT를 발급하면, 클라이언트는 이 토큰을 API 호출 시 서버에 전송합니다.
서버는 이 토큰을 검증하여 클라이언트의 권한을 확인하는데, 이때 토큰이 Bearer 토큰으로 전달됩니다.
즉, 클라이언트는 JWT를 직접적으로 제공하는 대신, Bearer 인증을 통해 이 토큰을 전달하게 됩니다.
Bearer의 경우 "Bearer "로 공백 문자열을 포함하는데, 이는 인증 방식의 이름과 실제 인증에 사용되는 JWT토큰 값을 분리하기 위한 것입니다. 따라서 반드시 공백을 추가해서 보내야 합니다.

 

이후 spring server로 부터 200 request를 받게 되면

 

private val result = MutableStateFlow<Boolean?>(null)
val requestResult : StateFlow<Boolean?> = result

 

위와 같이 SecurityViewModel에 정의했던 result에 대하여 

validationToken에서 result.value의 값을 true로 바꿔주고 

SDScreen에서 securityViewModeldml resultRequest값과 연결되어 있는 requestResult값의 변경에 따라 

 

LaunchedEffect(requestResult) {
        if(requestResult == true){
            navController.navigate("myNav")
            viewModel.resetResult()
        } else if(requestResult == false){
            viewModel.resetResult()
        }
    }

 

자동로그인이 성공한다면 myNav로 연결하여 SecretDiary의 서비스를 제공하게 되고

실패한다면 securityViewModel에 정의한 

fun resetResult(){
        result.value = null
    }

 

result.value 초기화 method를 이용해 실질적인 서비스를 제공하는 myNav navigation으로의 이동을 막게 됩니다.

 

3) 로그아웃

fun logout(context: Context){
        val sharedPreferences = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
        val token = sharedPreferences.getString("jwt_token", null)

        if(token != null){
            viewModelScope.launch(Dispatchers.IO) {

                //val response = SecretDiaryObject.getRetrofitSDService.logout("Bearer " + token)
                val response = SecretDiaryObject.getRetrofitSDService.logout("Bearer $token")

                val allEntries = sharedPreferences.all
                for ((key, value) in allEntries) {
                    Log.d("auto SharedPreferences", "$key: $value")
                }
                Log.d("auto ", "Token : $token")

                if(response.isSuccessful){
                    Log.d("auto Logout", "서버 로그아웃 성공")

                    //토큰 삭제
                    sharedPreferences.edit().remove("jwt_token").apply()
                    Log.d("auto Logout", "토큰이 삭제되었습니다.")
                } else {
                    Log.e("auto Logout", "Logout 실패: ${response.code()} - ${response.message()}")
                }
            }
        } else {
            Log.d("auto Logout", "토큰이 없습니다.")
        }
    }

 

로그아웃 역시 위 설명했던 내용들과 비슷합니다.

이미 설명했던 내용들은 생략하고 새로운 내용들만 작성하겠습니다.

 

로그아웃의 핵심은 더 이상 자동로그인을 할 수 없게 토큰을 삭제하는 것입니다.

val allEntries = sharedPreferences.all
                for ((key, value) in allEntries) {
                    Log.d("auto SharedPreferences", "$key: $value")
                }
                Log.d("auto ", "Token : $token")

 

해당 코드의 경우 테스트 코드이므로 삭제하셔도 상관 없습니다만, 해당 코드를 구현하는 과정에서 토큰을 삭제하기 전 토큰의 존재 여부와 값을 확인하기 위한 코드입니다.

 

//토큰 삭제
sharedPreferences.edit().remove("jwt_token").apply()

 

spring 서버로부터 로그아웃 요청이 성공적으로 받아졌다는 response가 오게 되면

sharedPreferences.edit().remove("jwt_token").apply()를 사용하여 저장된 토큰을 삭제합니다.

 

이렇게 Android에서 Spring Security를 이용한 로그인, 자동 로그인, 로그아웃 구현에 대해 알아보았습니다.

기존에는 이렇게 Secret Diary 구현 관련 정보글을 마무리하려 했으나, 생각보다 SNS 친구기능 구현이 작성할 만한 주제라고 생각이 들어서 추후에는 해당 내용에 대해  작성하겠습니다. 감사합니다.

 

 

반응형