LabHub
시작하기
배우기 러닝패스 코스

세션과 토큰 — 브라우저에서 메시까지

리프레시 토큰 회전과 재사용 탐지

LabHub 에서 이어서 보기

목표

Redis 에 토큰 계열을 저장하는 작은 발급자를 세우고, refresh 를 쓸 때마다 회전하는지, 이미 쓴 refresh 가 다시 오면 그 계열 전체가 폐기되는지, access 가 짧게 사는지, 로그아웃이 계열을 끊는지를 실제 HTTP 요청으로 확인한다.

왜 중요한가

access 를 짧게 살리면 새어도 피해 창이 짧지만, 그 대가로 오래 사는 refresh 가 새로운 표적이 된다. 비밀을 숨길 수 없는 브라우저·모바일 앱에서 RFC 9700 은 refresh 를 클라이언트 키에 묶거나 회전을 쓰라고 요구한다. 회전의 힘은 재사용 탐지에서 나온다 — 토큰이 새서 공격자와 정상 클라이언트가 둘 다 쓰면 둘 중 하나는 반드시 이미 쓴 토큰을 내밀게 되고, 서버는 누가 공격자인지 모르므로 그 계열 전체를 끊는다. 이것이 동작하려면 쓴 refresh 를 지우지 않고 '썼음' 으로 남겨 두어야 하고, 그 표시는 원자적으로 해야 한다. 이 실습은 그 규칙들을 직접 짜고 스스로 증명하게 한다.

단계

  1. /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) 해시로 한다.
  2. POST /login(form user=alice&pw=wonderland)의 응답 본문을 /root/st/refresh/login.json 에 저장한다. 그 access 로 GET /api/me 가 200 을 주고, 토큰이 없거나 지어낸 토큰, 틀린 비밀번호 로그인은 401 이다.
  3. 새로 로그인해 받은 refresh 로 POST /token(form grant_type=refresh_token&refresh_token=<값>)을 보내 응답을 /root/st/refresh/refresh.json 에 저장한다.
  4. 로그인 → refresh(RT1) → 새 RT2 로 refresh → 옛 RT1 재제출 순서로 시험해 /root/st/refresh/rotate.txtfirst_status= new_differs= new_status= old_status= 를 적는다.
  5. bob/builder 로 로그인해 한 번 회전한 뒤 옛 RT1 을 다시 내고, 최신 RT2 와 AT2 를 써 보아 /root/st/refresh/reuse.txtreplay_status= active_after= access_after= family_state= 를 적는다.
  6. 로그인 응답의 expires_in(1~300초)과 Redis 의 access·refresh TTL 을 /root/st/refresh/ttl.txtexpires_in= access_ttl= refresh_ttl= 로 적는다. 만료된 access 는 401 이어야 한다.
  7. POST /revoke(form token=<refresh>)로 로그아웃한 뒤 같은 refresh 와 access 를 써 보아 /root/st/refresh/revoke.txtrevoke_status= refresh_after= access_after= family_state= 를 적는다.
  8. /root/st/refresh/e2e.sh 로 회전·재사용 탐지·계열 폐기를 시험해 /root/st/refresh/e2e.outrotate=ok, reuse_detected=yes, family_revoked=yes 를 남긴다.

참고

토큰 발급자 띄우기

/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.txtfirst_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.txtreplay_status=<400|401>, active_after=<400|401>, access_after=401, family_state=revoked 를 적는다(family_stateredis-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.txtexpires_in= access_ttl= refresh_ttl= 로 적는다. expires_in 은 1~300초, access_ttl 은 양수이고 expires_in 이하, refresh_ttlaccess_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.txtrevoke_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 입니다.