복사되었습니다.

카카오 로그인 애플 로그인 보안 - Frontend를 고려하는 안전한 소셜 로그인 개발하기

Cover Image for 카카오 로그인 애플 로그인 보안 - Frontend를 고려하는 안전한 소셜 로그인 개발하기

소셜 로그인 기능 구현은 공식 문서에 잘 안내가 되어 있지만, 보안과 관련된 이슈까지 상세히 안내해주지는 않는다. 백엔드 개발자 입장에서 프론트엔드까지 고려하여 편의성과 보안성을 갖춘 소셜 로그인 기능을 개발하는 방법에 대해 알아보자.

크로스 플랫폼의 소셜 로그인 기능은 어떻게 구현하지?

디프만 17기 4팀의 kkruk 서비스를 개발하는 과정에서 처음에 web 프로젝트로 시작했다가 react native를 통해 모바일 환경까지 지원하는 방향으로 결정되었다. 크로스 플랫폼을 지원하는 백엔드를 개발해야 하다보니 소셜 인증 개발에 혼란이 있었다. 인증 서비스 업체에서 제공하는 공식 문서에서 설명하는 방식, OAuth 2.0의 표준 방식, 그리고 다양한 개발 블로그에 여러가지 설명들이 나와있지만 한 호흡으로 명쾌하게 정리해주는 곳은 없었다.

이참에 카카오 로그인과 애플 로그인을 구현하면서 겪은 시행착오들과 그 과정에서 얻은 인사이트를 바탕으로 최대한 간단하게 소셜 인증 구현 방식에 대해서 정리해보고자 한다. 인증 분야와 관련된 약간의 배경지식이 필요하겠지만, 가볍게 읽어두면 꿈에 그리는 크로스 플랫폼 서비스 구현에 한 발자국 다가갈 수 있을 것이라고 믿는다.

인증 주체: 카카오 로그인과 애플 로그인 책임 떠맡아주기

소셜 로그인을 제공하는 업체마다 조금씩 차이는 있지만 큰 틀에서는 OAuth 흐름이 대부분 동일하게 구현되어 있다. 가장 정리가 잘 되어 있다고 생각하는 카카오 공식 문서의 다이어그램을 토대로 이해해보자. 다소 복잡해보이지만 핵심은 카카오 인증 서버로부터 "인가 코드"라는 것을 받아오는 것이다. 여기서 프론트엔드 입장을 고려하여 두 가지 포인트 정도를 짚고 넘어가자.

kakao oauth sequence diagram1

  • 최초 인가 코드 요청을 하는 주체
  • 인가 코드를 전달받는 주체

Client secret은 일종의 비밀번호같은 개념이다. 서버에서 관리하는 것이 보안 측면에서 유리하다. 모바일 환경의 경우 사용자 단말기에 client secret같은 값을 저장하는 것은 보안 리스크가 있다. 이러한 배경으로 인해 프론트엔드 개발자들로부터 최초로 카카오에 인증 코드 요청을 누가하는가에 대한 질문을 종종 받는다. 클라이언트 측에서도 여러 SDK들이 존재하고 인가 코드 요청 자체는 client secret이 필요없기 때문에 혼란스러울만 하다.

위 다이어그램처럼 카카오에서는 백엔드가 최초 인가 코드 요청을 하도록 가이드하고 있다. 개인적으로도 백엔드가 하는 것이 맞다고 생각한다. 인가 코드 요청 엔드포인트를 호출할 때 여전히 client ID는 필요하다. 못생긴 client ID가 프론트엔드와 백엔드 코드 여기저기에 뿌려져 있는 것을 보면 마음이 불편하다. 백엔드 한 곳에서 관리하고 프론트엔드는 이 값을 관리할 필요가 없도록 만드는 것이 편의성이나 유지보수 측면에서도 유리하다.

인가 코드를 전달받는 주체는 백엔드이다. 카카오 공식 문서에서도 인가 코드 요청 API에서 redirect_uri 필드에 대해 "인가 코드를 전달받을 서비스 서버의 URI"라고 명시하고 있다. 왜일까? 답은 간단하다. 이 code 값은 token으로 교환하기 위한 재료인데, token 교환 API는 code와 함께 client secret을 요구하기 때문이다.

물론 프론트엔드에서 직접 code를 받은 뒤에 다시 이를 그대로 서버에게 전달하는 방법도 있겠지만, 결국 code의 실제 사용 주체는 백엔드이기 때문에 이는 불필요한 통신 비용만 증가시키는 셈이다. 프론트엔드에서도 code를 직접 만질 수 있는데 이는 뒤에서 자세히 설명하겠다.

kakao oauth sequence diagram2

백엔드가 code를 사용하는 주체인 이유는 client secret 필요 유무 이외에 한 가지 더 있다. 결국 code를 통해 교환한 token은 카카오 서비스의 token이지 "우리 서비스"의 token이 아니기 때문이다. 개발하는 서비스에서 자체 회원 관리를 해야 한다.

쉽게 말해서, kakao token은 단순히 카카오 로그인에 성공했는지, 그리고 카카오에서는 어떤 닉네임이나 이메일을 가지고 있는지 정도의 간단한 정보만을 확인하는 용도이다. 실제 자체 서비스에 회원으로 가입시켜서 회원 database에 등록해주고 access token, refresh token같은 JWT token을 발급해주는 등의 작업은 별도로 백엔드에서 추가 개발해야 한다는 뜻이다. 위 다이어그램에서도 "서비스 회원 정보 확인 또는 가입 처리"라는 말에 이 내용이 함축되어 있다.

애플 로그인도 크게 다르지 않다. 한 가지 차이점은 카카오 로그인의 경우 auth code를 302 redirect 응답을 통해 GET 요청으로 서버에 전달해주는데, 애플 로그인은 POST 요청으로 전달해준다는 것이다. 따라서, 백엔드 서버에서는 auth code를 받아줄 callback API를 GET과 POST에 대해 모두 열어두어야 한다.

쿠키 보안: 프론트엔드에게 인증 결과 전달하기

정확한 위치로 전달하는 방법

카카오 또는 애플 서비스와의 통신을 마치고 자체 서비스의 JWT 토큰을 백엔드에서 발급한 후 이를 프론트엔드 쪽에 전달한다면 우선 간단한 소셜 로그인 개발은 끝난 것이다. 그런데, 프론트엔드에 토큰을 전달하는 방법이 보안 측면에서 매우 중요하다. 이는 소셜 로그인의 영역을 벗어나 백엔드와 프론트엔드 사이의 소통 문제이므로 위 카카오 다이어그램에서도 "로그인 완료"라는 문구로 모호하게 표현하고 있다. 올바른 전달 방법은 무엇일까?

여기까지 잘 따라왔다면 한 가지 의문이 남을 것이다.

"Callback API는 카카오 또는 애플 인증 서버에서 호출하는 것인데, 백엔드의 callback API에서 다시 프론트엔드로 값을 어떻게 전달하지?"

이 고민이 생겼다면 당신은 모바일과 웹을 모두 지원하는 멀티 플랫폼 서비스를 개발하고 있을 확률이 높다. 보통 프론트엔드가 단일 웹 서비스로 구성되어 있다면, 해당 웹 서비스의 특정 주소를 백엔드에 고정으로 설정해두면 된다. 그러면 백엔드의 callback API가 호출될 때마다 고정된 주소로 인증 값들을 전달해주기만 하면 끝난다. 302 redirect 응답을 활용하면 웹 브라우저를 통해 GET 방식으로 특정 주소에 값을 전달할 수 있기 때문이다.

그런데, 모바일 브라우저를 지원해야 하거나, 개발 환경에서 localhost를 사용해서 테스트하거나, 실제 웹 서비스 주소가 바뀌는 경우 이를 백엔드 서버 코드에도 반영해주어야 하기 때문에 이러나 저러나 동적으로 프론트엔드 주소를 변경할 수 있도록 유연하게 만들 필요가 있다.

Kkruk 프로젝트에서는 이 문제를 해결하기 위해 "state"라는 필드를 활용했다. State는 카카오 공식 문서에서 "카카오 로그인 과정 중 동일한 값을 유지하는 임의의 문자열(정해진 형식 없음)"로 정의하고 있다. 이는 카카오 외에 다른 인증 서비스에서도 동일하게 사용되는 OAuth 2.0 표준이다. 백엔드에서 최초 인증 코드 요청 API 호출 시 이 state 값에 임의의 문자열을 담아서 보내면, 카카오 또는 애플 인증 서버는 auth code와 함께 이 state 값을 그대로 백엔드 callback API 호출을 통해 되돌려준다.

따라서, 최초 프론트엔드에서 백엔드에 로그인 요청을 보낼 때, 자신이 최종 token을 받고자 하는 위치를 parameter로 전달하도록 구현하면 된다. 서버는 이 parameter를 해석하여 state에 redirect할 위치를 포함시킨 뒤 카카오 또는 애플 인증 서버에게 그대로 전달만 해주면 된다. 그러면 callback API에 이 값이 그대로 돌아올 것이기 때문에, state 값을 파싱해서 302 redirect 응답을 통해 적절한 프론트엔드 주소로 token을 보내줄 수 있게 된다.

주의할 점은 이 state에 모든 URL을 허용하면 보안 구멍이 될 수 있다는 점이다. 누군가가 악의적으로 비정상적인 URL을 입력한다면 엉뚱한 곳에 보안 토큰이 전달될 수 있기 때문에 조심해야 한다. 이를 방지하기 위해서 full URL이 아니라 partial path만 허용하거나, 화이트리스트를 관리하는 등 백엔드에서 추가적인 보안 조치를 해야 한다. 그리고 이 값은 원래 제 3자가 인증 과정을 가로채는 것을 방지하기 위한 값이므로 목적에 맞게 랜덤 문자열을 포함하여 CSRF 공격 감지용으로 사용하는 것도 잊지말자.

안전하게 전달하는 방법

JWT 방식을 사용한다면 access token과 refresh token 둘 다 외부에 노출되면 위험한 값들이기 때문에 아무렇게나 전달하면 보안 이슈가 발생하게 된다. 우선 앞서 살펴봤듯이 callback API를 통해 백엔드 서버가 자체 토큰을 발급하고 나서 302 redirect 응답을 통해 프론트엔드에 토큰을 전달한다는 점에 주목해보자.

HTTP 302 응답은 기본적으로 GET 메서드이다. Response body에 값을 담아도 프론트엔드 측에서는 브라우저를 통해 전달받을 방법이 없다. 값을 전달하려면 query parameter를 사용해야 하는데, 이 경우 URL에 token 정보가 노출되기 때문에 보안에 취약하다. 따라서, Set-Cookie 헤더를 통해 토큰을 전달해야 한다.

그런데, 브라우저에서는 특수한 보안 조건이 있어서 그냥 Set-Cookie 헤더에 값을 포함한다고 해서 무조건 전달해주지 않는다. 다양한 값들을 섬세하게 설정하여 전달해주어야 한다. 대표적으로 신경써야 하는 부분들만 추려서 살펴보자.

  • Domain: 어떤 도메인에서 쿠키가 유효한지 결정하는 값이다.
  • HttpOnly: XSS 방어를 위해 JS에서 접근하는 것을 차단한다는 의미이다.
  • SameSite: Cross-Site 요청에도 쿠키 전송을 허용할지 여부를 설정하는 값이다.
  • Secure: HTTPS 연결에서만 전송한다는 의미이다.

보안상 가장 안전한 방법은 HTTPS 연결을 설정한 동일한 도메인에 프론트엔드와 백엔드를 모두 배포해두는 것이다. 하지만, 다양한 상황에 맞게 배포 환경을 다르게 구성한다면, 브라우저에서는 다음과 같은 상황에서 쿠키 사용이 제한될 수 있기 때문에 각별한 주의가 필요하다.

  • Secure가 설정되지 않은 상태로 SameSite=None인 경우 현대 브라우저들은 대부분 쿠키를 차단해버린다. 따라서, 원격지에 백엔드를 띄워놓은 경우 https를 설정해두지 않는다면 cookie 교환에 실패한다.
  • SameSite=Lax인 경우 Cross-Site 요청이라도 GET에 대해서는 허용하지만 POST에서는 쿠키를 사용할 수 없게 된다. 따라서, 추후 access token을 재발급하기 위한 POST refresh같은 API를 호출할 때 문제가 발생한다.

위험한 우회 방식: 한 걸음 더 다가가기

쿠키까지 사용하여 프론트엔드에 인증 토큰이 잘 전달되었다면 OAuth 2.0 표준에 어긋나지 않게 소셜 로그인 기능이 잘 구현된 것이라고 볼 수 있다. 그런데, 문제는 모바일 WebView 환경까지 고려하는 경우이다. "쿠키 지옥"에 빠질 확률이 높다.

모든 케이스를 직접 테스트해보지는 않았지만, 모바일 브라우저에서는 쿠키에 대한 제한이 웹 브라우저에 비해 더욱 까다롭다고 한다. SameSite=None인 경우의 쿠키는 아예 차단해버리는 등의 흉흉한 소문이 들린다. Kkruk 프로젝트에서도 "웹에서는 되는데 모바일에서는 로그인이 안된다"는 원성이 들렸다.

"프론트엔드와 백엔드의 인프라 구성을 제대로 해놓고 제한된 환경 안에서 테스트를 해봐야 한다"라는 말로 둘러대기에는 시간이 없다! 초기 개발 단계에서는 보통 빠른 개발 속도를 위해 코드 작업에 열중하기 때문에 인프라 구성까지 함께 신경쓸 여유는 많지 않다.

다양한 환경을 고려했을 때 쿠키를 활용할 수 없는 조건도 고민해봐야 한다는 의미다. 그런데, 앞서 언급했듯이 백엔드의 callback API가 호출되었을 때 프론트엔드에 302 redirect 응답 방식을 사용하여 값을 전달해주기 때문에 인증 토큰을 안전하게 전달할 수 있는 방법이 제한적이다.

코드 수정을 크게 하지 않으면서 쿠키를 사용하지 않으려면 response body에 JSON 형식으로 access token과 refresh token을 내려주는 방식이 가장 직관적이다. 이는 302 redirect로 구현할 수 없기 때문에 POST tokens같은 API가 백엔드에 준비되어 있어야 한다.

OAuth 2.0 표준과는 딱 맞아 떨어지지 않지만, 약간의 꼼수를 통해 문제를 해결할 수 있다. 백엔드 서버가 프론트엔드 입장에서 일종의 카카오 또는 애플 인증 서비스인 것처럼 보이도록 만드는 것이다. 앞선 표준 과정에서 백엔드의 callback API가 호출되었을 때, 302 redirect 응답을 통해 일종의 auth code같은 값을 프론트엔드 쪽에 전달해주는 것이다. 이 값은 원래의 auth code와 마찬가지로 짧은 유효기간을 가지고 있어야 하며 요청 클라이언트마다 고유한 값이어야 한다.

프론트엔드는 이 auth code를 백엔드 서버의 POST tokens API에 전달하여 별도의 쿠키 설정 없이 access token과 refresh token을 JSON body 형식으로 받아가면 된다. 한 번 사용된 auth code는 폐기해야 한다.

이런식으로 구현하면 OAuth 2.0의 auth code 기능 자체를 재구현하는 것이나 다름없다. 따라서, 꼼수로 백엔드 서버가 받은 auth code를 그대로 프론트엔드 측에 전달해서 사용하면 별도로 구현할 필요가 없어서 편리하다. 다만, 이는 auth code의 원래 사용 의도와 다르므로 지양해야 한다. 부득이하게 사용해야 한다면 암호화를 통한 보안 강화를 필수적으로 동반해야 한다.

개발 초기에는 백엔드와 프론트엔드가 서로 다른 환경에 배포되어 있다보니 쿠키 문제가 발생해서 이러한 우회 방식을 고려할 수도 있다. 그러나, 되도록이면 쿠키 방식이 정상적으로 동작하도록 네트워크 환경을 제대로, 즉, SameSite=Strict 설정이 가능하도록 구성해야 한다.

PKCE: 한 번 쯤은 튕겨보기

앞서 살펴본 우회 방식의 보안 위험에서 벗어날 수 있는 OAuth 2.0 표준 인증 방법이 사실 따로 존재한다. PKCE라는 것을 활용해서 직접 프론트엔드 측에서 auth code를 다루는 방식이다. PKCE라는 것은 Proof Key for Code Exchange의 약자로, 이름 그대로 auth code를 교환할 때 보안 위험을 줄이기 위한 노력이 깃들어있다.

프론트엔드 측에서 client secret을 저장하고 직접 사용하는 것은 위험하기 때문에 이 값을 모르는 상태로 auth code를 다룰 수 있어야 한다. 이때 꼼수 방식의 중간자 공격 문제를 해결하기 위해 auth code를 제 3자가 탈취하더라도 쓸모없게 만드는 장치가 필요하다. PKCE는 code_verifier, code_challenge라고 불리는 값들을 활용하여 auth code가 유효한지 추가로 검증하는 절차를 진행한다. PKCE에 대한 자세한 동작 원리는 다른 글에서 다루겠다.

중요한 사실은 카카오 공식 문서에서는 PKCE에 대한 가이드를 제공하지 않는다는 것이다. 떠돌아다니는 일부 온라인 자료에 의하면, PKCE가 필요한 경우는 client secret을 사용하지 않는 모바일 환경이 거의 대부분이기 때문에 "SDK를 사용하라"라는 안내만 남겨두었다고 한다. 모바일 SDK 구현체에서 내부적으로 PKCE를 활용하고 있기 때문에 웹 브라우저 기반 소셜 로그인 기능 개발 안내 문서에서는 관련 내용을 생략한 것이다.

간단히 줄이면, 모바일 환경의 경우 직접 코드에 보이진 않지만 SDK를 통해 PKCE 방식으로 auth code flow가 구현되어야 쿠키로부터 자유로울 수 있다. 그런데, 공식 문서에 의하면 SDK는 code만 발급받는 것이 아니라 토큰까지 직접 교환해준다는 사실을 확인할 수 있다.

카카오 인증 서버가 지정된 리다이렉트 URI로 인가 코드를 보내면, Android SDK가 인가 코드를 받아 토큰 요청을 호출합니다. 자세한 과정은 이해하기를 참고합니다.

동의 화면에서 사용자가 모든 필수 항목에 동의하고 [동의하고 계속하기]를 선택하면, iOS SDK는 카카오톡에서 서비스 앱으로 돌아와 아래 단계인 인가 코드 발급과 토큰 발급을 처리하고 카카오 로그인을 완료합니다.

이는 백엔드에서 자체 회원 관리를 해야 하는 경우 token exchange 개념의 API가 필요하다는 의미가 된다. 쉽게 말해서, 카카오 토큰 또는 애플 토큰 등 provider마다 다른 토큰을 입력으로 받아서 "우리 서비스" 자체 토큰을 발급해주는 창구가 필요하다는 것이다. 만약 일부 SDK가 auth code까지만 발급해준다면, 앞선 우회 방식처럼 auth code를 입력받아서 자체 토큰을 발급해주는 API가 별도로 필요할 수도 있다. 백엔드 개발 부담이 증가한다.

SDK를 활용한 방식은 프론트엔드의 개발 부담도 증가시킨다. 기존 웹 브라우저 방식에서는 아무런 정보도 없이 단순히 백엔드에 로그인 API만 한 번 호출해주면 되지만, SDK를 사용하는 순간 관련 패키지들을 설치하고 관리해야 한다. SDK 설정을 위해 키 해시를 등록하고 카카오 서버 측에도 관련 설정들을 반영해주어야 한다. 안드로이드와 iOS 각각 진행해야 하고, 애플 로그인에 대해서도 별도로 처리를 해주어야 한다.

번거롭지만 보안 측면을 신경쓴다면 PKCE 방식을 준수하도록 프론트엔드와 백엔드 개발자 모두 힘써야 한다. 초기 MVP 개발에 집중할 때 각 provider 관련 세부 설정들을 모두 신경쓰기는 어려운 것이 사실이다. 그래도 크로스 플랫폼의 다양성을 추구한다면, 다소 마찰이 생기더라도 프론트엔드와 백엔드 개발자 사이의 적극적인 소통을 통해 보안 위험들을 최소화하는 것이 중요하다.

마치며

다양성과 유연성을 추구하다보면 보안 구멍이 생기기 마련이다. 앞서 언급했듯이 가장 좋은 것은 프론트엔드와 백엔드 서버를 같은 네트워크 환경에 배포해두고 Cross-Site 요청을 최소화하는 것이다. 최근 진행한 kkruk 프로젝트에서는 처음에 web 프로젝트로 시작했다가 react native를 통해 모바일 환경까지 지원하는 방향으로 결정되어서 소셜 인증 개발에 혼란이 있었다.

웹 브라우저 기반 auth code flow를 출발점으로 확장성있는 인증 흐름을 구현하다보니 모바일 환경에서의 제약을 잘 반영할 수 없었다. MVP 구현을 위한 바쁜 일정 때문에 인증과 관련된 기능들을 보안성까지 고려하여 프론트엔드 개발자들과 함께 천천히 논의하는 것이 쉽지 않았다.

이번 작업을 진행하면서 보안 관련 이슈들은 코드 수정만으로는 모두 해결하기 어렵고, 네트워크 설정, 브라우저 동작 방식, 쿠키 문제 등 인프라적인 요소들을 많이 신경써야 한다는 것을 다시 한 번 느꼈다. 그리고 모바일 환경까지 고려한 크로스 플랫폼 서비스를 개발할 때 각 환경마다 어떤 방식으로 소셜 로그인을 구현해야 하는지 인사이트를 얻게 된 좋은 경험이었다.

Buy Me A Coffee

Comments

    More Posts

    Telepresence로 kubernetes 도메인 localhost에서 훔쳐쓰기

    Kubernetes에 배포된 pod들끼리 통신을 할 때는 클러스터 도메인 주소를 사용한다. Web app 개발 시 로컬에서는 localhost 주소를 주로 사용하기 때문에 다른 pod들과의 통합 테스트를 하기가 어렵다. Telepresence로 로컬에서 kubernetes의 traffic을 훔치는 방법을 알아보자.

    Python으로 kubernetes 노드 선택 기능을 개발하는 방법

    클러스터를 구성하면 목적에 따라 노드의 역할을 구분하는 경우가 많다. 따라서, kubernetes에서 pod를 특정 노드에만 배포해야 한다는 제약사항은 당연히 나올 수 밖에 없다. Python을 통해 이를 구현하는 방법과 고려해야 할 점들에 대해 알아보자.

    Python FastAPI로 파일 업로드 및 다운로드 가능한 web server 개발하기

    FastAPI를 활용하면 파일을 업로드하거나 다운로드할 수 있는 web server를 매우 간단하게 구현할 수 있다. 예제 코드와 함께 최대한 간단하게 파일 업로드 및 다운로드 API를 구현하고 테스트하는 방법을 알아보자.

    Font Size