Scope session cookies by grid path
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
// Maintainer-controlled web client version metadata.
|
// Maintainer-controlled web client version metadata.
|
||||||
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
||||||
window.CHGRID_BUILD_REVISION = "R342";
|
window.CHGRID_BUILD_REVISION = "R343";
|
||||||
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
||||||
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
||||||
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
||||||
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`.
|
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
|
||||||
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
|
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
|
||||||
- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing.
|
- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing.
|
||||||
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window.
|
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window.
|
||||||
@@ -127,7 +127,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- Server also supports websocket handshake cookie resume:
|
- Server also supports websocket handshake cookie resume:
|
||||||
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
|
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
|
||||||
- websocket and auth helper routes are scoped under the configured `server.base_path`
|
- websocket and auth helper routes are scoped under the configured `server.base_path`
|
||||||
- reads `chgrid_session_token` from websocket `Cookie` header
|
- reads the instance-scoped session cookie from the websocket `Cookie` header
|
||||||
- attempts resume before sending `auth_required`
|
- attempts resume before sending `auth_required`
|
||||||
- exposes `GET <base_path>auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
|
- exposes `GET <base_path>auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
|
||||||
- Server applies auth hardening before accepting login/register/resume:
|
- Server applies auth hardening before accepting login/register/resume:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
1. User clicks connect.
|
1. User clicks connect.
|
||||||
2. Client validates auth form and sets up local media.
|
2. Client validates auth form and sets up local media.
|
||||||
3. Client connects signaling websocket from the configured app origin.
|
3. Client connects signaling websocket from the configured app origin.
|
||||||
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
|
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
|
||||||
5. If resume does not authenticate, server sends `auth_required`.
|
5. If resume does not authenticate, server sends `auth_required`.
|
||||||
- includes `authPolicy` limits for username/password.
|
- includes `authPolicy` limits for username/password.
|
||||||
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ class SignalingServer:
|
|||||||
self.server_version = self._resolve_server_version()
|
self.server_version = self._resolve_server_version()
|
||||||
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
|
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
|
||||||
self.base_path = self._normalize_base_path(base_path)
|
self.base_path = self._normalize_base_path(base_path)
|
||||||
|
self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path)
|
||||||
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
|
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
|
||||||
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
|
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
|
||||||
self.auth_session_cookie_clear_path = self._base_path_join(AUTH_SESSION_COOKIE_CLEAR_PATH)
|
self.auth_session_cookie_clear_path = self._base_path_join(AUTH_SESSION_COOKIE_CLEAR_PATH)
|
||||||
@@ -287,6 +288,17 @@ class SignalingServer:
|
|||||||
return f"/{token}"
|
return f"/{token}"
|
||||||
return f"{self.base_path}{token}"
|
return f"{self.base_path}{token}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _session_cookie_name_for_base_path(base_path: str) -> str:
|
||||||
|
"""Return one deterministic session cookie name for the configured instance path."""
|
||||||
|
|
||||||
|
if base_path == "/":
|
||||||
|
return AUTH_SESSION_COOKIE_NAME
|
||||||
|
suffix = re.sub(r"[^a-z0-9]+", "_", base_path.strip("/").casefold()).strip("_")
|
||||||
|
if not suffix:
|
||||||
|
return AUTH_SESSION_COOKIE_NAME
|
||||||
|
return f"chgrid_session_{suffix}"
|
||||||
|
|
||||||
def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
|
def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
|
||||||
"""Return True when session cookies should be marked Secure."""
|
"""Return True when session cookies should be marked Secure."""
|
||||||
|
|
||||||
@@ -302,7 +314,7 @@ class SignalingServer:
|
|||||||
|
|
||||||
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
||||||
return (
|
return (
|
||||||
f"{AUTH_SESSION_COOKIE_NAME}={token}; Path={self.base_path}; HttpOnly; SameSite=Lax; "
|
f"{self.auth_session_cookie_name}={token}; Path={self.base_path}; HttpOnly; SameSite=Lax; "
|
||||||
f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}"
|
f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,7 +322,7 @@ class SignalingServer:
|
|||||||
"""Build Set-Cookie header value that expires the session cookie."""
|
"""Build Set-Cookie header value that expires the session cookie."""
|
||||||
|
|
||||||
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
||||||
return f"{AUTH_SESSION_COOKIE_NAME}=; Path={self.base_path}; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
|
return f"{self.auth_session_cookie_name}=; Path={self.base_path}; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
|
||||||
|
|
||||||
def _origin_allowed(self, request: HttpRequest) -> bool:
|
def _origin_allowed(self, request: HttpRequest) -> bool:
|
||||||
"""Return whether one auth helper HTTP request comes from the configured app origin."""
|
"""Return whether one auth helper HTTP request comes from the configured app origin."""
|
||||||
@@ -364,7 +376,7 @@ class SignalingServer:
|
|||||||
|
|
||||||
if path == self.auth_session_cookie_check_path:
|
if path == self.auth_session_cookie_check_path:
|
||||||
cookie_header = str(request.headers.get("Cookie", "")).strip()
|
cookie_header = str(request.headers.get("Cookie", "")).strip()
|
||||||
token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
|
token = self._cookie_value(cookie_header, self.auth_session_cookie_name)
|
||||||
if not token:
|
if not token:
|
||||||
return HttpResponse(401, "Unauthorized", headers, b"missing session")
|
return HttpResponse(401, "Unauthorized", headers, b"missing session")
|
||||||
try:
|
try:
|
||||||
@@ -400,7 +412,7 @@ class SignalingServer:
|
|||||||
cookie_header = str(headers.get("Cookie", "")).strip()
|
cookie_header = str(headers.get("Cookie", "")).strip()
|
||||||
if not cookie_header:
|
if not cookie_header:
|
||||||
return ""
|
return ""
|
||||||
return self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
|
return self._cookie_value(cookie_header, self.auth_session_cookie_name)
|
||||||
|
|
||||||
def _build_admin_menu_actions_for_client(self, client: ClientConnection | None) -> list[dict[str, str]]:
|
def _build_admin_menu_actions_for_client(self, client: ClientConnection | None) -> list[dict[str, str]]:
|
||||||
"""Build server-authored admin menu actions allowed for one client."""
|
"""Build server-authored admin menu actions allowed for one client."""
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from app.server import (
|
|||||||
AUTH_SESSION_COOKIE_CLIENT_HEADER,
|
AUTH_SESSION_COOKIE_CLIENT_HEADER,
|
||||||
AUTH_SESSION_COOKIE_CHECK_PATH,
|
AUTH_SESSION_COOKIE_CHECK_PATH,
|
||||||
AUTH_SESSION_COOKIE_CLEAR_PATH,
|
AUTH_SESSION_COOKIE_CLEAR_PATH,
|
||||||
AUTH_SESSION_COOKIE_NAME,
|
|
||||||
AUTH_SESSION_COOKIE_SET_PATH,
|
AUTH_SESSION_COOKIE_SET_PATH,
|
||||||
SignalingServer,
|
SignalingServer,
|
||||||
)
|
)
|
||||||
@@ -47,7 +46,7 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
|||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
set_cookie = response.headers.get("Set-Cookie", "")
|
set_cookie = response.headers.get("Set-Cookie", "")
|
||||||
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
|
assert f"{server.auth_session_cookie_name}=" in set_cookie
|
||||||
assert "Path=/chgrid/" in set_cookie
|
assert "Path=/chgrid/" in set_cookie
|
||||||
assert "HttpOnly" in set_cookie
|
assert "HttpOnly" in set_cookie
|
||||||
assert "SameSite=Lax" in set_cookie
|
assert "SameSite=Lax" in set_cookie
|
||||||
@@ -66,7 +65,7 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
|
|||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
set_cookie = response.headers.get("Set-Cookie", "")
|
set_cookie = response.headers.get("Set-Cookie", "")
|
||||||
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
|
assert f"{server.auth_session_cookie_name}=" in set_cookie
|
||||||
assert "Max-Age=0" in set_cookie
|
assert "Max-Age=0" in set_cookie
|
||||||
assert "HttpOnly" in set_cookie
|
assert "HttpOnly" in set_cookie
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
|
|||||||
server.auth_session_cookie_check_path,
|
server.auth_session_cookie_check_path,
|
||||||
headers={
|
headers={
|
||||||
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
||||||
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}",
|
"Cookie": f"{server.auth_session_cookie_name}={session.token}",
|
||||||
"Origin": "https://example.com",
|
"Origin": "https://example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -120,13 +119,21 @@ async def test_session_cookie_helpers_reject_wrong_origin() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
|
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/chgrid/")
|
||||||
websocket = SimpleNamespace(
|
websocket = SimpleNamespace(
|
||||||
request=SimpleNamespace(
|
request=SimpleNamespace(
|
||||||
headers=Headers({"Cookie": f"foo=bar; {AUTH_SESSION_COOKIE_NAME}=abc123; hello=world"})
|
headers=Headers({"Cookie": f"foo=bar; {server.auth_session_cookie_name}=abc123; hello=world"})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
token = server._session_token_from_websocket_cookie(websocket)
|
token = server._session_token_from_websocket_cookie(websocket)
|
||||||
|
|
||||||
assert token == "abc123"
|
assert token == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_cookie_name_scopes_to_base_path() -> None:
|
||||||
|
root_server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/")
|
||||||
|
nested_server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/ttgrid/")
|
||||||
|
|
||||||
|
assert root_server.auth_session_cookie_name == "chgrid_session_token"
|
||||||
|
assert nested_server.auth_session_cookie_name == "chgrid_session_ttgrid"
|
||||||
|
|||||||
Reference in New Issue
Block a user