목차
일정 관리 앱에 “구글 캘린더 연동”을 눌렀는데, 앱은 구글 비밀번호를 묻지 않는다.
비밀번호를 안 받고 남의 데이터를 읽는다
일정 관리 앱을 하나 만들었다고 하자. 사용자의 구글 캘린더를 읽어와서 화면에 같이 보여주고 싶다.
가장 단순한 방법은 이렇다.
POST /connect/google HTTP/1.1
{ "email": "kim@gmail.com", "password": "구글 비밀번호" }사용자에게 구글 아이디와 비밀번호를 받아서, 우리 서버가 그걸로 구글에 로그인한다. 동작은 한다. 그리고 절대 이렇게 하면 안 된다.
그런데 실제 서비스들은 비밀번호를 안 받고도 남의 캘린더를 읽는다. 어떻게 하는지가 이 글의 내용이다.
비밀번호를 주면 무슨 일이 벌어지나
왜 안 되는지부터 정확히 보자. 문제가 보여야 해법이 보인다.
사용자가 우리 앱에 구글 비밀번호를 넘기면 이런 일들이 한꺼번에 딸려온다.
- 권한이 전부 넘어간다. 캘린더만 읽으면 되는데 지메일도, 드라이브도, 결제 정보도 다 열린다. 비밀번호는 범위를 나눌 수 없다.
- 회수할 방법이 없다. 앱을 그만 쓰고 싶으면 구글 비밀번호를 바꾸는 수밖에 없다. 그러면 그 비밀번호를 준 다른 앱도 같이 끊긴다.
- 앱이 비밀번호를 보관하게 된다. 매번 로그인하려면 어딘가 저장해야 한다. 그 앱이 뚫리면 사용자의 구글 계정이 뚫린다.
- 구글은 누가 부르는지 모른다. 요청이 사용자 본인인지 앱인지 구분할 수가 없다.
집 열쇠에 비유하면 딱 맞는다. 택배를 받아달라고 현관 열쇠를 통째로 주는 것이다. 택배기사는 안방에도 들어갈 수 있고, 열쇠를 돌려받으려면 자물쇠를 바꿔야 한다.
필요한 건 다른 물건이다. 현관문만, 오늘 하루만 열리는 임시 카드. 그리고 그 카드는 집주인이 직접 발급해야 한다.
OAuth 2.0은 그 임시 카드를 발급하고 전달하는 절차를 정한 규격이다. 이름에 붙은 Auth는 인가(authorization) 쪽이고, 정확히는 “사용자가 자기 권한 일부를 앱에 위임하는” 프레임워크다.
등장인물은 넷이다
절차를 보기 전에 누가 나오는지부터 정리한다. 이걸 안 정리하면 화살표가 헷갈린다.
| 이름 | 규격 용어 | 예시 |
|---|---|---|
| 자원 주인 | Resource Owner | 사용자 본인 |
| 앱 | Client | 우리가 만든 일정 관리 앱 |
| 인가 서버 | Authorization Server | 구글의 로그인·동의 화면 |
| 자원 서버 | Resource Server | 구글 캘린더 API |
핵심은 인가 서버와 앱이 분리돼 있다는 것이다.
이 그림에 이 규격의 전부가 들어 있다. 사용자의 비밀번호는 구글에만 간다. 앱은 비밀번호를 본 적이 없고, 대신 구글이 발급한 접근 토큰을 받는다. 앱은 그 토큰으로 허락받은 만큼만 캘린더를 만진다.
Authorization Code 흐름
이제 실제 절차다. OAuth 2.0에는 여러 흐름이 있는데, 웹·앱에서 사용자를 대신해 권한을 받는 표준 흐름은 Authorization Code 하나다. 이걸 끝까지 따라간다.
단계별로 실제 요청이 어떻게 생겼는지 보자.
앱이 사용자를 구글로 보낸다.
GET /o/oauth2/v2/auth
?client_id=our-app-123
&redirect_uri=https://ourapp.example.com/callback
&response_type=code
&scope=calendar.readonly
&state=r4nd0mscope가 “무엇을 허락받고 싶은가”다. redirect_uri는 “다 되면 여기로 돌려보내달라”이고, state는 앱이 만든 임의 값인데 돌아왔을 때 내가 시작한 요청이 맞는지 확인하는 데 쓴다. 그냥 되돌려받아 비교하는 게 아니라 사용자 세션에 저장해뒀다가 대조해야 뜻이 있다. 안 그러면 남이 시작한 인가 흐름의 결과를 내 계정에 붙여버리는 일이 생긴다.
사용자가 구글에서 로그인하고 동의한다. 이 화면은 구글의 것이고 앱은 관여하지 못한다. 화면에는 scope가 사람 말로 표시된다. “이 앱이 캘린더를 읽으려 합니다.”
구글이 사용자를 앱으로 돌려보낸다.
HTTP/1.1 302 Found
Location: https://ourapp.example.com/callback?code=xyz789&state=r4nd0m여기서 앱이 받은 건 토큰이 아니라 인가 코드(authorization code) 다. 짧게 살고 한 번만 쓸 수 있는 값이다.
앱이 코드를 토큰으로 바꾼다.
POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=xyz789
&redirect_uri=https://ourapp.example.com/callback
&client_id=our-app-123
&client_secret=서버만-아는-값이 요청은 브라우저가 아니라 앱 서버가 직접 보낸다. 응답으로 접근 토큰이 온다.
{
"access_token": "ya29.a0Af...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "calendar.readonly"
}앱이 토큰으로 API를 부른다.
GET /calendar/v3/events HTTP/1.1
Authorization: Bearer ya29.a0Af...인증과 인가는 다른 질문이다에서 본 그 Authorization: Bearer 헤더다. 접근 토큰이 JWT 형식인 경우도 있고 그냥 임의 문자열인 경우도 있다. OAuth 2.0은 토큰의 형식을 정하지 않는다.
왜 코드를 한 번 더 교환하나
여기서 대부분이 걸린다. 구글이 그냥 토큰을 바로 주면 되지, 왜 코드를 주고 다시 바꾸게 하나.
이유는 경로가 다르기 때문이다.
리다이렉트로 오는 값은 주소창에 찍히고, 브라우저 방문 기록에 남고, 중간 서버의 접근 로그에 남을 수 있다. 수명이 긴 접근 토큰을 그 경로로 보내면 여기저기 흘린다.
그래서 그 경로에는 금방 죽고 한 번만 쓰이는 코드만 보낸다. 진짜 토큰은 앱 서버가 직접 받아온다. 그리고 코드를 토큰으로 바꿀 때는 client_secret이 필요하다. 코드를 주웠어도 앱의 비밀 값이 없으면 토큰으로 못 바꾼다.
한 문장으로 줄이면 이렇다. 위험한 경로에는 값싼 것만 보내고, 비싼 것은 안전한 경로로 받는다.
PKCE - 비밀을 못 가진 앱을 위해
방금 설계에는 전제가 하나 있다. 앱이 client_secret을 숨길 수 있다는 것.
서버가 있는 웹 앱은 가능하다. 비밀 값을 서버에만 두면 된다. 그런데 이런 앱들은 못 한다.
- 모바일 앱 - 설치 파일 안에 넣으면 뜯어볼 수 있다
- 브라우저에서만 도는 앱 - 자바스크립트에 넣으면 그냥 보인다
비밀 값을 숨길 수 없으면 위 설계의 마지막 방어가 없어진다. 코드를 가로챈 사람이 그대로 토큰으로 바꿀 수 있다.
PKCE(Proof Key for Code Exchange) 가 이 구멍을 메운다. 발음은 “픽시”다. 발상은 단순하다. 미리 정해둔 비밀 대신, 이번 요청에서만 쓸 비밀을 앱이 그때그때 만든다.
세 줄로 요약된다.
- 앱이 임의 문자열(
code_verifier)을 만들고, 그걸 SHA-256으로 해시한 값(code_challenge)을code_challenge_method=S256과 함께 인가 요청에 보낸다 - 인가 서버는 코드를 발급하면서 그 challenge를 코드에 묶어 기억한다
- 코드를 토큰으로 바꿀 때 앱이 원본
code_verifier를 낸다. 해시해서 아까 것과 같아야 토큰을 준다
code_challenge_method에는 해시를 안 쓰고 원본을 그대로 보내는 방식(plain)도 규격상 있다. 그걸 쓰면 가로챈 사람이 challenge를 그대로 verifier로 되쓸 수 있어서 이 장치가 아무것도 안 막는다. S256을 쓴다.
코드를 가로챈 사람은 code_challenge(해시값)는 볼 수 있어도 원본을 되돌릴 수 없어서 교환 요청을 만들지 못한다. 요청을 시작한 그 앱만 원본을 안다.
PKCE는 원래 모바일 앱을 위해 나왔지만, 지금은 서버가 있는 웹 앱에도 함께 쓰라고 권장된다. client_secret을 대체하는 게 아니라 하나 더 얹는 것이다. 새로 만든다면 그냥 처음부터 켜두는 편이 낫다.
스코프는 넘기는 권한의 크기다
앞에서 “비밀번호는 범위를 나눌 수 없다”고 했다. OAuth 2.0이 그 문제를 푸는 도구가 스코프(scope) 다.
&scope=calendar.readonly이 값 하나가 세 군데에서 일한다.
- 동의 화면 - 사용자에게 “무엇을 허락하는지” 보여준다
- 토큰 - 발급된 토큰에 이 범위가 묶인다
- 자원 서버 - 요청이 그 범위 안인지 검사한다
calendar.readonly 토큰으로 일정을 추가하려 하면 자원 서버가 거절한다. 사용자가 허락한 범위 밖이기 때문이다. 읽기만 허락했으면 읽기만 된다.
여기서 스코프의 성격을 정확히 짚어야 한다. 스코프는 “사용자가 이 앱에 얼마나 위임했는가” 이지, “이 사용자가 무엇을 할 수 있는가” 가 아니다.
| 스코프 | 사용자 권한 | |
|---|---|---|
| 묻는 것 | 앱에 얼마나 위임했나 | 이 사람이 무엇을 할 수 있나 |
| 정하는 주체 | 사용자의 동의 | 시스템의 정책 |
| 담기는 곳 | 토큰 | 사용자 계정 |
둘 다 있어야 통과한다. 스코프에 calendar.write가 있어도 그 사용자가 원래 그 캘린더에 쓸 권한이 없으면 안 된다. 위임은 원래 있는 권한을 넘어설 수 없다. 이 두 축을 어떻게 표현하고 검사하는지는 다음 글에서 이어간다.
앱을 만들 때 스코프를 넉넉하게 요청해두고 싶은 유혹이 있다. 나중에 기능을 늘릴 때 다시 동의를 받기 귀찮기 때문이다. 그런데 넓은 스코프는 그 토큰이 새면 그만큼 피해가 커진다는 뜻이다. 그리고 동의 화면에 요구 목록이 길게 뜨면 사용자가 연동을 중단한다. 필요한 것만 요청하는 게 안전하고 유리하다.
다른 흐름들
Authorization Code 말고도 그랜트(grant, 토큰을 받아내는 방식)가 몇 개 있다. 전부 다룰 필요는 없고, 이런 게 있고 왜 안 쓰는지만 알면 된다.
| 그랜트 | 쓰임 | 상태 |
|---|---|---|
| Authorization Code | 사용자를 대신해 권한을 받는 표준 경로 | 기본값. PKCE와 함께 |
| Client Credentials | 사용자가 없는 서버 간 호출 | 유효. 배치·내부 연동에 쓴다 |
| Device Code | 입력이 불편한 기기(TV 등) | 유효. 다른 기기에서 코드를 입력한다 |
| Implicit | 리다이렉트로 토큰을 바로 받음 | 권장되지 않는다 |
| Resource Owner Password | 앱이 아이디·비밀번호를 직접 받음 | 권장되지 않는다 |
아래 둘이 왜 밀려났는지는 지금까지 본 것으로 설명된다.
- Implicit는 접근 토큰을 리다이렉트 경로로 바로 받는다. 앞에서 “위험한 경로에는 값싼 것만 보낸다”고 했던 원칙을 정면으로 어긴다. 브라우저 앱이
client_secret을 못 숨겨서 나왔던 방식인데, PKCE가 그 문제를 더 나은 방법으로 풀면서 존재 이유가 사라졌다. - Resource Owner Password는 앱이 사용자의 아이디와 비밀번호를 직접 받는다. 이 글이 처음에 “절대 하면 안 된다”고 했던 그 방식이다. 규격에 들어 있던 이유는 기존 시스템의 이행을 돕기 위해서였다.
여기까지가 인가의 이야기다. 그런데 “구글로 로그인”은 권한 위임이 아니라 로그인이다. OAuth 2.0 자체는 “이 토큰의 주인이 누구인가”를 표준으로 알려주지 않는다. 그 위에 인증을 얹어 표준화한 것이 OIDC(OpenID Connect) 이고, 신원을 담은 ID 토큰을 추가로 발급한다. 그래서 정확히 말하면 소셜 로그인은 OAuth 2.0이 아니라 그 위의 OIDC를 쓰는 경우가 많다.
실무에서 앱을 붙일 때
Spring Security를 쓰면 이 흐름 전체를 직접 구현할 일이 거의 없다. 설정으로 끝난다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: our-app-123
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: calendar.readonly
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"리다이렉트를 만들고, state를 검사하고, 코드를 토큰으로 바꾸고, 토큰을 저장하는 일을 프레임워크가 한다. 그렇다고 절차를 몰라도 되는 건 아니다. 붙이다 막히는 자리가 대체로 절차 위에 있기 때문이다.
redirect_uri불일치 - 인가 서버에 등록한 값과 요청에 넣은 값이 글자 하나까지 같아야 한다. 가장 자주 만나는 오류인데, 이건 불편한 규칙이 아니라 방어 장치다. 인가 코드가 어디로 돌아갈지를 인가 서버가 통제하는 것이라, 귀찮다고 와일드카드나 접두사 일치로 등록하면 코드가 공격자 주소로 배달될 길을 열어주게 된다.- 코드가 이미 쓰였다 - 인가 코드는 한 번만 유효하다. 콜백 페이지를 새로고침하면 같은 코드로 다시 교환을 시도해서 실패한다.
- 스코프가 모자라다 - 동의는 받았는데 나중에 기능을 늘리면 새 스코프로 다시 동의를 받아야 한다. 기존 토큰은 예전 범위 그대로다.
client_secret이 저장소에 들어갔다 - 이 값은 앱의 신원 증명이다. 환경 변수나 비밀 관리 도구로 뺀다.
그리고 앱을 만드는 쪽에서 잊기 쉬운 게 하나 더 있다. 사용자가 연동을 끊을 방법을 화면에 두는 것. 인가 서버(구글 계정 설정)에서도 끊을 수 있지만, 우리 앱에서도 끊을 수 있어야 한다. 그게 처음에 “회수할 수 없다”고 했던 문제의 실제 해답이다.
정리
| 푸는 문제 | 비밀번호를 넘기지 않고 권한 일부만 위임한다 |
| 등장인물 | 사용자 · 앱 · 인가 서버 · 자원 서버 |
| 핵심 | 비밀번호는 인가 서버에만. 앱은 토큰만 받는다 |
| 표준 흐름 | Authorization Code. 코드를 받아 토큰으로 교환 |
| 왜 두 단계 | 브라우저 경로엔 값싼 코드만, 토큰은 서버 간 직접 |
| PKCE | 비밀을 못 숨기는 앱을 위해 매번 새 비밀을 만든다 |
| 스코프 | 위임한 권한의 크기. 원래 권한을 넘어서지 못한다 |
| 안 쓰는 것 | Implicit · Resource Owner Password |
| OIDC | OAuth 2.0 위에 인증을 얹어 표준화한 것 |
권한을 넘길 때도 스코프라는 이름의 “무엇을 할 수 있나”가 나왔다. 그러면 우리 시스템 안에서 그 권한은 어떻게 표현하고 어디서 검사해야 하나. 다음 글에서 다룬다.