live_session — 페이지 경계
live_session은 페이지 단위의 인증 경계다. 이름 하나에 정책 하나를 묶고, 그 이름을 단 페이지에
사는 모든 컴포넌트가 그 정책을 지나게 한다. 그리고 경계를 넘는 이동을 전체 페이지 로드로 만든다.
Phoenix LiveView의 live_session에 해당한다(GAP-009). 다만 붙는 자리가 다르다 — Phoenix는 LiveView가
곧 라우트지만, wireview는 Django 뷰가 라우트이고 한 페이지에 컴포넌트가 여럿이다. 그래서 경계는
컴포넌트 사이가 아니라 페이지 사이에 그어진다.
Meta.on_mount |
live_session |
|
|---|---|---|
| 붙는 곳 | 컴포넌트 클래스 | 페이지(Django 뷰) |
| 답하는 질문 | "이 컴포넌트가 마운트될 때 무엇을 먼저 하나" | "이 페이지에 들어올 수 있는가, 어디서 나갈 때 연결을 끊어야 하나" |
| 실행 순서 | 세션 훅 다음 | 컴포넌트 훅보다 먼저 |
| 없으면 | 컴포넌트마다 같은 목록을 반복 선언 | 페이지 경계에서 인증이 재검증되지 않는다 |
live_session은 Meta.on_mount를 대체하지 않고 그 위에 층을 얹는다. 훅 프로토콜은 같다.
30초 요약#
# myapp/live_sessions.py — 앱마다 자동 임포트된다
from wireview import live_session
admin = live_session("admin", authorize=lambda ctx: ctx.user.is_staff)
# myapp/views.py
from .live_sessions import admin
@admin.view
def dashboard(request):
return render(request, "admin/dashboard.html")
# myapp/live.py
class AdminPanel(Component):
class Meta:
live_sessions = {"admin"} # 이 경계 안에서만 산다
이제 네 가지가 성립한다.
- 인증되지 않은 요청은 HTML이 만들어지기 전에 로그인 페이지로 간다.
- 같은 판정이 WebSocket join에도 적용된다 — 같은 술어 하나가 두 곳에서 돈다.
AdminPanel은admin밖의 페이지에서는 아예 렌더되지 않는다.admin을 벗어나는 boost 이동은 morph가 아니라 전체 페이지 로드가 된다.
왜 훅만으로는 부족한가#
Meta.on_mount에 인증 훅을 붙이면 컴포넌트마다 붙여야 하고, 하나 빠뜨려도 아무 신호가 없다. 그보다
중요한 문제가 둘 더 있다.
첫 HTML은 회수되지 않는다. {% component %}는 HTTP 응답에 초기 렌더와 data-state를 같이
싣는다. join에서 거절해도 이미 나간 바이트는 돌아오지 않는다. 그래서 @admin.view는 실제로
Django 인증을 거는 데코레이터이고, 거절은 뷰가 돌기 전에 일어난다.
위조가 필요 없는 우회가 있다. 정책 이름만 따로 서명하면, 공개 페이지의 정상 서명과 보호 컴포넌트의 정상 상태를 함께 보내는 것으로 빈 정책이 적용된다. 그래서 정책 이름과 인증 세대는 각 컴포넌트의 상태 봉투 안에 들어간다(HTML Diff의 v2 봉투).
API#
live_session(name, *, authorize=None, on_mount=(), login_url=None)#
경계를 선언하고 name으로 등록한다. 이름은 브라우저로 나가고 서명된 상태 안에 들어가므로 배포
사이에 안정적이어야 하고 프로젝트 안에서 유일해야 한다. 같은 이름을 두 번 선언하면
ImproperlyConfigured다.
| 인자 | 뜻 |
|---|---|
authorize |
LiveSessionContext를 받아 bool을 돌려주는 술어. 뷰가 렌더하기 전에 한 번, join이 마운트하기 전에 한 번 돈다. None이면 모두 허용 |
on_mount |
이 페이지의 모든 컴포넌트에 적용되는 훅 목록. 컴포넌트 자신의 Meta.on_mount보다 먼저 돈다 |
login_url |
익명 방문자를 보낼 곳. 기본은 Django의 LOGIN_URL |
선언은 각 앱의 live_sessions.py에 둔다 — live.py와 같은 방식으로 앱 준비 시점에 자동
임포트되므로 손으로 import할 필요가 없다.
django.template.context_processors.request가 켜져 있어야 한다. 템플릿 태그는 페이지의 경계를context["request"]에서 읽는다. 이 프로세서가 없으면 기능 전체가 말없이 꺼진다 — 헤더가 빈 이름을 심어 브라우저가 어떤 이동도 경계 넘음으로 보지 않고, 모든 상태가 경계 없이 서명되며,Meta.live_sessions를 선언한 컴포넌트는 자기가 속한 페이지에서 사라진다.소속을 선언하지 않은 컴포넌트에게는 이것이 실제 우회가 된다. 인가된 사용자가 그렇게 받은 경계 없는 토큰은 익명 소켓에서 재사용할 수 있고, 그 연결에는 페이지 정책도 인증 세대도 로그아웃 구독도 붙지 않는다.
manage.py check의wireview.W010이 설정을 보고하지만 경고일 뿐 실행을 막지 않는다. 프로세서를 켜고, 보호할 컴포넌트에는Meta.live_sessions를 선언한다.
LiveSessionContext#
admin = live_session("admin", authorize=lambda ctx: ctx.user.is_staff and ctx.session.get("mfa"))
| 필드 | 뜻 |
|---|---|
user |
요청(또는 연결)의 사용자. None이 아니다 — 비인증이면 AnonymousUser |
session |
Django 세션의 읽기 전용 뷰 (세션 읽기) |
술어는 동기 함수다. 뷰에서도 join에서도 같은 함수가 불려야 하기 때문이다. ORM을 건드려도 된다 —
join은 sync_to_async로 감싸서 부른다.
언제 도는가. 뷰에서는 요청마다, 소켓에서는 join마다 돈다(페이지에 루트 컴포넌트가 여럿이면
각각). 다만 소켓의 user와 session은 connect 때 읽은 스냅샷이므로, 술어가 ctx.user.is_staff처럼
그 객체의 속성만 보면 연결이 열려 있는 동안 값이 갱신되지 않는다. 술어가 직접 DB를 조회하면 이후
join에서는 최신 값을 본다. 이미 마운트된 컴포넌트로 오는 이벤트는 재인가하지 않는다 — 아래
로그아웃과 기존 연결 참고.
@session.view#
함수 뷰(동기·async def 둘 다)와 클래스 기반 뷰를 받는다.
@admin.view
def dashboard(request): ...
@admin.view
async def feed(request): ...
@admin.view
class Dashboard(TemplateView): ...
async def 뷰에는 async wrapper가 붙는다. Django는 넘겨받은 호출 가능 객체를 검사해서 뷰를 어떻게
부를지 정하므로, 동기 wrapper로 감싸면 응답 자리에 await되지 않은 coroutine이 들어간다.
두 가지를 한다. authorize가 거절하면 뷰를 부르지 않고, 통과하면 요청에 세션 이름을 남겨
{% wireview_header %}와 그 페이지가 발급하는 모든 data-state가 그것을 싣게 한다.
거절의 모양은 django.contrib.auth를 따른다 — 로그인하지 않은 방문자는 로그인 페이지로 보내고
(로그인이 부족한 전부일 수 있으므로), 로그인했는데도 술어를 통과하지 못하면 403이다.
Meta.live_sessions#
class AdminPanel(Component):
class Meta:
live_sessions = {"admin"}
"나는 이 세션 안에서만 산다"는 선언이다. 비워 두면 어디서나 산다(경계가 생기기 전의 기본값). 선언하면 경계가 없는 페이지에서도 거절된다 — 공개 페이지가 바로 그 모습이기 때문이다.
보호하려는 컴포넌트에는 반드시 선언한다. 뷰에
@session.view만 붙이는 것으로는 부족하다. 서버는 join 시점에 토큰이 말하는 경계만 보고, 그 토큰이 언제 발급됐는지는 모른다. 그래서 공개 페이지 시절에 발급된 정상 토큰은 그 페이지에 나중에 경계를 붙여도STATE_MAX_AGE동안 정책 없이 join된다. 소속을 선언한 컴포넌트만 그 토큰을 거절한다. 아래 경계를 나중에 도입할 때 참고.
거절은 컴포넌트를 만드는 모든 경로에서 일어난다: join, children 복원, 부모 렌더가 만든
LiveComponent, 부모 템플릿 안의 중첩 {% component %}, 재join. 거절된 컴포넌트는 HTML도 상태도
내보내지 않고 저장소에서도 지워진다 — 그 id로 이벤트를 보내도 처리되지 않는다.
훅이 예외를 던져도 거절이다. 인가 조회가 DB 오류로 실패하는 것이 거절보다 통과하기 쉬워서는 안 된다. 예외는 그대로 올라가거나(join·HTTP 렌더) 로그에 남지만(LiveComponent 자식), 어느 쪽이든 컴포넌트는 렌더되지 않고 저장소에도 남지 않는다.
경계를 넘는 이동#
{% wireview_header %}가 페이지의 세션 이름을 meta로 심는다.
<meta name="wireview-live-session" content="admin" />
클라이언트는 boost 이동마다 목적지 문서의 이름을 현재 페이지의 것과 비교하고, 다르면 morph하지 않고 평범한 페이지 이동을 한다. WebSocket이 끊기고 새 핸드셰이크가 지금의 쿠키로 다시 선다.
이 규칙이 없으면 이렇게 된다: 로그인 상태로 관리자 페이지를 열고, 로그아웃하고, boost로 관리자 페이지로 돌아간다 → 연결은 로그인 시점에 세운 그대로다.
검사는 세 진입점이 합류하는 곳 하나에 있다.
| 경로 | 어디서 시작하나 |
|---|---|
| 링크 클릭 | boost의 클릭 인터셉터 |
뒤로/앞으로(popstate) |
브라우저. 캐시된 body를 morph 예약한 뒤 fetch한다 |
서버 redirect/push |
wire.push_to() 등이 HistoryCache를 직접 부른다 |
셋 다 HistoryCache.replaceContentFromUrl로 모이고, 검사는 거기서 응답을 보고 한다(요청한 URL이
아니라). 리다이렉트 체인이 경계 밖에서 끝나면 그 최종 페이지가 잡힌다. popstate만은 캐시된 body를
fetch보다 먼저 morph하므로 history 항목에 기록해 둔 세션 이름으로 그 전에 판단한다.
메타가 없는 문서는 "경계 없음"으로 읽힌다. 업그레이드 전에 만들어진 페이지, 캐시된 옛 페이지, wireview가 아닌 페이지 셋 다 그렇고, 경계 안에서 그런 곳으로 가는 것은 전체 로드다.
로그아웃과 기존 연결#
전체 페이지 로드는 정상 클라이언트가 문맥을 갱신하는 수단일 뿐, 누군가 열어 둔 소켓을 폐기하지 못한다. 그래서 두 갈래가 있다.
인증 세대는 nonce다. login()이 세션에 _wireview_auth_gen을 새로 찍고, 상태 봉투의 a는
사용자 pk와 그 nonce를 해시한 값이다. 두 후보를 일부러 뺐고, 둘 다 넣어 봤다가 뺐다.
session_key는 signed-cookie 백엔드에서 서명된 쿠키 문자열 전체라, 장바구니 하나만 써도 값이 바뀐다.
그 위에 지문을 세우면 인증과 무관한 이유로 열린 페이지가 reload된다.
_auth_user_hash는 더 미묘하다. 비밀번호 변경에 따라 움직이니 세대가 알아야 할 것처럼 보이지만,
update_session_auth_hash()는 로그인을 일부러 유지한 채 그 해시를 바꾸고 아무 시그널도 내지
않는다. 소켓은 처음 구독한 토픽에 남고 나중의 logout()은 새 토픽으로 발행한다 — 정상 브로커에서
매번 빗나간다. 폐기 경로를 깨뜨리는 결합은 폐기 경로보다 가치가 낮다.
그 대가로 비밀번호 변경 자체는 토큰을 무효화하지 않는다. 로그인을 유지한 변경이라면 그것이 맞는 동작이고, 유지하지 않은 변경이라면 세션이 무효가 되어 다음 연결이 거절된다 — 이미 열려 있는 소켓은 어느 쪽이든 로그아웃이 닫는다.
이제 세 갈래다.
새 연결: 봉투의 a가 지금 연결의 인증 세대와 다르면 join이 거절되고 클라이언트는 reload한다.
join 시점 재확인: 경계에 처음 들어가는 join은 세션을 백엔드에서 다시 읽고, 읽은 것으로
스냅샷을 교체하고, 그 세션이 여전히 이 사용자를 인증하는지 묻는다(지문 비교만으로는 부족하다 —
nonce가 없는 세션의 지문 입력은 pk뿐이고 그 pk는 세션이 아니라 연결에서 오므로, 비워진 세션도 같은
값을 낸다). connect 때 읽은 스냅샷은 그 뒤의 로그아웃을 알 수 없고, 클라이언트는 소켓을
열어 둔 채 첫 join을 미룰 수 있기 때문이다. 지문만 대조하지 않고 교체하는 이유는 인증 말고 다른
것을 읽는 정책 때문이다 — authorize=lambda ctx: ctx.session.get("mfa")는 지문을 움직이지 않으므로
대조로는 잡히지 않고, 사라진 값을 그대로 통과시킨다. 연결당 한 번이고, 경계 밖에서는 아예 하지
않는다.
기존 연결: user_logged_out이 그 세대의 토픽으로 발행하고, 그 토픽을 듣고 있던 소켓이 닫힌다.
경계 안의 연결만 구독한다 — 밖에서는 상태가 인증에 묶여 있지 않으므로 무효화할 것이 없고, 무관한
로그아웃에 공개 페이지의 소켓까지 닫힐 이유가 없다.
로그아웃 없이 다시 로그인하는 경우(step-up, 재인증)도 같다. login()이 nonce를 덮어쓰므로 이후의
로그아웃은 옛 세대를 부를 수 없다 — 그래서 덮어쓰기 직전에 옛 세대로 한 번 발행한다. nonce가 없던
세션에도 발행한다. 그 세션들이야말로 다른 방법으로는 닿을 수 없기 때문이다.
그 발행은 nonce가 없는 세션에 대해 넓게 퍼진다. nonce 없는 로그인들은 pk 하나로 합쳐지므로, 다른 브라우저에서의 첫 로그인이 같은 사용자의 다른 nonce 없는 소켓까지 닫는다. 업그레이드 이후 각 세션이 한 번씩 로그인하면 사라지는, 전환 기간에 한정된 과잉 종료다.
다만 다른 사용자가 같은 브라우저에서 로그인하면 Django가 세션을 먼저 flush하므로 그 시점에 옛 nonce가 이미 없다. 그 경우 옛 세대를 끊는 것은 먼저 로그아웃하는 것뿐이다.
발행은 최선 노력이지 배달 보장이 아니다. Channels 채널 레이어 규격은 용량 초과 시 조용한 폐기와 그룹 멤버십 만료를 허용한다. 놓친 소켓은 스스로 다시 연결할 때까지 살아 있고, 그동안 이벤트를 계속 처리한다 — 열린 연결을 주기적으로 재검증하는 경로는 없다. join 재확인이 회수하는 것은 "다음 연결"이지 "언젠가 그 연결"이 아니다. "모든 소켓이 즉시 닫힌다"는 보장은 없다.
다중 프로세스에서는 브로커가 필요하다. InMemory 채널 레이어는 같은 프로세스의 연결에만 닿으므로 다른 워커가 들고 있는 소켓은 닫히지 않는다(배포).
세션 백엔드가 정하는 한계#
| 백엔드 | 로그아웃 뒤 새 연결 | 로그아웃 뒤 열린 소켓 |
|---|---|---|
| db / cache / cached_db | 막힌다 (세션이 지워져 join 재확인이 잡는다) | 발행이 닿으면 닫힌다 |
| signed_cookies | 막지 못한다 | 발행이 닿으면 닫힌다 |
signed-cookie 세션은 서버에 아무 기록이 없다. Django 자신이 문서에서 로그아웃으로 폐기되지 않는다고 밝힌다 — 로그인 시점의 쿠키를 보관했다가 로그아웃 뒤에 다시 제출하면 같은 사용자로 인증된다. 재확인이 다시 읽을 서버 상태가 없으므로 wireview도 이것을 막지 못한다. 경계 뒤에 진짜 인가가 걸린 페이지가 있다면 서버 저장형 세션 백엔드를 쓴다.
이미 마운트된 컴포넌트의 이벤트는 재인가하지 않는다. 술어는 join마다 돌지만 이벤트마다 돌지는
않고, connect 때 읽은 사용자·세션 스냅샷을 본다. 로그아웃은 소켓을 닫는 것이 갱신 경로다. 그래서
로그아웃을 거치지 않는 권한 변경은 다음 연결에서야 반영된다 —
is_staff를 떼거나, update_session_auth_hash() 없이 비밀번호를 바꾸는 경우가 그렇다(둘 다 Django가
시그널을 내지 않는다). 즉시 끊어야 한다면 직접 부른다.
from wireview import invalidate_authentication
user.is_staff = False
user.save()
invalidate_authentication(user, request.session) # 그 인증 세대의 소켓을 닫는다
객체 하나에 대한 권한은 애초에 경계의 일이 아니다. 페이지에 들어왔다는 것이 그 안의 모든 행을 건드릴 권한을 뜻하지 않으므로, 그것은 핸들러에서 검사한다.
페이지 하나에 세션 하나#
연결도 마찬가지다. 첫 join이 연결의 경계를 정하고, 다른 이름을 실은 join은 거절된다. 한 소켓에 두 정책을 섞는 것이 공개 정책으로 보호 컴포넌트를 덮는 방법이기 때문이다.
점검#
manage.py check가 wireview.W010으로 셋을 본다 — django.template.context_processors.request가
꺼져 있는 경우(위), 아무도 선언하지 않은 이름을 Meta.live_sessions가
가리키는 경우(오타가 join 거절과 reload로 나타나 서명 문제처럼 보인다), 프로젝트가 경계를 선언했는데
Meta.on_mount로만 자신을 지키는 컴포넌트가 Meta.live_sessions를 선언하지 않은 경우.
시스템 체크 참고.
옛 페이지와의 호환#
상태 봉투가 v2로 올라간다. 업그레이드 시점에 열려 있던 페이지의 v1 토큰은 거절되고 클라이언트가 reload한다 — 새 페이지는 현재 인증 문맥으로 다시 렌더되고 새 토큰을 받는다.
옛 토큰을 받아 주던 STATE_ACCEPT_LEGACY는 1.0 전에 없어졌다(#99). 옛 토큰은 경계 이름을 담고 있지
않고, 토큰 안에는 "이 페이지에 경계가 있었는지"를 말해 주는 것도 없다. 받아 주면 경계를 선언하지
않은 컴포넌트가 연결의 경계를 "없음"으로 정해 뷰에 붙인 authorize와 세션 훅이 통째로 빠질 수
있었다. 이제 그런 창은 없다.
경계를 나중에 도입할 때#
이미 공개로 돌던 페이지에 @session.view를 붙이는 전환에는 순서가 있다. 서버는 join에서 토큰이
말하는 경계를 보고, 그 클래스가 언제부터 보호 대상이 됐는지는 모른다. 그래서 그 전에 발급된 경계
없는 토큰은 새 정책을 지나지 않는다.
기다리면 없어지지 않는다. 토큰 하나는
STATE_MAX_AGE뒤 만료되지만, 경계 없이 join한 컴포넌트는 렌더할 때마다 경계 없는 토큰을 새로 발급한다. 그 새 토큰은 자기 수명을 처음부터 갖는다. 열려 있는 탭 하나가 그렇게 노출을 무한히 연장할 수 있다. 만료는 토큰 하나의 수명이지 권한 회수 수단이 아니다.
- 보호할 컴포넌트에
Meta.live_sessions를 선언한다. 이것만이 옛 토큰을 컴포넌트 자신의 권한으로 거절한다.manage.py check의wireview.W010은Meta.on_mount로 자신을 지키는 컴포넌트를 알려 주지만, 페이지의authorize에만 의존하던 컴포넌트는 찾지 못한다 — 보호 대상 목록은 직접 확인한다. - 이미 열려 있는 연결을 끊는다. 배포로 워커를 교체하면 소켓도 끊긴다. 롤링 배포라면 옛 워커가 전부 내려간 뒤에야 전환이 끝난다 — 옛 워커는 계속 경계 없는 토큰을 발급한다.
- 즉시 끊어야 한다면
WIREVIEW["SIGNING_KEY"]를 교체하되SIGNING_KEY_FALLBACKS = []를 명시한다.None은 "fallback 없음"이 아니라 Django의SECRET_KEY_FALLBACKS를 상속한다는 뜻이라, 옛 키가 거기 있으면 옛 토큰이 계속 통과한다. 그리고 키 교체는 이미 join한 연결을 닫지 않는다 — 이벤트 경로는 서명을 다시 보지 않는다. 2번이 함께 필요하다.
Phoenix LiveView 대응#
| Phoenix LiveView | django-wireview | |
|---|---|---|
| 선언 | 라우터의 live_session :admin, on_mount: [...] |
live_session("admin", authorize=..., on_mount=[...]) |
| 적용 | 라우트 묶음 | Django 뷰(@session.view) |
| 소속 선언 | LiveView가 라우트에 속한다 | Meta.live_sessions |
| 경계 이동 | live navigation이 전체 로드로 퇴화 | boost가 전체 로드로 퇴화 |
| 로그아웃 | 소켓에 disconnect 브로드캐스트 | user_logged_out → 인증 토픽 발행 → 소켓 닫힘 |