リフレッシュトークンのローテーションと再利用検知
한국어 원문으로 표시합니다.
목표
Redis 에 토큰 계열을 저장하는 작은 발급자를 세우고, refresh 를 쓸 때마다 회전하는지, 이미 쓴 refresh 가 다시 오면 그 계열 전체가 폐기되는지, access 가 짧게 사는지, 로그아웃이 계열을 끊는지를 실제 HTTP 요청으로 확인한다.
왜 중요한가
access 를 짧게 살리면 새어도 피해 창이 짧지만, 그 대가로 오래 사는 refresh 가 새로운 표적이 된다. 비밀을 숨길 수 없는 브라우저·모바일 앱에서 RFC 9700 은 refresh 를 클라이언트 키에 묶거나 회전을 쓰라고 요구한다. 회전의 힘은 재사용 탐지에서 나온다 — 토큰이 새서 공격자와 정상 클라이언트가 둘 다 쓰면 둘 중 하나는 반드시 이미 쓴 토큰을 내밀게 되고, 서버는 누가 공격자인지 모르므로 그 계열 전체를 끊는다. 이것이 동작하려면 쓴 refresh 를 지우지 않고 '썼음' 으로 남겨 두어야 하고, 그 표시는 원자적으로 해야 한다. 이 실습은 그 규칙들을 직접 짜고 스스로 증명하게 한다.
단계
/root/st/refresh/app.py를 127.0.0.1:8306 에 띄운다.GET /health가 200 과{"ok":true}를 준다. 저장은 Redis 에fam:<id>(user, state, current),rt:<토큰>(user, fam, uses),at:<토큰>(user, fam) 해시로 한다.POST /login(formuser=alice&pw=wonderland)의 응답 본문을/root/st/refresh/login.json에 저장한다. 그 access 로GET /api/me가 200 을 주고, 토큰이 없거나 지어낸 토큰, 틀린 비밀번호 로그인은 401 이다.- 새로 로그인해 받은 refresh 로
POST /token(formgrant_type=refresh_token&refresh_token=<값>)을 보내 응답을/root/st/refresh/refresh.json에 저장한다. - 로그인 → refresh(RT1) → 새 RT2 로 refresh → 옛 RT1 재제출 순서로 시험해
/root/st/refresh/rotate.txt에first_status= new_differs= new_status= old_status=를 적는다. bob/builder로 로그인해 한 번 회전한 뒤 옛 RT1 을 다시 내고, 최신 RT2 와 AT2 를 써 보아/root/st/refresh/reuse.txt에replay_status= active_after= access_after= family_state=를 적는다.- 로그인 응답의
expires_in(1~300초)과 Redis 의 access·refresh TTL 을/root/st/refresh/ttl.txt에expires_in= access_ttl= refresh_ttl=로 적는다. 만료된 access 는 401 이어야 한다. POST /revoke(formtoken=<refresh>)로 로그아웃한 뒤 같은 refresh 와 access 를 써 보아/root/st/refresh/revoke.txt에revoke_status= refresh_after= access_after= family_state=를 적는다./root/st/refresh/e2e.sh로 회전·재사용 탐지·계열 폐기를 시험해/root/st/refresh/e2e.out에rotate=ok,reuse_detected=yes,family_revoked=yes를 남긴다.
참고
- POST 는
curl -X POST --data-urlencode ...로 명시합니다. 토큰 값은python3 -c로 JSON 에서 꺼냅니다. - 무효·만료·폐기된 refresh 에 토큰 엔드포인트는 400
invalid_grant(RFC 6749 5.2), 보호된 API 는 401invalid_token(RFC 6750 3.1)으로 답합니다. - '썼음' 표시는
HINCRBY rt:<토큰> uses 1한 명령으로 합니다. 읽고 나서 따로 쓰면 동시에 온 두 요청이 모두 통과합니다. - 흔한 실수 1: 쓴 refresh 를 바로 지우는 것 — 다시 들어왔을 때 처음 보는 값과 구별이 안 돼 재사용을 탐지할 수 없습니다.
- 흔한 실수 2: 재사용된 토큰만 거절하고 계열은 살려 두는 것 — 공격자가 먼저 회전했다면 공격자 손의 최신 토큰이 계속 통합니다.
토큰 발급자 띄우기
/root/st/refresh/app.py 를 127.0.0.1:8306 에 띄운다. GET /health 가 200 과 {"ok":true} 를 준다. 저장은 Redis(127.0.0.1:6379)에 fam:<id>(user, state, current), rt:<토큰>(user, fam, uses), at:<토큰>(user, fam) 해시로 한다.
Redis 는 이미 6379 에 떠 있습니다. 서버는 백그라운드로 띄우고 /health 가 응답할 때까지 폴링하세요. python-multipart 가 없으니 form 본문은 urllib.parse.parse_qs 로 직접 풉니다.
로그인으로 토큰 한 쌍 받기
POST /login(form user=alice&pw=wonderland)의 응답 본문을 /root/st/refresh/login.json 에 그대로 저장한다. 본문에 access_token, refresh_token, token_type(Bearer), expires_in 이 있어야 하고, 그 access 로 GET /api/me 가 200 과 user=alice 를 주고, 토큰이 없거나 지어낸 토큰이면 401, 틀린 비밀번호 로그인은 401 이다.
curl 에 -X POST 를 명시하고 --data-urlencode 로 form 을 보냅니다. 보호된 API 는 Authorization: Bearer 헤더로 부릅니다. 비밀번호 그랜트는 RFC 9700 이 금지하므로 첫 토큰은 /token 이 아니라 /login 이 줍니다.
refresh 로 새 access 받기
새로 로그인해 받은 refresh 로 POST /token(form grant_type=refresh_token&refresh_token=<값>)을 보내 200 응답 본문을 /root/st/refresh/refresh.json 에 저장한다. 새 access_token 은 로그인 때와 달라야 하고 GET /api/me 에서 200 이어야 한다.
RFC 6749 6절의 refresh 그랜트입니다. 본문은 form-urlencoded 입니다. login.json 의 refresh 를 다시 쓰면 이미 쓴 토큰일 수 있으니 새로 로그인해서 받으세요.
회전: 새 refresh 는 통하고 옛것은 거절
로그인 → refresh(RT1) → 응답의 새 refresh(RT2) 로 다시 refresh → 옛 RT1 을 한 번 더 제출하는 순서로 시험해 /root/st/refresh/rotate.txt 에 first_status=200, new_differs=yes, new_status=200, old_status=<400 또는 401> 을 적는다.
refresh 를 쓸 때마다 새 refresh 를 주고, 쓴 refresh 는 지우지 말고 uses 를 올려 표시만 해 둡니다. 같은 값을 돌려주거나 옛것을 계속 받아 주면 회전이 아닙니다.
재사용 탐지: 계열 전체 폐기
bob/builder 로 로그인해 RT1 으로 한 번 회전(RT2, AT2 받음)한 뒤 옛 RT1 을 다시 제출하고, 이어서 RT2 로 refresh, AT2 로 GET /api/me 를 시험해 /root/st/refresh/reuse.txt 에 replay_status=<400|401>, active_after=<400|401>, access_after=401, family_state=revoked 를 적는다(family_state 는 redis-cli HGET fam:<계열> state).
서버는 옛 RT1 을 누가 냈는지 알 수 없습니다. 그래서 그 계열의 현재 refresh 와 access 까지 모두 죽입니다. /api/me 가 access 키만 보지 말고 계열 상태도 보게 하세요.
access 는 짧게, refresh 는 길게
로그인 응답의 expires_in 과 Redis 의 TTL at:<access>, TTL rt:<refresh> 를 /root/st/refresh/ttl.txt 에 expires_in= access_ttl= refresh_ttl= 로 적는다. expires_in 은 1~300초, access_ttl 은 양수이고 expires_in 이하, refresh_ttl 은 access_ttl 보다 커야 한다. Redis 에서 만료된(키가 사라진) access 는 GET /api/me 에서 401 이어야 한다.
access 수명은 Redis 키 TTL 로 표현하면 만료가 저절로 됩니다. refresh 의 TTL 은 비활성 만료(RFC 9700 4.14.2) 역할을 합니다.
로그아웃은 계열을 폐기
로그인한 뒤 POST /revoke(form token=<refresh>)로 로그아웃하고, 같은 refresh 와 access 를 다시 써 보아 /root/st/refresh/revoke.txt 에 revoke_status=200, refresh_after=<400|401>, access_after=401, family_state=revoked 를 적는다. 모르는 토큰으로 /revoke 해도 200 이어야 한다.
RFC 7009 모양입니다. refresh 를 폐기하면 같은 승인에서 나온 access 도 무효로 하는 것이 권고이고, 모르는 토큰에도 200 을 줍니다. refresh 키 하나만 지우면 access 가 계속 통합니다.
회전·재사용·폐기를 한 번에 증명
/root/st/refresh/e2e.sh 가 로그인 → 회전 → 옛 refresh 재제출 → 최신 refresh 와 access 재사용을 순서대로 시험해 rotate=ok, reuse_detected=yes, family_revoked=yes 세 줄을 출력하게 하고, 그 출력을 /root/st/refresh/e2e.out 에 저장한다. 채점기는 e2e.sh 를 다시 돌려 같은 결과가 나오는지도 본다.
앞 스텝들을 한 스크립트로 이어 붙입니다. 매번 새로 로그인해야 몇 번을 돌려도 같은 결과가 나옵니다. family_revoked 는 최신 refresh 거절과 access 401 이 둘 다일 때만 yes 입니다.