Move auth session persistence to true HttpOnly cookies
This commit is contained in:
@@ -28,6 +28,7 @@ Notes:
|
|||||||
- Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags.
|
- Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags.
|
||||||
- Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override.
|
- Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override.
|
||||||
- Auth requires `CHGRID_AUTH_SECRET` in server environment; `deploy/scripts/install_server.sh` creates `server/.env` with this value automatically if missing.
|
- Auth requires `CHGRID_AUTH_SECRET` in server environment; `deploy/scripts/install_server.sh` creates `server/.env` with this value automatically if missing.
|
||||||
|
- Saved login/session persistence uses a server-set `HttpOnly` cookie (`chgrid_session_token`).
|
||||||
|
|
||||||
Common server overrides:
|
Common server overrides:
|
||||||
- `uv run python main.py --config /path/to/config.toml`
|
- `uv run python main.py --config /path/to/config.toml`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||||
window.CHGRID_WEB_VERSION = "2026.02.28 R315";
|
window.CHGRID_WEB_VERSION = "2026.02.28 R316";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ const SYSTEM_SOUND_URLS = {
|
|||||||
logout: withBase('sounds/logout.ogg'),
|
logout: withBase('sounds/logout.ogg'),
|
||||||
notify: withBase('sounds/notify.ogg'),
|
notify: withBase('sounds/notify.ogg'),
|
||||||
} as const;
|
} as const;
|
||||||
|
const AUTH_SESSION_COOKIE_SET_URL = withBase('auth/session/set');
|
||||||
|
const AUTH_SESSION_COOKIE_CLEAR_URL = withBase('auth/session/clear');
|
||||||
|
const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client';
|
||||||
const ACTION_SOUND_URL = withBase('sounds/action.ogg');
|
const ACTION_SOUND_URL = withBase('sounds/action.ogg');
|
||||||
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
|
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
|
||||||
const FOOTSTEP_GAIN = 0.7;
|
const FOOTSTEP_GAIN = 0.7;
|
||||||
@@ -259,7 +262,6 @@ let lastAnnouncementText = '';
|
|||||||
let lastAnnouncementAt = 0;
|
let lastAnnouncementAt = 0;
|
||||||
let outputMode = settings.loadOutputMode();
|
let outputMode = settings.loadOutputMode();
|
||||||
let authMode: 'login' | 'register' = 'login';
|
let authMode: 'login' | 'register' = 'login';
|
||||||
let authSessionToken = settings.loadAuthSessionToken();
|
|
||||||
let authUsername = settings.loadAuthUsername();
|
let authUsername = settings.loadAuthUsername();
|
||||||
let authPolicy: AuthPolicy | null = null;
|
let authPolicy: AuthPolicy | null = null;
|
||||||
let authRole = 'user';
|
let authRole = 'user';
|
||||||
@@ -623,8 +625,7 @@ function applyVoiceSendPermission(): void {
|
|||||||
|
|
||||||
/** Enables/disables the connect button based on state and nickname validity. */
|
/** Enables/disables the connect button based on state and nickname validity. */
|
||||||
function updateConnectAvailability(): void {
|
function updateConnectAvailability(): void {
|
||||||
const hasSessionToken = authSessionToken.trim().length > 0;
|
const showLogout = state.running;
|
||||||
const showLogout = state.running || hasSessionToken;
|
|
||||||
dom.logoutButton.classList.toggle('hidden', !showLogout);
|
dom.logoutButton.classList.toggle('hidden', !showLogout);
|
||||||
dom.logoutButton.disabled = !showLogout;
|
dom.logoutButton.disabled = !showLogout;
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
@@ -635,22 +636,12 @@ function updateConnectAvailability(): void {
|
|||||||
dom.authSessionView.classList.add('hidden');
|
dom.authSessionView.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSessionToken) {
|
|
||||||
const label = sanitizeAuthUsername(authUsername) || 'current account';
|
|
||||||
dom.authSessionText.textContent = `Logged in as ${label}.`;
|
|
||||||
dom.showRegisterButton.classList.add('hidden');
|
|
||||||
dom.authModeSeparator.classList.add('hidden');
|
|
||||||
dom.loginView.classList.add('hidden');
|
|
||||||
dom.registerView.classList.add('hidden');
|
|
||||||
dom.authSessionView.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
dom.showRegisterButton.classList.remove('hidden');
|
dom.showRegisterButton.classList.remove('hidden');
|
||||||
dom.authModeSeparator.classList.remove('hidden');
|
dom.authModeSeparator.classList.remove('hidden');
|
||||||
dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login';
|
dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login';
|
||||||
dom.loginView.classList.toggle('hidden', authMode !== 'login');
|
dom.loginView.classList.toggle('hidden', authMode !== 'login');
|
||||||
dom.registerView.classList.toggle('hidden', authMode !== 'register');
|
dom.registerView.classList.toggle('hidden', authMode !== 'register');
|
||||||
dom.authSessionView.classList.add('hidden');
|
dom.authSessionView.classList.add('hidden');
|
||||||
}
|
|
||||||
const usernameMin = authPolicy?.usernameMinLength ?? 1;
|
const usernameMin = authPolicy?.usernameMinLength ?? 1;
|
||||||
const passwordMin = authPolicy?.passwordMinLength ?? 1;
|
const passwordMin = authPolicy?.passwordMinLength ?? 1;
|
||||||
const hasLoginCredentials =
|
const hasLoginCredentials =
|
||||||
@@ -659,8 +650,9 @@ function updateConnectAvailability(): void {
|
|||||||
sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin &&
|
sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin &&
|
||||||
dom.registerPassword.value.trim().length >= passwordMin &&
|
dom.registerPassword.value.trim().length >= passwordMin &&
|
||||||
dom.registerPassword.value === dom.registerPasswordConfirm.value;
|
dom.registerPassword.value === dom.registerPasswordConfirm.value;
|
||||||
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials);
|
const authReady = authMode === 'login' ? true : hasRegisterCredentials;
|
||||||
dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect';
|
dom.connectButton.textContent =
|
||||||
|
authMode === 'register' ? 'Register & Connect' : hasLoginCredentials ? 'Log In & Connect' : 'Connect';
|
||||||
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
|
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1466,12 +1458,8 @@ function setAuthMode(mode: 'login' | 'register'): void {
|
|||||||
updateConnectAvailability();
|
updateConnectAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds outbound auth packet from local token or active auth form. */
|
/** Builds outbound auth packet from active login/register form fields. */
|
||||||
function buildAuthRequestPacket(): OutgoingMessage | null {
|
function buildAuthRequestPacket(): OutgoingMessage | null {
|
||||||
const token = authSessionToken.trim();
|
|
||||||
if (token) {
|
|
||||||
return { type: 'auth_resume', sessionToken: token };
|
|
||||||
}
|
|
||||||
if (authMode === 'register') {
|
if (authMode === 'register') {
|
||||||
const username = sanitizeAuthUsername(dom.registerUsername.value);
|
const username = sanitizeAuthUsername(dom.registerUsername.value);
|
||||||
const password = dom.registerPassword.value;
|
const password = dom.registerPassword.value;
|
||||||
@@ -1489,10 +1477,10 @@ function buildAuthRequestPacket(): OutgoingMessage | null {
|
|||||||
function sendAuthRequest(): void {
|
function sendAuthRequest(): void {
|
||||||
const packet = buildAuthRequestPacket();
|
const packet = buildAuthRequestPacket();
|
||||||
if (!packet) {
|
if (!packet) {
|
||||||
setConnectionStatus('Enter username and password.');
|
pendingAuthRequest = false;
|
||||||
|
setConnectionStatus('Attempting saved session...');
|
||||||
mediaSession.setConnecting(false);
|
mediaSession.setConnecting(false);
|
||||||
updateConnectAvailability();
|
updateConnectAvailability();
|
||||||
signaling.disconnect();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingAuthRequest = true;
|
pendingAuthRequest = true;
|
||||||
@@ -1502,11 +1490,24 @@ function sendAuthRequest(): void {
|
|||||||
|
|
||||||
/** Handles server auth-required prompts prior to world welcome. */
|
/** Handles server auth-required prompts prior to world welcome. */
|
||||||
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
||||||
|
const hadPendingRequest = pendingAuthRequest;
|
||||||
|
pendingAuthRequest = false;
|
||||||
applyAuthPolicy(message.authPolicy);
|
applyAuthPolicy(message.authPolicy);
|
||||||
applyAuthPermissions('user', []);
|
applyAuthPermissions('user', []);
|
||||||
applyServerAdminMenuActions([]);
|
applyServerAdminMenuActions([]);
|
||||||
setConnectionStatus('Authentication required.');
|
setConnectionStatus('Authentication required.');
|
||||||
updateStatus(message.message);
|
updateStatus(message.message);
|
||||||
|
if (!hadPendingRequest) {
|
||||||
|
const packet = buildAuthRequestPacket();
|
||||||
|
if (packet) {
|
||||||
|
pendingAuthRequest = true;
|
||||||
|
setConnectionStatus('Authenticating...');
|
||||||
|
signaling.send(packet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mediaSession.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies auth result state and terminates failed auth attempts quickly. */
|
/** Applies auth result state and terminates failed auth attempts quickly. */
|
||||||
@@ -1518,8 +1519,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
|
|||||||
dom.registerPassword.value = '';
|
dom.registerPassword.value = '';
|
||||||
dom.registerPasswordConfirm.value = '';
|
dom.registerPasswordConfirm.value = '';
|
||||||
if (message.message.toLowerCase().includes('session')) {
|
if (message.message.toLowerCase().includes('session')) {
|
||||||
authSessionToken = '';
|
void clearHttpOnlySessionCookie();
|
||||||
settings.saveAuthSessionToken('');
|
|
||||||
}
|
}
|
||||||
applyAuthPermissions('user', []);
|
applyAuthPermissions('user', []);
|
||||||
applyServerAdminMenuActions([]);
|
applyServerAdminMenuActions([]);
|
||||||
@@ -1531,8 +1531,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.sessionToken) {
|
if (message.sessionToken) {
|
||||||
authSessionToken = message.sessionToken;
|
void persistHttpOnlySessionCookie(message.sessionToken);
|
||||||
settings.saveAuthSessionToken(message.sessionToken);
|
|
||||||
}
|
}
|
||||||
if (message.username) {
|
if (message.username) {
|
||||||
authUsername = message.username;
|
authUsername = message.username;
|
||||||
@@ -1556,9 +1555,8 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
|
|||||||
|
|
||||||
/** Clears stored auth session and returns UI to login mode. */
|
/** Clears stored auth session and returns UI to login mode. */
|
||||||
function logOutAccount(): void {
|
function logOutAccount(): void {
|
||||||
authSessionToken = '';
|
|
||||||
authUsername = '';
|
authUsername = '';
|
||||||
settings.saveAuthSessionToken('');
|
void clearHttpOnlySessionCookie();
|
||||||
settings.saveAuthUsername('');
|
settings.saveAuthUsername('');
|
||||||
applyAuthPermissions('user', []);
|
applyAuthPermissions('user', []);
|
||||||
applyServerAdminMenuActions([]);
|
applyServerAdminMenuActions([]);
|
||||||
@@ -1571,6 +1569,41 @@ function logOutAccount(): void {
|
|||||||
updateConnectAvailability();
|
updateConnectAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persists active auth session in a server-managed HttpOnly cookie. */
|
||||||
|
async function persistHttpOnlySessionCookie(sessionToken: string): Promise<void> {
|
||||||
|
const token = sessionToken.trim();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await fetch(AUTH_SESSION_COOKIE_SET_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
[AUTH_SESSION_COOKIE_CLIENT_HEADER]: '1',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to persist auth cookie.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears server-managed HttpOnly auth session cookie. */
|
||||||
|
async function clearHttpOnlySessionCookie(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(AUTH_SESSION_COOKIE_CLEAR_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
[AUTH_SESSION_COOKIE_CLIENT_HEADER]: '1',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to clear auth cookie.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles server-pushed role/permission refresh events for the current session. */
|
/** Handles server-pushed role/permission refresh events for the current session. */
|
||||||
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
|
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
|
||||||
const hadVoiceSend = voiceSendAllowed;
|
const hadVoiceSend = voiceSendAllowed;
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
|
|||||||
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
||||||
const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername';
|
const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername';
|
||||||
const LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken';
|
const LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken';
|
||||||
const AUTH_SESSION_COOKIE_NAME = 'chgrid_session_token';
|
|
||||||
const AUTH_SESSION_MAX_AGE_SECONDS = 14 * 24 * 60 * 60;
|
|
||||||
|
|
||||||
type DevicePreference = {
|
type DevicePreference = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,33 +28,6 @@ type AudioDevicePreferences = {
|
|||||||
* Wraps localStorage reads/writes for client user settings.
|
* Wraps localStorage reads/writes for client user settings.
|
||||||
*/
|
*/
|
||||||
export class SettingsStore {
|
export class SettingsStore {
|
||||||
private readCookie(name: string): string {
|
|
||||||
const cookie = document.cookie || '';
|
|
||||||
const parts = cookie.split(';');
|
|
||||||
for (const part of parts) {
|
|
||||||
const [rawKey, ...rest] = part.trim().split('=');
|
|
||||||
if (rawKey !== name) continue;
|
|
||||||
const rawValue = rest.join('=');
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(rawValue);
|
|
||||||
} catch {
|
|
||||||
return rawValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeCookie(name: string, value: string, maxAgeSeconds: number): void {
|
|
||||||
const encoded = encodeURIComponent(value);
|
|
||||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
||||||
document.cookie = `${name}=${encoded}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}; SameSite=Lax${secure}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearCookie(name: string): void {
|
|
||||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
||||||
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEffectLevels(): Partial<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | null {
|
loadEffectLevels(): Partial<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | null {
|
||||||
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
|
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -145,18 +116,14 @@ export class SettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadAuthSessionToken(): string {
|
loadAuthSessionToken(): string {
|
||||||
// Session token is persisted in cookie storage (not localStorage).
|
// Session token now lives in an HttpOnly cookie managed by the server.
|
||||||
localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY);
|
localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY);
|
||||||
return this.readCookie(AUTH_SESSION_COOKIE_NAME);
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAuthSessionToken(token: string): void {
|
saveAuthSessionToken(token: string): void {
|
||||||
|
void token;
|
||||||
localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY);
|
localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY);
|
||||||
if (token) {
|
|
||||||
this.writeCookie(AUTH_SESSION_COOKIE_NAME, token, AUTH_SESSION_MAX_AGE_SECONDS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clearCookie(AUTH_SESSION_COOKIE_NAME);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAuthUsername(): string {
|
loadAuthUsername(): string {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Defaults:
|
|||||||
- Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev.
|
- Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev.
|
||||||
- Client dev default is `localhost:5173`.
|
- Client dev default is `localhost:5173`.
|
||||||
- Auth requires `CHGRID_AUTH_SECRET` in environment.
|
- Auth requires `CHGRID_AUTH_SECRET` in environment.
|
||||||
|
- Saved login uses server-managed `HttpOnly` cookie (`chgrid_session_token`) via `GET /auth/session/set` and `GET /auth/session/clear` (both require `X-Chgrid-Auth-Client: 1`).
|
||||||
|
|
||||||
## Quick Restarts
|
## Quick Restarts
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,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 server HTTP endpoint `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`.
|
||||||
- `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.
|
||||||
@@ -111,6 +112,10 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- Server is authoritative for all action validation and normalization.
|
- Server is authoritative for all action validation and normalization.
|
||||||
- Server is authoritative for movement acceptance (bounds + rate/delta checks).
|
- Server is authoritative for movement acceptance (bounds + rate/delta checks).
|
||||||
- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume.
|
- Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume.
|
||||||
|
- Server also supports websocket handshake cookie resume:
|
||||||
|
- reads `chgrid_session_token` from websocket `Cookie` header
|
||||||
|
- attempts resume before sending `auth_required`
|
||||||
|
- exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` required)
|
||||||
- Server applies auth hardening before accepting login/register/resume:
|
- Server applies auth hardening before accepting login/register/resume:
|
||||||
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency
|
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency
|
||||||
- repeated auth failures are rate-limited by IP and IP+identity windows
|
- repeated auth failures are rate-limited by IP and IP+identity windows
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
## Connect Flow
|
## Connect Flow
|
||||||
|
|
||||||
1. User clicks connect.
|
1. User clicks connect.
|
||||||
2. Client validates auth form/session token and sets up local media.
|
2. Client validates auth form and sets up local media.
|
||||||
3. Client connects signaling websocket.
|
3. Client connects signaling websocket.
|
||||||
4. Server sends `auth_required`.
|
4. Server attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
|
||||||
|
5. If resume does not authenticate, server sends `auth_required`.
|
||||||
- includes `authPolicy` limits for username/password.
|
- includes `authPolicy` limits for username/password.
|
||||||
5. Client sends `auth_login`, `auth_register`, or `auth_resume`.
|
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
||||||
6. Server sends `auth_result`.
|
7. Server sends `auth_result`.
|
||||||
- includes role + permissions for authenticated session.
|
- includes role + permissions for authenticated session.
|
||||||
7. Server sends `welcome` with users/items snapshot.
|
8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors.
|
||||||
8. Client:
|
9. Server sends `welcome` with users/items snapshot.
|
||||||
|
10. Client:
|
||||||
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
|
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
|
||||||
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
|
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
|
||||||
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
|
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from pydantic import ValidationError, TypeAdapter
|
from pydantic import ValidationError, TypeAdapter
|
||||||
from websockets.asyncio.server import ServerConnection, serve
|
from websockets.asyncio.server import ServerConnection, serve
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
from websockets.http11 import Request as HttpRequest, Response as HttpResponse
|
||||||
|
|
||||||
from .auth_service import AuthError, AuthService
|
from .auth_service import AuthError, AuthService
|
||||||
from .client import ClientConnection
|
from .client import ClientConnection
|
||||||
@@ -118,6 +120,11 @@ AUTH_FAILURE_JITTER_MAX_MS = 0.08
|
|||||||
RADIO_METADATA_POLL_INTERVAL_S = 10.0
|
RADIO_METADATA_POLL_INTERVAL_S = 10.0
|
||||||
RADIO_METADATA_TIMEOUT_S = 6.0
|
RADIO_METADATA_TIMEOUT_S = 6.0
|
||||||
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
|
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
|
||||||
|
AUTH_SESSION_COOKIE_NAME = "chgrid_session_token"
|
||||||
|
AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60
|
||||||
|
AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set"
|
||||||
|
AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear"
|
||||||
|
AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client"
|
||||||
ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = (
|
ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = (
|
||||||
{"id": "manage_roles", "label": "Role management", "permission": "role.manage"},
|
{"id": "manage_roles", "label": "Role management", "permission": "role.manage"},
|
||||||
{"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"},
|
{"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"},
|
||||||
@@ -246,6 +253,84 @@ class SignalingServer:
|
|||||||
"passwordMaxLength": self.auth_service.password_max_length,
|
"passwordMaxLength": self.auth_service.password_max_length,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
|
||||||
|
"""Return True when session cookies should be marked Secure."""
|
||||||
|
|
||||||
|
if self._ssl_context is not None:
|
||||||
|
return True
|
||||||
|
if request is None:
|
||||||
|
return False
|
||||||
|
forwarded = str(request.headers.get("X-Forwarded-Proto", "")).split(",", 1)[0].strip().lower()
|
||||||
|
return forwarded == "https"
|
||||||
|
|
||||||
|
def _session_cookie_header(self, token: str, *, request: HttpRequest | None = None) -> str:
|
||||||
|
"""Build Set-Cookie header value for a valid session token."""
|
||||||
|
|
||||||
|
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
||||||
|
return (
|
||||||
|
f"{AUTH_SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; "
|
||||||
|
f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_session_cookie_header(self, *, request: HttpRequest | None = None) -> str:
|
||||||
|
"""Build Set-Cookie header value that expires the session cookie."""
|
||||||
|
|
||||||
|
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
||||||
|
return f"{AUTH_SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cookie_value(cookie_header: str, name: str) -> str:
|
||||||
|
"""Extract one cookie value by name from a Cookie header."""
|
||||||
|
|
||||||
|
for segment in cookie_header.split(";"):
|
||||||
|
key, separator, raw_value = segment.strip().partition("=")
|
||||||
|
if separator and key == name:
|
||||||
|
return raw_value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def _process_http_request(self, _connection: ServerConnection, request: HttpRequest) -> HttpResponse | None:
|
||||||
|
"""Handle lightweight same-origin auth cookie set/clear HTTP endpoints."""
|
||||||
|
|
||||||
|
path = request.path.split("?", 1)[0]
|
||||||
|
if path not in {AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = Headers()
|
||||||
|
headers["Content-Type"] = "text/plain; charset=utf-8"
|
||||||
|
headers["Cache-Control"] = "no-store"
|
||||||
|
client_header = str(request.headers.get(AUTH_SESSION_COOKIE_CLIENT_HEADER, "")).strip()
|
||||||
|
if client_header != "1":
|
||||||
|
return HttpResponse(400, "Bad Request", headers, b"missing client header")
|
||||||
|
|
||||||
|
if path == AUTH_SESSION_COOKIE_CLEAR_PATH:
|
||||||
|
headers["Set-Cookie"] = self._clear_session_cookie_header(request=request)
|
||||||
|
return HttpResponse(200, "OK", headers, b"cleared")
|
||||||
|
|
||||||
|
authorization = str(request.headers.get("Authorization", "")).strip()
|
||||||
|
if not authorization.lower().startswith("bearer "):
|
||||||
|
return HttpResponse(400, "Bad Request", headers, b"missing bearer token")
|
||||||
|
token = authorization[7:].strip()
|
||||||
|
if not token:
|
||||||
|
return HttpResponse(400, "Bad Request", headers, b"missing bearer token")
|
||||||
|
try:
|
||||||
|
session = self.auth_service.resume(token)
|
||||||
|
except AuthError:
|
||||||
|
return HttpResponse(401, "Unauthorized", headers, b"invalid session")
|
||||||
|
headers["Set-Cookie"] = self._session_cookie_header(session.token, request=request)
|
||||||
|
return HttpResponse(200, "OK", headers, b"ok")
|
||||||
|
|
||||||
|
def _session_token_from_websocket_cookie(self, websocket: ServerConnection) -> str:
|
||||||
|
"""Read session token from websocket handshake Cookie header."""
|
||||||
|
|
||||||
|
request = getattr(websocket, "request", None)
|
||||||
|
headers = getattr(request, "headers", None)
|
||||||
|
if headers is None:
|
||||||
|
return ""
|
||||||
|
cookie_header = str(headers.get("Cookie", "")).strip()
|
||||||
|
if not cookie_header:
|
||||||
|
return ""
|
||||||
|
return self._cookie_value(cookie_header, 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."""
|
||||||
|
|
||||||
@@ -1217,6 +1302,7 @@ class SignalingServer:
|
|||||||
self.port,
|
self.port,
|
||||||
ssl=self._ssl_context,
|
ssl=self._ssl_context,
|
||||||
max_size=self.max_message_size,
|
max_size=self.max_message_size,
|
||||||
|
process_request=self._process_http_request,
|
||||||
):
|
):
|
||||||
await asyncio.Future()
|
await asyncio.Future()
|
||||||
finally:
|
finally:
|
||||||
@@ -1245,6 +1331,13 @@ class SignalingServer:
|
|||||||
LOGGER.info("websocket opened id=%s", client.id)
|
LOGGER.info("websocket opened id=%s", client.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
cookie_token = self._session_token_from_websocket_cookie(websocket)
|
||||||
|
if cookie_token:
|
||||||
|
await self._handle_auth_packet(
|
||||||
|
client,
|
||||||
|
AuthResumePacket(type="auth_resume", sessionToken=cookie_token),
|
||||||
|
)
|
||||||
|
if not client.authenticated:
|
||||||
await self._send(
|
await self._send(
|
||||||
websocket,
|
websocket,
|
||||||
AuthRequiredPacket(
|
AuthRequiredPacket(
|
||||||
|
|||||||
74
server/tests/test_http_session_cookie.py
Normal file
74
server/tests/test_http_session_cookie.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
from websockets.http11 import Request
|
||||||
|
|
||||||
|
from app.server import (
|
||||||
|
AUTH_SESSION_COOKIE_CLIENT_HEADER,
|
||||||
|
AUTH_SESSION_COOKIE_CLEAR_PATH,
|
||||||
|
AUTH_SESSION_COOKIE_NAME,
|
||||||
|
AUTH_SESSION_COOKIE_SET_PATH,
|
||||||
|
SignalingServer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(path: str, headers: dict[str, str] | None = None) -> Request:
|
||||||
|
values = Headers()
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
values[key] = value
|
||||||
|
return Request(path=path, headers=values)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
username = f"user_{uuid.uuid4().hex[:8]}"
|
||||||
|
session = server.auth_service.register(username, "password99")
|
||||||
|
request = _request(
|
||||||
|
AUTH_SESSION_COOKIE_SET_PATH,
|
||||||
|
headers={
|
||||||
|
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
||||||
|
"Authorization": f"Bearer {session.token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await server._process_http_request(SimpleNamespace(), request)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
set_cookie = response.headers.get("Set-Cookie", "")
|
||||||
|
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
|
||||||
|
assert "HttpOnly" in set_cookie
|
||||||
|
assert "SameSite=Lax" in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
request = _request(AUTH_SESSION_COOKIE_CLEAR_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"})
|
||||||
|
|
||||||
|
response = await server._process_http_request(SimpleNamespace(), request)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
set_cookie = response.headers.get("Set-Cookie", "")
|
||||||
|
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
|
||||||
|
assert "Max-Age=0" in set_cookie
|
||||||
|
assert "HttpOnly" in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
websocket = SimpleNamespace(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
headers=Headers({"Cookie": f"foo=bar; {AUTH_SESSION_COOKIE_NAME}=abc123; hello=world"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
token = server._session_token_from_websocket_cookie(websocket)
|
||||||
|
|
||||||
|
assert token == "abc123"
|
||||||
Reference in New Issue
Block a user