Spring Security 없이 소셜 로그인 정복하기(OAuth2.0)

RID·2024년 6월 7일
0

인증/인가

목록 보기
1/1

배경


이제느 새로운 앱을 다운 받고 가입을 하려고 하는데 소셜 로그인 기능이 없다면 굉장히 아쉬운 느낌이 든다. 그만큼 우리 사용자들에게 굉장한 편의성을 주는 소셜 로그인 기능을 어떻게 구현할 수 있는지 한 번 살펴보자.

예전에 발로란트라는 게임의 1일 상점을 확인할 수 있는 앱을 설치한 적이 있다. 그런데 이를 위해서는 내 라이엇 계정의 ID와 비밀번호를 그대로 입력해야 서비스를 이용할 수 있었다. 라이엇 로그인 페이지가 아닌, 해당 앱에서 자체적으로 만든 로그인 페이지에 내 계정정보를 입력해야 했다는 뜻이다.

이 경우 나는 ID와 비밀번호를 입력함으로서 내 계정에 대한 모든 권한과 정보를 해당 서비스에 주어야 한다. 정작 그 서비스에서 내가 원했던 것은 단지 상점 정보일 뿐인데도 말이다. 굉장히 찝찝한 느낌에 앱을 삭제하게 되었다.

이러한 문제는 Oauth를 통해서 해결할 수 있다. 사용자(Resource Owner)입장에서 내 계정에 대한 보안을 걱정하지 않으면서, 동시에 서비스(Client) 운영자도 이러한 민감 정보 저장 문제로부터 자유로은 Oauth 기반 인증 시스템을 Spring으로 구현해보자.

OAuth2.0


OAuth 프로토콜은 정확히 말하자면 사용자(Resource Owner)가 가진 다른 서비스의 Resource에 접근할 수 있는 권한을 부여하는 방식이다.

가령, 일정 관리 앱을 만들 때 유저의 구글 캘린더에 적혀있는 내용을 연동해준다고 생각해보자. 우리가 앱을 만들기 위해서 필요한 정보는 딱 캘린더 내용이다. 그러기 위해서 유저의 ID/PW를 받을 필요가 있을까? 없다.

단지, 유저에게 요청해서 '당신이 구글에 로그인을 해주면 우리가 구글에 요청해서 캘린더 접속 권한만 받을게요'라고 얘기하면 된다. 유저의 경우 구글 화면으로 이루어진 로그인을 하기 때문에 안심할 수 있고, 우리에게도 계정정보가 넘어오지 않는다.

아래는 OAuth2.0 프로토콜의 문서인 RFC 6749에 나와있는 Flow를 가져온 것이다.

RFC 6749

 +--------+                               +---------------+
 |        |--(A)- Authorization Request ->|   Resource    |
 |        |                               |     Owner     |
 |        |<-(B)-- Authorization Grant ---|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(C)-- Authorization Grant -->| Authorization |
 | Client |                               |     Server    |
 |        |<-(D)----- Access Token -------|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(E)----- Access Token ------>|    Resource   |
 |        |                               |     Server    |
 |        |<-(F)--- Protected Resource ---|               |
 +--------+                               +---------------+

Client라고 하는 우리의 서비스가 사용자(Resource Owner)에게 구글 캘린더 사용을 위한 계정 로그인을 부탁하게 되고, 사용자는 이 로그인을 진행하여 Client에게 Authorization 서버로 부터 access token을 받을 수 있게 도와준다.

이 access token을 통해 Client는 이후 다시 사용자의 로그인 요청을 하지 않더라도 Resource 서버로부터 원하는 데이터를 얻을 수 있게 된다.

여기까지 흐름을 살펴보았을 때, Oauth는 단순히 소셜 로그인을 위한 인증 프로토콜이 아니라는 것을 느낄 수 있다. 본인의 경우 소셜 로그인을 목적으로 검색을 진행하다보니 자연스레 OAuth = 소셜 로그인 이라는 생각에 빠져있었다.

실제 소셜 로그인의 경우 OAuth 프로토콜을 통해 얻은 access token으로 사용자의 유저 정보를 받아와 이를 통해 우리의 회원으로 가입시키거나, 로그인 처리를 시키는 절차를 진행하는 것이다.

소셜 로그인 구상하기


OAuth가 적용된 부분은 access token을 통해 유저정보를 받은 과정까지이며, 이후 해당 정보를 통해 우리 서비스를 이용하기 위한 인증/인가 절차를 구축하는 것은 우리의 몫이다.

그렇다면 OAuth2.0을 통해 소셜 로그인을 구축하기 위해서 어떤 과정이 필요한지 시퀀스 다이어그램을 먼저 그려본 후 코드를 작성해보도록 하자.

카카오 소셜 로그인을 목적으로 인증/인가 방식을 구축한다고 할 때 우리가 생각해야 하는 부분 스텝별로 나눠서 생각해보자.

  1. 사용자가 '카카오로 로그인하기' 버튼을 누를 시 카카오톡 로그인 화면으로 전환하기
  2. 사용자가 로그인 시 백엔드 서버로 Authorization Code를 받기.
  3. 제공받은 Authorization Code를 통해 kakao측 Authorization Server로부터 access token 발급받기.
  4. 제공받은 access token을 통해 kakao측 Resource Server로부터 유저 정보 받아오기.
  5. 받아온 유저정보를 이용해서 백엔드 유저 table 검색 후 신규 유저의 경우 회원가입 진행.
  6. 기존 회원인 경우 4에서 제공받은 유저정보를 통해 백엔드 서버 인증을 위한 access token 발행.

여기서 헷갈리면 안되는 것은 kakao 측에서 제공받은 access token과, BE에서 제공하는 access token의 차이이다.

  • Kakao측에서 받는 access token의 경우 해당 사용자의 kakao resource의 정보에 접근할 수 있는 권한을 갖는 access token이다.

  • BE에서 제공하는 access token의 경우 우리 서비스를 이용하기 위해 필요한 인증을 제공하는 access token이다.

이제 이 과정을 Controller, Service, Client, Repository 등의 관점에서 한 번 생각해보자.

  • 소셜 로그인에 대한 요청이 들어오게 되면, redirect를 통해 사용자를 kakao 로그인 페이지로 연결할 것이다.

  • 로그인 후 Controller로 authorization code가 넘어오게 되며, 이 code를 이용해서 kakaoLoginService에 로그인 요청을 보낸다.

  • KakaoClient에게 code를 넘겨주고, kakao 외부 url로 요청을 보내 access token을 받아오며, 이를 통해 유저 정보를 가져온다.

  • 유저정보가 User DB에 없으면 회원가입을 진행한 후 토큰을 발급하며, 있는 경우 로그인을 진행한다.

소셜 로그인 구현하기


이쯤에서 좋은 코드의 세 가지 주요 요소를 다시 한 번 살펴보자.

  1. 변경에 유연해야 한다.
  2. 읽기 쉬워야 한다.
  3. 제대로 동작해야 한다.

대부분 3번에 집중하면서 구현을 할 테니 이번 구현에서는 2번에 최대한 집중해서 살펴보고, 1번을 만족하는 코드를 작성하기 위해서는 어떤 고민을 해야할 지에 대한 내용으로 마무리 해보겠다.

일단 SOLID의 법칙 중 SRP를 지키기 쉬운 방법 중 하나는 Client쪽(의존관계를 호출하는 쪽) 코드를 먼저 작성하는 것이다. 그래서 가장 먼저 Controller쪽 부터 구현을 진행해보자.

KakaoLoginController.kt

@RestController
class KakaoLoginController(
	private val kakaoLoginService: KakaoLoginService
){
	
    // 해당 요청 시 kakao login page로 redirect 시키기
    @GetMapping("/oauth2/login/kakao")
    fun redirectLoginPage(response: HttpServletResponse){
    	val loginPageUrl = kakaoLoginService.generateLoginPageUrl()
        response.sendRedirect(loginPageUrl)
    }

	// 사용자가 로그인 페이지에서 로그인 시 아래 url로 redirect 
    // code를 통해 access token 받아오기
    @GetMapping("/oauth2/login/callback)
    fun callback(
    	@RequestParam code: String
    ): ResponseEntity<String>{
        	val accessToken = kakaoLoginService.login(code)
            return ResponseEntity.ok(accessToken)        
    }
}

여기까지 보았을 때 흐름 상 문제 된다고 느껴지는 것이 없다! 나머지 구체적인 요소들은 kakaoLoginService 가 알아서 잘 구현하면 된다. 그러니 믿고 맡겨보자!(사실 이것도 내가 작성해야 하긴 한다..)

KakaoLoginService.kt

@Service
class KakaoLoginService(
	private val kakaoLoginClient: KakaoLoginClient,
    private val userService: UserService,
    private val jwtHelper: JwtHelper
){
	fun generateLoginPageUrl(): String{
    	return kakaoLoginClient.generateLoginPageUrl()
    }
    
    fun login(code: String){
   		// code를 이용해서 AccessToken 발급
        kakaoLoginClient.getAccessToken(code)
        	// 토큰을 이용해서 유저 정보 가져오기
        	.let{ kakaoLoginClient.retrieveUserInfo(it) } 
            // 사용자 정보를 토대로 회원가입 / 조회
            .let{ userService.registerIfAbsent(it) }
			// 우리 서비스 AccessToken 발급
			.let{ jwtHelper.generateAccessToken(it.id!!) }
    }

}

막상 맡기고 위임한다고 했는데 위의 Service 코드도 생각보다 별거 없다. 사실은 별거 없다기 보다 SRP를 지키기 위해 각자의 책임을 갖는, 그 역할을 잘 수행할 수 있는 객체의 도움을 받도록 작성했기 때문이다.

Kakao의 외부 url과 소통해야하는 부분은 KakaoLoginClient 객체에게, jwt관련 내용은 JwtHelper에게, 회원가입의 경우 기존에 유저정보를 담당하던 UserService 객체에게 책임을 위임하는 것이다. 이렇게 되면 Service의 경우 각 역할의 구체적인 구현 사항에 대해 알지 않아도 되기 때문에 변경에 있어서 자유롭다.

또한, 코드와 주석이 1:1로 대응되는 만큼 코드의 가독성이 굉장히 좋다고 할 수 있다.

사실상 이제 코드 구현이 끝났다고 봐도 무방하긴 하다. 우리가 기존에 사용하는 controller-service-repository layer 형태로 구현을 완료했고, 적절한 등장인물을 등장 시켜 의존성을 연결했다. 이제 각 책임을 가지는 객체가 해야하는 구체적인 구현은 자유롭게(책임을 다하기만 하면) 하면 된다.

RestClientConfig.kt

@Configuration
class RestClientConfig{
	
    @Bean
    fun restClient(): RestClient{
    	return RestClient.builder().build()
    }

}

외부(kakao) url로 요청을 보내야 하는 상황이 생기기 때문에 http 요청을 보낼 수 있는 RestClient를 사용하도록 하겠다.

이제 본격적으로 kakao측과 소통할 수 있는 KakaoLoginClient에 대한 구현을 구체화 해보자.

KakaoLoginClient

class KakaoLoginClient(
    @Value("\${oauth2.kakao.client_id}") val clientId: String,
    @Value("\${oauth2.kakao.redirect_url}") val redirectUrl: String,
    @Value("\${oauth2.kakao.auth_server_base_url}") val authServerBaseUrl: String,
    @Value("\${oauth2.kakao.resource_server_base_url}") val resourceServerBaseUrl: String,
    private val restClient: RestClient
){

    fun generateLoginPageUrl(): String {
        return StringBuilder(authServerBaseUrl)
            .append("/oauth/authorize")
            .append("?client_id=").append(clientId)
            .append("&redirect_uri=").append(redirectUrl)
            .append("&response_type=").append("code")
            .toString()
    }

    fun getAccessToken(authorizationCode: String): String {
        val requestData = mutableMapOf(
            "grant_type" to "authorization_code",
            "client_id" to clientId,
            "code" to authorizationCode
        )
        return restClient.post()
            .uri("$authServerBaseUrl/oauth/token")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(LinkedMultiValueMap<String, String>().apply { this.setAll(requestData) })
            .retrieve()
            .onStatus(HttpStatusCode::isError) { _, _ ->
                throw RuntimeException("Failed to get Kakao access token")
            }
            .body<KakaoTokenResponse>()
            ?.accessToken
            ?: throw RuntimeException("Failed to get Kakao access token")
    }

    fun getUserInfo(accessToken: String): OAuth2LoginUserInfo {
        return restClient.get()
            .uri("$resourceServerBaseUrl/v2/user/me")
            .header("Authorization", "Bearer $accessToken")
            .retrieve()
            .onStatus(HttpStatusCode::isError) { _, _ ->
                throw RuntimeException("Failed to get Kakao UserInfo")
            }
            .body<KakaoLoginUserInfoResponse>()
            ?: throw RuntimeException("Failed to get Kakao UserInfo")
    }

}

OAuth 프로토콜을 사용하기 위해 kakao developers 홈페이지에서 프로젝트를 생성하고, 필요한 정보들을 미리 application.yml에 저장해두었다.

해당 정보를 바탕으로 url을 생성하고, RestClient를 통해 외부 kakao url과 요청을 주고받을 것이다. 주고 받는 과정에서 kakao측에서 돌려주는 response의 경우 미리 확인하고 KakaoTokenResponse, KakaoLoginUserInfoResponse 등과 같이 미리 dto 형태로 구현을 해두자.

KakaoLoginUserInfoResponse.kt

@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
class KakaoLoginUserInfoResponse(
    id: Long,
    properties: KakaoUserPropertiesResponse
)

@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class KakaoUserPropertiesResponse(
    val nickname: String
)

UserInfo의 경우 kakao developers에서 설정한 권한에 따라 더 많은 정보를 받을수도 있고, 지금처럼 id, nickname 정도의 정보만 받을 수도 있다. 각자의 앱 회원가입에 필요한 정보에 따라 dto를 잘 구현하자.

KakaoTokenReponse.kt

@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class KakaoTokenResponse(
    val accessToken: String
)

UserService.kt

@Service
class UserService(
	private val userRepository: UserRepository
){
	fun registerIfAbsent(userInfo: KakaoLoginUserInfoResponse): User {
    	return userRepository.findByProviderNameAndProviderId("KAKAO",userInfo.id.toString())
        ?: userRepository.save(
        	User(
            	providerName = "KAKAO",
                providerId = userInfo.id.toString(),
                nickname = userInfo.properties.nickname
            )
        )
    }
}

UserService에서 어떤 정보를 저장하는지 한 번 살펴보자. Provider 이름과, providerId 값을 저장한다. 소셜 로그인의 경우 대게 2개 이상의 서비스를 이용해 로그인을 가능하게 하므로 다른 소셜 로그인이 추가될 가능성을 고려해 provider 이름을 저장하였다. 그렇다면 providerId는 왜 저장할까?

해당 값은 Kakao측에서 유저 정보를 가져올 때 해당 사용자의 고유한 ID 값을 넘겨준 것이다. 이것이 왜 필요한지 생각해보자. 만약 kakao 계정으로 회원가입을 진행한 유저가 kakao 이메일, 이름, 닉네임을 전부 변경했다고 하자.

이때, 우리 서비스는 어떻게 이 회원이 이전에 가입한 회원이라는 것을 보장할 수 있을까? 답은 카카오 측에서 제공해준 사용자에 대한 고유한 ID말고는 없다.

그렇다면 왜 굳이 provider 이름까지 저장할까? 만약 kakao 측에서 고유한 id라고 보내준 값이 naver에서 보내준 다른 유저의 고유한 id와 같다면 어떻게 될까? 우리 서비스 측에서는 이 두 사용자를 구분할 방법이 없다. 그렇기 때문에 composite key 같은 느낌으로 provider 이름과 고유한 id를 같이 검색하는 것이다.


이제 웬만한 구현은 다 마쳤고, 받아온 유저정보로 부터 우리 서비스의 jwt 토큰을 만들어서 발급해주기만 하면 된다! 토큰 발급을 위한 유저정보를 가져오는 과정도 마쳤으니, JwtHelper 객체를 구현해서 access token을 만드는 과정만 진행하면 된다.

해당 부분에 대한 구체적인 구현은 글에서는 다루지 않도록 하겠다. 각자 서비스에 따라 토큰에 들어갈 내용이 다를 것이고, 각자의 방식에 따라 구현해도 아무 문제가 없다. 우리는 이미 해당 부분에 대해 추상화를 진행했고, 구체적인 구현 방식은 알아서 잘 동작하게만 진행하면 된다!

개선 가능성


지금까지 구현한 코드의 경우 굉장히 잘 작성되었고, 객체 사이에 캡슐화가 잘 되어있는 것 처럼 느껴진다. 그렇기 때문에 JwtHelper 나, UserService의 구체적인 구현 내용이 변경되어도 KakaoLoginService, KakaoLoginClient는 이를 모른다.

하지만 이런 객체의 구현 내용에 대해서는 캡슐화가 잘 되어 있지만, 객체 존재에 대해서는 추상화가 되지 않았다. 왜냐하면 제목부터 알 수 있듯이 너무 많은 객체가 kakao에 대해서 알고 있다. 곧, naver라는 새로운 방식이 등장함에 따라 kakao에 대해 알고 있는 객체는 모두 변경의 가능성이 있다는 얘기다.

다음에는 이번 글에 이어서 해당 코드에 캡슐화를 제대로 진행하여 Oauth를 적용해보자. 새로운 로그인 체계가 추가되었을 때 얼마나 많은 변경이 일어나는지 현재 코드와 비교해서 생각해보자.

0개의 댓글