1편에서 브라우저는 은행이 진짜라는 걸 확인했고, 둘만 아는 키로 대화할 통로를 만들었다. 그런데 은행은 아직 내가 누군지 모른다. 이번 편은 은행이 나를 확인하고, 확인한 걸 계속 기억하는 방법이다.
TLS가 끝나면 브라우저와 은행 사이에 안전한 통로가 생긴다. 하지만 은행이 아는 건 "누군가와 안전한 통로가 생겼다"까지다. 그 누군가가 김상필인지, 내 비밀번호를 훔친 다른 사람인지는 모른다.
1편이 "이 사이트가 진짜일까?"였다면, 이번 편은 "이 사람이 진짜일까?"다.
| 누가 누구를 확인하나 | 무엇으로 | |
|---|---|---|
| 1편 | 브라우저가 은행을 | 인증서와 은행 서명 |
| 2편 | 은행이 나를 | 비밀번호, 그다음은 세션이나 토큰 |
확인은 두 단계로 나뉜다. 처음 한 번 "너 누구야"를 확인하는 단계(로그인), 그리고 그 뒤로 요청이 올 때마다 "아까 그 사람 맞다"를 이어 가는 단계(세션이나 토큰)다.
Django는 비밀번호를 이런 모양으로 저장한다. 방식, 반복 횟수, 솔트, 해시값이 $로 나뉘어 한 줄에 들어간다.
pbkdf2_sha256$(반복 횟수, 수십만~백만)$(사람마다 다른 솔트)$(해시값)
헷갈리기 쉬운 점이 하나 있다. 비밀번호는 통로 안에서는 원문 그대로 간다. 해시는 은행이 저장하고 비교할 때 하는 일이다. 가는 길은 TLS가, 저장된 값은 해시가 지킨다.
웹(HTTP)은 요청 하나하나를 따로 처리한다. 방금 로그인한 사람이 다음 페이지를 열어도, 서버 입장에서는 처음 보는 요청이다. 그렇다고 페이지를 열 때마다 비밀번호를 칠 수는 없다.
그래서 로그인에 성공하면 "나 아까 로그인한 그 사람이야"를 보여 줄 무언가를 받아 두고, 요청할 때마다 그걸 같이 보낸다. 그 무언가에 무엇을 담느냐에 따라 두 가지로 나뉜다.
팔찌에는 번호만 있다. 누가 무엇을 샀는지는 카운터 장부에 있다. 서버가 기억한다.
손목띠에 내용이 다 인쇄돼 있고 매표소 도장이 찍혀 있다. 들고 다니는 쪽이 증명한다.
찜질방에 들어갈 때 계산을 하면 번호가 적힌 팔찌를 준다. 팔찌에는 번호만 있고 이름은 없다. 매점에서 식혜를 사면 팔찌 번호만 보여 주고, 카운터 장부에 "37번 식혜 하나"가 적힌다. 나갈 때 정산하면 37번은 장부에서 지워진다.
| 찜질방 | 세션 |
|---|---|
| 입장할 때 계산 | 로그인 |
| 팔찌 번호 | 세션 ID (아무 의미 없는 무작위 문자열) |
| 카운터 장부 | 서버의 세션 저장소 (Django는 django_session 테이블) |
| 매점에서 팔찌 보여 주기 | 요청할 때마다 쿠키로 세션 ID를 같이 보냄 |
| 나갈 때 정산 | 로그아웃, 장부에서 삭제 |
| 떨어진 팔찌를 주운 사람 | 세션 ID를 훔친 사람 |
팔찌 번호만 봐서는 누구 것인지 알 수 없다. 의미는 전부 카운터 장부에 있다. 그래서 세션을 "서버가 기억하는 방식"이라고 한다. 거꾸로 말하면, 장부에서 지우는 순간 그 팔찌는 바로 쓸모가 없어진다.
| 세션 ID | 누구 | 만료 |
|---|
"번호표를 훔치려면 통로 안에서 훔쳐야 되는데, 그걸 못 하게 키를 어렵게 만든 거잖아"
통로 안에서 훔치는 건 TLS 덕분에 거의 불가능하다. 여기까지는 맞다. 그런데 세션 ID는 통로 안이 아니라 통로의 양 끝, 그리고 통로가 없는 순간에 털린다.
TLS는 현금 수송차다. 운송 중에는 안전하다. 하지만 집에 도둑이 드는 것(브라우저 안), 수송차 없이 돈을 들고 걷는 순간(http), 누가 나인 척 심부름을 보내는 것(CSRF)은 수송차가 막을 수 없다. 쿠키 옵션은 집 보안이다.
| 장부의 세션 ID | 누구 | 만료 |
|---|
| 공격 | 어떻게 | 막는 것 |
|---|---|---|
| XSS | 게시글 같은 곳에 심어 둔 스크립트가 내 브라우저에서 실행되어, 이미 풀린 쿠키를 읽어 간다 | HttpOnly, 그리고 스크립트가 못 들어오게 입력값 처리 |
| http 노출 | http:// 로 접속하게 만들면 암호화 없는 요청에 쿠키가 그대로 실린다 | Secure, HTTPS 강제 |
| CSRF | 훔치지 않고 이용한다. 해커 사이트가 내 브라우저로 은행에 요청을 보내면 쿠키가 알아서 같이 간다 | SameSite, CSRF 토큰 |
| 악성코드 | PC가 감염되면 브라우저 저장소를 통째로 가져간다 | 짧은 세션 만료, 중요한 작업 전 비밀번호 다시 묻기 |
HttpOnly는 세션 ID를 가져가는 것만 막는다. 스크립트가 내 화면 안에서 요청을 대신 보내는 것까지는 못 막는다. 그래서 스크립트가 아예 못 들어오게 막는 게 먼저다. Django 로그인 폼에 숨어 있는 csrftoken은 CSRF를 한 번 더 막는 장치다. 해커 사이트는 이 값을 알 수 없어서, 쿠키가 같이 가더라도 요청이 거부된다.
놀이공원 매표소에서 표를 사면 손목띠를 채워 준다. 띠에는 "성인, 자유이용, 10월 26일까지"가 인쇄돼 있고, 매표소만 찍을 수 있는 도장이 있다. 놀이기구 직원은 매표소에 전화해서 장부를 확인하지 않는다. 띠에 적힌 내용과 도장만 보고 통과시킨다.
| 놀이공원 | 토큰 (JWT) |
|---|---|
| 매표소 | 로그인 서버 |
| 띠에 인쇄된 내용 | 누구, 권한, 만료 시각 |
| 매표소 도장 | 서명 (서버 비밀키로 계산한 값) |
| 놀이기구 직원 | API 서버, 영상 서버 |
| 띠만 보고 통과 | 장부를 보지 않고 서명만 확인 |
| "10월 26일까지" | 만료 시각 (exp) |
세션은 번호만 주고 내용은 장부에 두었다. 토큰은 반대로 내용을 다 적고, 고치지 못하게 서명한다. 1편의 서명, HMAC과 같은 원리다.
JWT는 점(.)으로 나뉜 세 덩어리다. 아래는 이 페이지가 방금 실제로 계산해서 만든 토큰이다.
머리와 내용은 암호화한 게 아니다. base64url이라는 방식으로 글자만 바꿔 적은 것이라 누구나 되돌려 읽을 수 있다. 위 가운데 칸이 바로 되돌린 결과다. 서명은 못 고치게 할 뿐 못 보게 하지는 않는다. 비밀번호 같은 값은 절대 넣으면 안 된다.
해커가 된 셈 치고 "viewer"를 "admin"으로 바꿔 보자. 해커는 서버 비밀키가 없으니 서명은 원래 것을 그대로 쓸 수밖에 없다.
놀이공원 손님이 띠를 잃어버렸다. 매표소가 모든 놀이기구 직원에게 "이 띠는 무효"라고 일일이 알리기는 어렵다. 직원들은 원래 띠만 보고 통과시키니까. 토큰도 똑같다. 서버에 장부가 없어서 "이 사람 토큰만" 골라서 막을 방법이 없다.
"다른 사람들한테도 동일한 서명으로 발급해 줬으니 다시 다 발급해야 하는 거 아닐까?"
반은 맞다. 서명 키를 바꾸면 정말 막힌다. 놀이공원이 도장 모양을 바꾸는 셈이라, 옛 도장이 찍힌 띠는 전부 무효가 된다. 대신 전 직원이 한꺼번에 로그아웃된다. 그래서 최후의 수단이고, 평소에는 아래 다른 방법을 섞어 쓴다.
| 사람 | 정지 직후 요청 | 15분 뒤 요청 |
|---|
토큰이 털리거나 정지해야 할 때 피해를 줄이려면 수명을 짧게 하면 된다. 하지만 15분마다 비밀번호를 다시 치게 할 수는 없다. 그래서 토큰을 두 개 준다.
| 액세스 토큰 | 리프레시 토큰 | |
|---|---|---|
| 수명 | 짧게 (5~15분) | 길게 (며칠~몇 주) |
| 쓰는 곳 | 매 요청 | 액세스 토큰이 만료됐을 때 새로 받는 용도로만 |
| 서버가 기억하나 | 안 함. 서명만 확인 | 함. 목록에 두고 폐기할 수 있음 |
| 털리면 | 최대 15분 | 서버가 폐기하면 그때 끝 |
결국 리프레시 토큰 쪽은 장부를 다시 두는 셈이다. 토큰도 "장부가 전혀 없는" 방식은 아니고, 장부를 보는 횟수를 15분에 한 번으로 줄인 방식에 가깝다.
| 세션 | 토큰 (JWT) | |
|---|---|---|
| 비유 | 찜질방 팔찌와 카운터 장부 | 놀이공원 자유이용권 |
| 내용이 있는 곳 | 서버 장부 | 토큰 안 |
| 서버가 확인하는 법 | 장부 조회 | 서명 확인 |
| 즉시 로그아웃 | 장부에서 지우면 바로 | 만료 전까지 어려움 |
| 서버가 여러 대일 때 | 장부를 함께 써야 함 | 서명 키만 있으면 됨 |
| 잘 맞는 곳 | 브라우저로 쓰는 웹사이트 하나 | 모바일 앱, 외부에 여는 API, 여러 서비스 |
브라우저로 쓰는 사이트 하나이고, 권한을 바로 회수해야 하는 관제 시스템이라면 세션이 구조적으로 맞다. "요즘은 JWT"라는 이유만으로 바꾸면 오히려 손해일 수 있다.
| 구간 | 방식 | 왜 |
|---|---|---|
| 사이트 로그인 | Django 세션. sessionid 쿠키가 팔찌, django_session 테이블이 장부, csrftoken이 CSRF 방어 | 사람 계정은 정지하면 바로 막혀야 한다 |
| 영상 서버 접속 | 짧게 만료되는 서명 토큰 | 요청이 많아서 매번 장부를 보지 않고 서명만 확인하는 편이 빠르다. 대신 수명을 짧게 둔다 |
로그인 주소 /login/?next=%2F는 Django의 기본 모양이다. "로그인이 끝나면 원래 가려던 /로 보내 달라"는 뜻이다.
sessionid, csrftoken이 있는지sessionid에 HttpOnly, Secure, SameSite가 켜져 있는지 (Django 설정 SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE)SESSION_COOKIE_AGE)한 문장으로: 세션은 서버가 기억하고, 토큰은 들고 다니는 쪽이 증명한다.