Add account auth with websocket login/register and sessions
This commit is contained in:
@@ -9,12 +9,41 @@
|
|||||||
<main class="app">
|
<main class="app">
|
||||||
<h1>Chat Grid</h1>
|
<h1>Chat Grid</h1>
|
||||||
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
<section id="loginView" class="auth-panel">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label for="authUsername">Username</label>
|
||||||
|
<input id="authUsername" type="text" maxlength="32" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label for="authPassword">Password</label>
|
||||||
|
<input id="authPassword" type="password" maxlength="64" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<button id="showRegisterButton" type="button">Create account</button>
|
||||||
|
</section>
|
||||||
|
<section id="registerView" class="auth-panel hidden">
|
||||||
|
<h2>Register</h2>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label for="registerUsername">Username</label>
|
||||||
|
<input id="registerUsername" type="text" maxlength="32" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label for="registerPassword">Password</label>
|
||||||
|
<input id="registerPassword" type="password" maxlength="64" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="auth-row">
|
||||||
|
<label for="registerEmail">Email (optional)</label>
|
||||||
|
<input id="registerEmail" type="email" maxlength="320" autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<button id="showLoginButton" type="button">Back to login</button>
|
||||||
|
</section>
|
||||||
<div id="nicknameContainer" class="nickname-row">
|
<div id="nicknameContainer" class="nickname-row">
|
||||||
<label for="preconnectNickname">Nickname</label>
|
<label for="preconnectNickname">Nickname</label>
|
||||||
<input id="preconnectNickname" type="text" maxlength="32" autocomplete="nickname" />
|
<input id="preconnectNickname" type="text" maxlength="32" autocomplete="nickname" />
|
||||||
</div>
|
</div>
|
||||||
<div class="controls" id="button-container">
|
<div class="controls" id="button-container">
|
||||||
<button id="connectButton">Connect</button>
|
<button id="connectButton">Connect</button>
|
||||||
|
<button id="logoutButton">Log out</button>
|
||||||
<button id="settingsButton">Settings</button>
|
<button id="settingsButton">Settings</button>
|
||||||
<button id="disconnectButton" class="hidden">Disconnect</button>
|
<button id="disconnectButton" class="hidden">Disconnect</button>
|
||||||
<button id="focusGridButton" class="hidden" aria-controls="gameCanvas">Chat Grid</button>
|
<button id="focusGridButton" class="hidden" aria-controls="gameCanvas">Chat Grid</button>
|
||||||
|
|||||||
@@ -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.25 R242";
|
window.CHGRID_WEB_VERSION = "2026.02.25 R243";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -90,12 +90,22 @@ declare global {
|
|||||||
type Dom = {
|
type Dom = {
|
||||||
connectionStatus: HTMLElement;
|
connectionStatus: HTMLElement;
|
||||||
appVersion: HTMLElement;
|
appVersion: HTMLElement;
|
||||||
|
loginView: HTMLElement;
|
||||||
|
registerView: HTMLElement;
|
||||||
|
authUsername: HTMLInputElement;
|
||||||
|
authPassword: HTMLInputElement;
|
||||||
|
registerUsername: HTMLInputElement;
|
||||||
|
registerPassword: HTMLInputElement;
|
||||||
|
registerEmail: HTMLInputElement;
|
||||||
|
showRegisterButton: HTMLButtonElement;
|
||||||
|
showLoginButton: HTMLButtonElement;
|
||||||
updatesSection: HTMLElement;
|
updatesSection: HTMLElement;
|
||||||
updatesToggle: HTMLButtonElement;
|
updatesToggle: HTMLButtonElement;
|
||||||
updatesPanel: HTMLDivElement;
|
updatesPanel: HTMLDivElement;
|
||||||
nicknameContainer: HTMLDivElement;
|
nicknameContainer: HTMLDivElement;
|
||||||
preconnectNickname: HTMLInputElement;
|
preconnectNickname: HTMLInputElement;
|
||||||
connectButton: HTMLButtonElement;
|
connectButton: HTMLButtonElement;
|
||||||
|
logoutButton: HTMLButtonElement;
|
||||||
disconnectButton: HTMLButtonElement;
|
disconnectButton: HTMLButtonElement;
|
||||||
focusGridButton: HTMLButtonElement;
|
focusGridButton: HTMLButtonElement;
|
||||||
settingsButton: HTMLButtonElement;
|
settingsButton: HTMLButtonElement;
|
||||||
@@ -113,12 +123,22 @@ type Dom = {
|
|||||||
const dom: Dom = {
|
const dom: Dom = {
|
||||||
connectionStatus: requiredById('connectionStatus'),
|
connectionStatus: requiredById('connectionStatus'),
|
||||||
appVersion: requiredById('appVersion'),
|
appVersion: requiredById('appVersion'),
|
||||||
|
loginView: requiredById('loginView'),
|
||||||
|
registerView: requiredById('registerView'),
|
||||||
|
authUsername: requiredById('authUsername'),
|
||||||
|
authPassword: requiredById('authPassword'),
|
||||||
|
registerUsername: requiredById('registerUsername'),
|
||||||
|
registerPassword: requiredById('registerPassword'),
|
||||||
|
registerEmail: requiredById('registerEmail'),
|
||||||
|
showRegisterButton: requiredById('showRegisterButton'),
|
||||||
|
showLoginButton: requiredById('showLoginButton'),
|
||||||
updatesSection: requiredById('updatesSection'),
|
updatesSection: requiredById('updatesSection'),
|
||||||
updatesToggle: requiredById('updatesToggle'),
|
updatesToggle: requiredById('updatesToggle'),
|
||||||
updatesPanel: requiredById('updatesPanel'),
|
updatesPanel: requiredById('updatesPanel'),
|
||||||
nicknameContainer: requiredById('nicknameContainer'),
|
nicknameContainer: requiredById('nicknameContainer'),
|
||||||
preconnectNickname: requiredById('preconnectNickname'),
|
preconnectNickname: requiredById('preconnectNickname'),
|
||||||
connectButton: requiredById('connectButton'),
|
connectButton: requiredById('connectButton'),
|
||||||
|
logoutButton: requiredById('logoutButton'),
|
||||||
disconnectButton: requiredById('disconnectButton'),
|
disconnectButton: requiredById('disconnectButton'),
|
||||||
focusGridButton: requiredById('focusGridButton'),
|
focusGridButton: requiredById('focusGridButton'),
|
||||||
settingsButton: requiredById('settingsButton'),
|
settingsButton: requiredById('settingsButton'),
|
||||||
@@ -203,6 +223,10 @@ let lastFocusedElement: Element | null = null;
|
|||||||
let lastAnnouncementText = '';
|
let lastAnnouncementText = '';
|
||||||
let lastAnnouncementAt = 0;
|
let lastAnnouncementAt = 0;
|
||||||
let outputMode = settings.loadOutputMode();
|
let outputMode = settings.loadOutputMode();
|
||||||
|
let authMode: 'login' | 'register' = 'login';
|
||||||
|
let authSessionToken = settings.loadAuthSessionToken();
|
||||||
|
let authUsername = settings.loadAuthUsername();
|
||||||
|
let pendingAuthRequest = false;
|
||||||
const messageBuffer: string[] = [];
|
const messageBuffer: string[] = [];
|
||||||
let messageCursor = -1;
|
let messageCursor = -1;
|
||||||
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
||||||
@@ -482,14 +506,33 @@ function sanitizeName(value: string): string {
|
|||||||
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
|
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalizes auth username according to server policy. */
|
||||||
|
function sanitizeAuthUsername(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, '')
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
|
dom.logoutButton.disabled = !authSessionToken.trim() && !state.running;
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
dom.connectButton.disabled = true;
|
dom.connectButton.disabled = true;
|
||||||
|
dom.loginView.classList.add('hidden');
|
||||||
|
dom.registerView.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0;
|
dom.loginView.classList.toggle('hidden', authMode !== 'login');
|
||||||
dom.connectButton.disabled = mediaSession.isConnecting() || !hasNickname;
|
dom.registerView.classList.toggle('hidden', authMode !== 'register');
|
||||||
|
const hasSessionToken = authSessionToken.trim().length > 0;
|
||||||
|
const hasLoginCredentials =
|
||||||
|
sanitizeAuthUsername(dom.authUsername.value).length >= 2 && dom.authPassword.value.trim().length >= 8;
|
||||||
|
const hasRegisterCredentials =
|
||||||
|
sanitizeAuthUsername(dom.registerUsername.value).length >= 2 && dom.registerPassword.value.trim().length >= 8;
|
||||||
|
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials);
|
||||||
|
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restores persisted outbound effect levels from local storage. */
|
/** Restores persisted outbound effect levels from local storage. */
|
||||||
@@ -1294,6 +1337,112 @@ async function reconnectWithRetry(reason: 'heartbeat' | 'socketClose'): Promise<
|
|||||||
reconnectInFlight = false;
|
reconnectInFlight = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Switches pre-connect auth view between login and register modes. */
|
||||||
|
function setAuthMode(mode: 'login' | 'register'): void {
|
||||||
|
authMode = mode;
|
||||||
|
dom.loginView.classList.toggle('hidden', mode !== 'login');
|
||||||
|
dom.registerView.classList.toggle('hidden', mode !== 'register');
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds outbound auth packet from local token or active auth form. */
|
||||||
|
function buildAuthRequestPacket(): OutgoingMessage | null {
|
||||||
|
const token = authSessionToken.trim();
|
||||||
|
if (token) {
|
||||||
|
return { type: 'auth_resume', sessionToken: token };
|
||||||
|
}
|
||||||
|
if (authMode === 'register') {
|
||||||
|
const username = sanitizeAuthUsername(dom.registerUsername.value);
|
||||||
|
const password = dom.registerPassword.value;
|
||||||
|
const email = dom.registerEmail.value.trim();
|
||||||
|
if (!username || !password) return null;
|
||||||
|
return { type: 'auth_register', username, password, ...(email ? { email } : {}) };
|
||||||
|
}
|
||||||
|
const username = sanitizeAuthUsername(dom.authUsername.value);
|
||||||
|
const password = dom.authPassword.value;
|
||||||
|
if (!username || !password) return null;
|
||||||
|
return { type: 'auth_login', username, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends current auth request over signaling websocket after socket open. */
|
||||||
|
function sendAuthRequest(): void {
|
||||||
|
const packet = buildAuthRequestPacket();
|
||||||
|
if (!packet) {
|
||||||
|
updateStatus('Enter username and password.');
|
||||||
|
audio.sfxUiCancel();
|
||||||
|
mediaSession.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
signaling.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingAuthRequest = true;
|
||||||
|
setConnectionStatus('Authenticating...');
|
||||||
|
signaling.send(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles server auth-required prompts prior to world welcome. */
|
||||||
|
function handleAuthRequired(message: string): void {
|
||||||
|
setConnectionStatus('Authentication required.');
|
||||||
|
updateStatus(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies auth result state and terminates failed auth attempts quickly. */
|
||||||
|
async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> {
|
||||||
|
pendingAuthRequest = false;
|
||||||
|
if (!message.ok) {
|
||||||
|
dom.authPassword.value = '';
|
||||||
|
dom.registerPassword.value = '';
|
||||||
|
if (message.message.toLowerCase().includes('session')) {
|
||||||
|
authSessionToken = '';
|
||||||
|
settings.saveAuthSessionToken('');
|
||||||
|
}
|
||||||
|
updateStatus(message.message);
|
||||||
|
audio.sfxUiCancel();
|
||||||
|
mediaSession.setConnecting(false);
|
||||||
|
updateConnectAvailability();
|
||||||
|
signaling.disconnect();
|
||||||
|
setConnectionStatus('Authentication failed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.sessionToken) {
|
||||||
|
authSessionToken = message.sessionToken;
|
||||||
|
settings.saveAuthSessionToken(message.sessionToken);
|
||||||
|
}
|
||||||
|
if (message.username) {
|
||||||
|
authUsername = message.username;
|
||||||
|
settings.saveAuthUsername(message.username);
|
||||||
|
dom.authUsername.value = message.username;
|
||||||
|
dom.registerUsername.value = message.username;
|
||||||
|
}
|
||||||
|
if (message.nickname) {
|
||||||
|
const resolved = sanitizeName(message.nickname);
|
||||||
|
if (resolved) {
|
||||||
|
state.player.nickname = resolved;
|
||||||
|
dom.preconnectNickname.value = resolved;
|
||||||
|
settings.saveNickname(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.authPassword.value = '';
|
||||||
|
dom.registerPassword.value = '';
|
||||||
|
setConnectionStatus('Authenticated. Joining world...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears stored auth session and returns UI to login mode. */
|
||||||
|
function logOutAccount(): void {
|
||||||
|
authSessionToken = '';
|
||||||
|
authUsername = '';
|
||||||
|
settings.saveAuthSessionToken('');
|
||||||
|
settings.saveAuthUsername('');
|
||||||
|
if (state.running) {
|
||||||
|
signaling.send({ type: 'auth_logout' });
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
setAuthMode('login');
|
||||||
|
updateStatus('Logged out.');
|
||||||
|
updateConnectAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds dependencies shared by connect/disconnect flow helpers. */
|
/** Builds dependencies shared by connect/disconnect flow helpers. */
|
||||||
function getConnectionFlowDeps(): ConnectFlowDeps {
|
function getConnectionFlowDeps(): ConnectFlowDeps {
|
||||||
return {
|
return {
|
||||||
@@ -1322,6 +1471,7 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
|
|||||||
mediaDescribeError: (error) => describeMediaError(error),
|
mediaDescribeError: (error) => describeMediaError(error),
|
||||||
mediaStopLocalMedia: () => stopLocalMedia(),
|
mediaStopLocalMedia: () => stopLocalMedia(),
|
||||||
signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise<void>),
|
signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise<void>),
|
||||||
|
signalingSendAuth: () => sendAuthRequest(),
|
||||||
signalingDisconnect: () => signaling.disconnect(),
|
signalingDisconnect: () => signaling.disconnect(),
|
||||||
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
|
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
|
||||||
persistPlayerPosition,
|
persistPlayerPosition,
|
||||||
@@ -1423,6 +1573,8 @@ const onAppMessage = createOnMessageHandler({
|
|||||||
playIncomingItemUseSound: (url, x, y) => {
|
playIncomingItemUseSound: (url, x, y) => {
|
||||||
void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1);
|
void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1);
|
||||||
},
|
},
|
||||||
|
handleAuthRequired,
|
||||||
|
handleAuthResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
||||||
@@ -2429,20 +2581,51 @@ function setupUiHandlers(): void {
|
|||||||
persistPlayerPosition();
|
persistPlayerPosition();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
dom.showRegisterButton.addEventListener('click', () => {
|
||||||
|
setAuthMode('register');
|
||||||
|
dom.registerUsername.focus();
|
||||||
|
});
|
||||||
|
dom.showLoginButton.addEventListener('click', () => {
|
||||||
|
setAuthMode('login');
|
||||||
|
dom.authUsername.focus();
|
||||||
|
});
|
||||||
|
dom.logoutButton.addEventListener('click', () => {
|
||||||
|
logOutAccount();
|
||||||
|
});
|
||||||
|
dom.authUsername.addEventListener('input', () => {
|
||||||
|
dom.authUsername.value = sanitizeAuthUsername(dom.authUsername.value);
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
dom.authPassword.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
dom.registerUsername.addEventListener('input', () => {
|
||||||
|
dom.registerUsername.value = sanitizeAuthUsername(dom.registerUsername.value);
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
dom.registerPassword.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
|
dom.registerEmail.addEventListener('input', () => {
|
||||||
|
updateConnectAvailability();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInputHandlers();
|
setupInputHandlers();
|
||||||
setupUiHandlers();
|
setupUiHandlers();
|
||||||
|
dom.authUsername.value = sanitizeAuthUsername(authUsername);
|
||||||
|
dom.registerUsername.value = sanitizeAuthUsername(authUsername);
|
||||||
const storedNickname = sanitizeName(settings.loadNickname());
|
const storedNickname = sanitizeName(settings.loadNickname());
|
||||||
dom.preconnectNickname.value = storedNickname;
|
dom.preconnectNickname.value = storedNickname;
|
||||||
if (storedNickname) {
|
if (storedNickname) {
|
||||||
state.player.nickname = storedNickname;
|
state.player.nickname = storedNickname;
|
||||||
}
|
}
|
||||||
|
setAuthMode('login');
|
||||||
updateConnectAvailability();
|
updateConnectAvailability();
|
||||||
updateDeviceSummary();
|
updateDeviceSummary();
|
||||||
updateStatus(
|
updateStatus(
|
||||||
isVersionReloadedSession()
|
isVersionReloadedSession()
|
||||||
? 'Client updated, please reconnect.'
|
? 'Client updated, please reconnect.'
|
||||||
: 'Welcome to the Chat Grid. Press the Settings button to configure your audio, then Connect to join the grid.',
|
: 'Welcome to the Chat Grid. Log in or register, configure audio if needed, then Connect to join the grid.',
|
||||||
);
|
);
|
||||||
setConnectionStatus(isVersionReloadedSession() ? 'Client updated, please reconnect.' : 'Not connected.');
|
setConnectionStatus(isVersionReloadedSession() ? 'Client updated, please reconnect.' : 'Not connected.');
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ type MessageHandlerDeps = {
|
|||||||
playLocateToneAt: (x: number, y: number) => void;
|
playLocateToneAt: (x: number, y: number) => void;
|
||||||
resolveIncomingSoundUrl: (url: string) => string;
|
resolveIncomingSoundUrl: (url: string) => string;
|
||||||
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
||||||
|
handleAuthRequired: (message: string) => void;
|
||||||
|
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +83,14 @@ type MessageHandlerDeps = {
|
|||||||
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
|
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
|
||||||
return async function onMessage(message: IncomingMessage): Promise<void> {
|
return async function onMessage(message: IncomingMessage): Promise<void> {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
case 'auth_required':
|
||||||
|
deps.handleAuthRequired(message.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'auth_result':
|
||||||
|
await deps.handleAuthResult(message);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'welcome':
|
case 'welcome':
|
||||||
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
|
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
|
||||||
deps.setWorldGridSize(message.worldConfig.gridSize);
|
deps.setWorldGridSize(message.worldConfig.gridSize);
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const welcomeMessageSchema = z.object({
|
|||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
auth: z
|
||||||
|
.object({
|
||||||
|
authenticated: z.boolean(),
|
||||||
|
userId: z.string().nullable().optional(),
|
||||||
|
username: z.string().nullable().optional(),
|
||||||
|
role: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
uiDefinitions: z
|
uiDefinitions: z
|
||||||
.object({
|
.object({
|
||||||
itemTypeOrder: z.array(z.string().min(1)),
|
itemTypeOrder: z.array(z.string().min(1)),
|
||||||
@@ -85,6 +93,21 @@ export const welcomeMessageSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const authRequiredSchema = z.object({
|
||||||
|
type: z.literal('auth_required'),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authResultSchema = z.object({
|
||||||
|
type: z.literal('auth_result'),
|
||||||
|
ok: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
sessionToken: z.string().optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const signalMessageSchema = z.object({
|
export const signalMessageSchema = z.object({
|
||||||
type: z.literal('signal'),
|
type: z.literal('signal'),
|
||||||
senderId: z.string(),
|
senderId: z.string(),
|
||||||
@@ -203,6 +226,8 @@ export const itemPianoStatusSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const incomingMessageSchema = z.discriminatedUnion('type', [
|
export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||||
|
authRequiredSchema,
|
||||||
|
authResultSchema,
|
||||||
welcomeMessageSchema,
|
welcomeMessageSchema,
|
||||||
signalMessageSchema,
|
signalMessageSchema,
|
||||||
updatePositionSchema,
|
updatePositionSchema,
|
||||||
@@ -223,6 +248,10 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
|
|||||||
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
||||||
|
|
||||||
export type OutgoingMessage =
|
export type OutgoingMessage =
|
||||||
|
| { type: 'auth_register'; username: string; password: string; email?: string }
|
||||||
|
| { type: 'auth_login'; username: string; password: string }
|
||||||
|
| { type: 'auth_resume'; sessionToken: string }
|
||||||
|
| { type: 'auth_logout' }
|
||||||
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
||||||
| { type: 'update_position'; x: number; y: number }
|
| { type: 'update_position'; x: number; y: number }
|
||||||
| { type: 'teleport_complete'; x: number; y: number }
|
| { type: 'teleport_complete'; x: number; y: number }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ConnectFlowDeps = {
|
|||||||
mediaDescribeError: (error: unknown) => string;
|
mediaDescribeError: (error: unknown) => string;
|
||||||
mediaStopLocalMedia: () => void;
|
mediaStopLocalMedia: () => void;
|
||||||
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
|
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
|
||||||
|
signalingSendAuth: () => void;
|
||||||
signalingDisconnect: () => void;
|
signalingDisconnect: () => void;
|
||||||
onMessage: (message: unknown) => Promise<void>;
|
onMessage: (message: unknown) => Promise<void>;
|
||||||
persistPlayerPosition: () => void;
|
persistPlayerPosition: () => void;
|
||||||
@@ -46,14 +47,11 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nickname = deps.sanitizeName(deps.dom.preconnectNickname.value);
|
const nickname = deps.sanitizeName(deps.dom.preconnectNickname.value);
|
||||||
if (!nickname) {
|
deps.state.player.nickname = nickname || deps.state.player.nickname;
|
||||||
deps.updateStatus('Nickname is required.');
|
|
||||||
deps.updateConnectAvailability();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deps.state.player.nickname = nickname;
|
|
||||||
deps.dom.preconnectNickname.value = nickname;
|
deps.dom.preconnectNickname.value = nickname;
|
||||||
|
if (nickname) {
|
||||||
deps.settingsSaveNickname(nickname);
|
deps.settingsSaveNickname(nickname);
|
||||||
|
}
|
||||||
deps.mediaSetConnecting(true);
|
deps.mediaSetConnecting(true);
|
||||||
deps.updateConnectAvailability();
|
deps.updateConnectAvailability();
|
||||||
|
|
||||||
@@ -85,6 +83,7 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deps.signalingConnect(deps.onMessage);
|
await deps.signalingConnect(deps.onMessage);
|
||||||
|
deps.signalingSendAuth();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (deps.state.running || !deps.mediaIsConnecting()) {
|
if (deps.state.running || !deps.mediaIsConnecting()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
|||||||
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
||||||
const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
|
const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
|
||||||
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
||||||
|
const AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken';
|
||||||
|
const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername';
|
||||||
|
|
||||||
type DevicePreference = {
|
type DevicePreference = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -113,6 +115,30 @@ export class SettingsStore {
|
|||||||
localStorage.setItem(NICKNAME_STORAGE_KEY, value);
|
localStorage.setItem(NICKNAME_STORAGE_KEY, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadAuthSessionToken(): string {
|
||||||
|
return localStorage.getItem(AUTH_SESSION_TOKEN_STORAGE_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAuthSessionToken(token: string): void {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(AUTH_SESSION_TOKEN_STORAGE_KEY, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(AUTH_SESSION_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuthUsername(): string {
|
||||||
|
return localStorage.getItem(AUTH_USERNAME_STORAGE_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAuthUsername(username: string): void {
|
||||||
|
if (username) {
|
||||||
|
localStorage.setItem(AUTH_USERNAME_STORAGE_KEY, username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(AUTH_USERNAME_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
loadOutputMode(): 'mono' | 'stereo' {
|
loadOutputMode(): 'mono' | 'stereo' {
|
||||||
return localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo';
|
return localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,44 @@ body {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: min(460px, 95vw);
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: rgb(17 24 39 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-row label {
|
||||||
|
color: #cbd5e1;
|
||||||
|
min-width: 135px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-row input {
|
||||||
|
background: #111827;
|
||||||
|
color: #e5e7eb;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
width: min(280px, 60vw);
|
||||||
|
}
|
||||||
|
|
||||||
.nickname-row {
|
.nickname-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Notes:
|
|||||||
This creates:
|
This creates:
|
||||||
- `/home/bestmidi/chgrid/server/.venv`
|
- `/home/bestmidi/chgrid/server/.venv`
|
||||||
- `/home/bestmidi/chgrid/server/config.toml` (if missing)
|
- `/home/bestmidi/chgrid/server/config.toml` (if missing)
|
||||||
|
- `/home/bestmidi/chgrid/server/.env` with `CHGRID_AUTH_SECRET` (if missing)
|
||||||
|
|
||||||
Edit `/home/bestmidi/chgrid/server/config.toml`:
|
Edit `/home/bestmidi/chgrid/server/config.toml`:
|
||||||
- `server.bind_ip = "127.0.0.1"`
|
- `server.bind_ip = "127.0.0.1"`
|
||||||
@@ -45,6 +46,7 @@ Edit `/home/bestmidi/chgrid/server/config.toml`:
|
|||||||
- `tls.cert_file = ""`
|
- `tls.cert_file = ""`
|
||||||
- `tls.key_file = ""`
|
- `tls.key_file = ""`
|
||||||
- `storage.state_file = "runtime/items.json"`
|
- `storage.state_file = "runtime/items.json"`
|
||||||
|
- `auth.db_file = "runtime/chatgrid.db"`
|
||||||
|
|
||||||
## 4) Build and publish client
|
## 4) Build and publish client
|
||||||
|
|
||||||
|
|||||||
@@ -49,5 +49,17 @@ fi
|
|||||||
|
|
||||||
mkdir -p runtime
|
mkdir -p runtime
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
AUTH_SECRET="$(
|
||||||
|
python3 - <<'PY'
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(64))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
printf "CHGRID_AUTH_SECRET=%s\n" "$AUTH_SECRET" > .env
|
||||||
|
chmod 600 .env
|
||||||
|
echo "created $SERVER_DIR/.env with CHGRID_AUTH_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "server install complete"
|
echo "server install complete"
|
||||||
echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port)"
|
echo "next: edit $SERVER_DIR/config.toml (TLS, bind_ip, port)"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ User=bestmidi
|
|||||||
Group=bestmidi
|
Group=bestmidi
|
||||||
WorkingDirectory=/home/bestmidi/chgrid/server
|
WorkingDirectory=/home/bestmidi/chgrid/server
|
||||||
Environment=PATH=/home/bestmidi/chgrid/server/.venv/bin:/usr/bin:/bin
|
Environment=PATH=/home/bestmidi/chgrid/server/.venv/bin:/usr/bin:/bin
|
||||||
|
EnvironmentFile=-/home/bestmidi/chgrid/server/.env
|
||||||
ExecStartPre=/usr/bin/mkdir -p /home/bestmidi/chgrid/server/runtime
|
ExecStartPre=/usr/bin/mkdir -p /home/bestmidi/chgrid/server/runtime
|
||||||
ExecStart=/home/bestmidi/chgrid/server/.venv/bin/python main.py --config /home/bestmidi/chgrid/server/config.toml
|
ExecStart=/home/bestmidi/chgrid/server/.venv/bin/python main.py --config /home/bestmidi/chgrid/server/config.toml
|
||||||
StandardOutput=append:/home/bestmidi/chgrid/server/runtime/server.log
|
StandardOutput=append:/home/bestmidi/chgrid/server/runtime/server.log
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
|
|
||||||
## Client -> Server
|
## Client -> Server
|
||||||
|
|
||||||
|
- `auth_register`: create account with username/password and optional email.
|
||||||
|
- `auth_login`: authenticate with username/password.
|
||||||
|
- `auth_resume`: resume prior session via stored session token.
|
||||||
|
- `auth_logout`: revoke current session and disconnect.
|
||||||
- `update_position`: client movement intent; server enforces world bounds and movement rate policy.
|
- `update_position`: client movement intent; server enforces world bounds and movement rate policy.
|
||||||
- `teleport_complete`: client signals teleport landing; server rebroadcasts spatial landing cue.
|
- `teleport_complete`: client signals teleport landing; server rebroadcasts spatial landing cue.
|
||||||
- `update_nickname`: nickname change request (server enforces uniqueness).
|
- `update_nickname`: nickname change request (server enforces uniqueness).
|
||||||
@@ -21,6 +25,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
|
|
||||||
## Server -> Client
|
## Server -> Client
|
||||||
|
|
||||||
|
- `auth_required`: authentication challenge after websocket connect.
|
||||||
|
- `auth_result`: auth success/failure and session/account metadata.
|
||||||
- `welcome`: initial snapshot with users/items plus server UI/world metadata.
|
- `welcome`: initial snapshot with users/items plus server UI/world metadata.
|
||||||
- `signal`: forwarded WebRTC offer/answer/ICE.
|
- `signal`: forwarded WebRTC offer/answer/ICE.
|
||||||
- `update_position`, `update_nickname`, `user_left`: presence updates.
|
- `update_position`, `update_nickname`, `user_left`: presence updates.
|
||||||
@@ -51,6 +57,11 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
|
|
||||||
## Welcome Metadata
|
## Welcome Metadata
|
||||||
|
|
||||||
|
- `welcome.auth`: authenticated account identity:
|
||||||
|
- `authenticated`
|
||||||
|
- `userId`
|
||||||
|
- `username`
|
||||||
|
- `role`
|
||||||
- `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.
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
## Connect Flow
|
## Connect Flow
|
||||||
|
|
||||||
1. User clicks connect.
|
1. User clicks connect.
|
||||||
2. Client validates nickname and sets up local media.
|
2. Client validates auth form/session token and sets up local media.
|
||||||
3. Client connects signaling websocket.
|
3. Client connects signaling websocket.
|
||||||
4. Server sends `welcome` with users/items snapshot.
|
4. Server sends `auth_required`.
|
||||||
5. Client:
|
5. Client sends `auth_login`, `auth_register`, or `auth_resume`.
|
||||||
|
6. Server sends `auth_result`.
|
||||||
|
7. Server sends `welcome` with users/items snapshot.
|
||||||
|
8. 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
|
||||||
@@ -38,6 +41,8 @@ Each frame:
|
|||||||
Core incoming message effects:
|
Core incoming message effects:
|
||||||
|
|
||||||
- `signal`: WebRTC negotiation and ICE exchange.
|
- `signal`: WebRTC negotiation and ICE exchange.
|
||||||
|
- `auth_required`: prompt client to authenticate before gameplay messages.
|
||||||
|
- `auth_result`: auth success/failure with optional session token + account metadata.
|
||||||
- `update_position`: update peer position; may play movement/teleport world sound.
|
- `update_position`: update peer position; may play movement/teleport world sound.
|
||||||
- `teleport_complete`: play peer teleport landing sound at final tile.
|
- `teleport_complete`: play peer teleport landing sound at final tile.
|
||||||
- `update_nickname`: update peer display name.
|
- `update_nickname`: update peer display name.
|
||||||
|
|||||||
397
server/app/auth_service.py
Normal file
397
server/app/auth_service.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
"""Account and session persistence service for websocket authentication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
||||||
|
SALT_BYTES = 16
|
||||||
|
PBKDF2_ITERATIONS = 310_000
|
||||||
|
PBKDF2_DKLEN = 32
|
||||||
|
USERNAME_PATTERN = re.compile(r"^[a-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthUser:
|
||||||
|
"""Authenticated account identity details."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
status: str
|
||||||
|
email: str | None
|
||||||
|
last_nickname: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthSession:
|
||||||
|
"""Session validation result with user identity."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
token: str
|
||||||
|
user: AuthUser
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(ValueError):
|
||||||
|
"""Raised when authentication input or policy checks fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Manages account registration, login, and rolling session validation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: Path,
|
||||||
|
token_hash_secret: str,
|
||||||
|
password_min_length: int,
|
||||||
|
password_max_length: int,
|
||||||
|
username_min_length: int,
|
||||||
|
username_max_length: int,
|
||||||
|
):
|
||||||
|
"""Initialize auth database connection and schema."""
|
||||||
|
|
||||||
|
self.db_path = db_path
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.password_min_length = max(1, int(password_min_length))
|
||||||
|
self.password_max_length = max(self.password_min_length, int(password_max_length))
|
||||||
|
self.username_min_length = max(1, int(username_min_length))
|
||||||
|
self.username_max_length = max(self.username_min_length, int(username_max_length))
|
||||||
|
secret = token_hash_secret.strip()
|
||||||
|
if not secret:
|
||||||
|
raise AuthError("CHGRID_AUTH_SECRET is required when auth is enabled.")
|
||||||
|
self._token_secret = secret.encode("utf-8")
|
||||||
|
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the underlying SQLite connection."""
|
||||||
|
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def bootstrap_admin(self, username: str, password: str, email: str | None = None) -> AuthUser:
|
||||||
|
"""Create the first admin account, or fail if one already exists."""
|
||||||
|
|
||||||
|
existing = self._conn.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
raise AuthError("An admin account already exists.")
|
||||||
|
created = self.register(username, password, email=email, role="admin")
|
||||||
|
return created.user
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
email: str | None = None,
|
||||||
|
role: str = "user",
|
||||||
|
) -> AuthSession:
|
||||||
|
"""Register an account and issue a session token."""
|
||||||
|
|
||||||
|
normalized_username = self._normalize_username(username)
|
||||||
|
self._validate_username(normalized_username)
|
||||||
|
self._validate_password(password)
|
||||||
|
normalized_email = self._normalize_email(email)
|
||||||
|
if role not in {"user", "admin"}:
|
||||||
|
raise AuthError("role must be user or admin.")
|
||||||
|
now_ms = self.now_ms()
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
password_hash = self._hash_password(password)
|
||||||
|
try:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, password_hash, email, role, status, last_nickname, created_at_ms, updated_at_ms, last_login_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
message = str(exc).lower()
|
||||||
|
if "users.username" in message:
|
||||||
|
raise AuthError("Username is already taken.") from exc
|
||||||
|
if "users.email" in message:
|
||||||
|
raise AuthError("Email is already in use.") from exc
|
||||||
|
raise
|
||||||
|
user = self._get_user_by_username(normalized_username)
|
||||||
|
if user is None:
|
||||||
|
raise AuthError("Failed to load newly created user.")
|
||||||
|
return self._create_session(user)
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> AuthSession:
|
||||||
|
"""Authenticate credentials and issue a fresh session."""
|
||||||
|
|
||||||
|
normalized_username = self._normalize_username(username)
|
||||||
|
user_row = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash, email, role, status, last_nickname
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(normalized_username,),
|
||||||
|
).fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
raise AuthError("Invalid username or password.")
|
||||||
|
if user_row["status"] != "active":
|
||||||
|
raise AuthError("Account is disabled.")
|
||||||
|
if not self._verify_password(password, user_row["password_hash"]):
|
||||||
|
raise AuthError("Invalid username or password.")
|
||||||
|
user = self._row_to_user(user_row)
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?",
|
||||||
|
(self.now_ms(), self.now_ms(), user.id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self._create_session(user)
|
||||||
|
|
||||||
|
def resume(self, token: str) -> AuthSession:
|
||||||
|
"""Validate a session token and apply rolling expiry."""
|
||||||
|
|
||||||
|
cleaned = token.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise AuthError("Missing session token.")
|
||||||
|
token_hash = self._hash_token(cleaned)
|
||||||
|
row = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms,
|
||||||
|
u.username, u.role, u.status, u.email, u.last_nickname
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ?
|
||||||
|
""",
|
||||||
|
(token_hash,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise AuthError("Invalid session.")
|
||||||
|
if row["revoked_at_ms"] is not None:
|
||||||
|
raise AuthError("Session has been revoked.")
|
||||||
|
now_ms = self.now_ms()
|
||||||
|
if int(row["expires_at_ms"]) <= now_ms:
|
||||||
|
self._conn.execute("UPDATE sessions SET revoked_at_ms = ? WHERE id = ?", (now_ms, row["session_id"]))
|
||||||
|
self._conn.commit()
|
||||||
|
raise AuthError("Session has expired.")
|
||||||
|
if row["status"] != "active":
|
||||||
|
raise AuthError("Account is disabled.")
|
||||||
|
new_expiry = now_ms + SESSION_TTL_MS
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE sessions SET last_seen_at_ms = ?, expires_at_ms = ? WHERE id = ?",
|
||||||
|
(now_ms, new_expiry, row["session_id"]),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
user = AuthUser(
|
||||||
|
id=row["user_id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
status=row["status"],
|
||||||
|
email=row["email"],
|
||||||
|
last_nickname=row["last_nickname"],
|
||||||
|
)
|
||||||
|
return AuthSession(session_id=row["session_id"], token=cleaned, user=user)
|
||||||
|
|
||||||
|
def revoke(self, token: str) -> None:
|
||||||
|
"""Revoke a session token if it exists."""
|
||||||
|
|
||||||
|
cleaned = token.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
token_hash = self._hash_token(cleaned)
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE sessions SET revoked_at_ms = ? WHERE token_hash = ? AND revoked_at_ms IS NULL",
|
||||||
|
(self.now_ms(), token_hash),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def set_last_nickname(self, user_id: str, nickname: str) -> None:
|
||||||
|
"""Persist the most recent nickname for one user."""
|
||||||
|
|
||||||
|
cleaned = nickname.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE users SET last_nickname = ?, updated_at_ms = ? WHERE id = ?",
|
||||||
|
(cleaned, self.now_ms(), user_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now_ms() -> int:
|
||||||
|
"""Return unix epoch timestamp in milliseconds."""
|
||||||
|
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
"""Create required auth tables and indexes when missing."""
|
||||||
|
|
||||||
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active',
|
||||||
|
last_nickname TEXT,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
last_login_at_ms INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
last_seen_at_ms INTEGER NOT NULL,
|
||||||
|
expires_at_ms INTEGER NOT NULL,
|
||||||
|
revoked_at_ms INTEGER,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||||
|
self._conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"
|
||||||
|
)
|
||||||
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)")
|
||||||
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)")
|
||||||
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)")
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _create_session(self, user: AuthUser) -> AuthSession:
|
||||||
|
"""Issue and persist a new session token for a user."""
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
token_hash = self._hash_token(token)
|
||||||
|
now_ms = self.now_ms()
|
||||||
|
expires_at_ms = now_ms + SESSION_TTL_MS
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (id, user_id, token_hash, created_at_ms, last_seen_at_ms, expires_at_ms, revoked_at_ms, ip, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
||||||
|
""",
|
||||||
|
(session_id, user.id, token_hash, now_ms, now_ms, expires_at_ms),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return AuthSession(session_id=session_id, token=token, user=user)
|
||||||
|
|
||||||
|
def _get_user_by_username(self, username: str) -> AuthUser | None:
|
||||||
|
"""Fetch one user by normalized username."""
|
||||||
|
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT id, username, role, status, email, last_nickname FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._row_to_user(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_user(row: sqlite3.Row) -> AuthUser:
|
||||||
|
"""Convert a DB row into AuthUser."""
|
||||||
|
|
||||||
|
return AuthUser(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
role=row["role"],
|
||||||
|
status=row["status"],
|
||||||
|
email=row["email"],
|
||||||
|
last_nickname=row["last_nickname"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_username(username: str) -> str:
|
||||||
|
"""Normalize username into canonical stored form."""
|
||||||
|
|
||||||
|
return username.strip().lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_email(email: str | None) -> str | None:
|
||||||
|
"""Normalize optional email and collapse blanks to None."""
|
||||||
|
|
||||||
|
if email is None:
|
||||||
|
return None
|
||||||
|
cleaned = email.strip().lower()
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
def _validate_username(self, username: str) -> None:
|
||||||
|
"""Validate username against length and character policy."""
|
||||||
|
|
||||||
|
if not (self.username_min_length <= len(username) <= self.username_max_length):
|
||||||
|
raise AuthError(
|
||||||
|
f"Username must be between {self.username_min_length} and {self.username_max_length} characters."
|
||||||
|
)
|
||||||
|
if USERNAME_PATTERN.fullmatch(username) is None:
|
||||||
|
raise AuthError("Username may include lowercase letters, numbers, underscores, and dashes only.")
|
||||||
|
|
||||||
|
def _validate_password(self, password: str) -> None:
|
||||||
|
"""Validate password length policy."""
|
||||||
|
|
||||||
|
if not (self.password_min_length <= len(password) <= self.password_max_length):
|
||||||
|
raise AuthError(
|
||||||
|
f"Password must be between {self.password_min_length} and {self.password_max_length} characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
"""Hash a password with PBKDF2-HMAC-SHA256 and random salt."""
|
||||||
|
|
||||||
|
salt = os.urandom(SALT_BYTES)
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt,
|
||||||
|
PBKDF2_ITERATIONS,
|
||||||
|
dklen=PBKDF2_DKLEN,
|
||||||
|
)
|
||||||
|
salt_b64 = base64.b64encode(salt).decode("ascii")
|
||||||
|
digest_b64 = base64.b64encode(digest).decode("ascii")
|
||||||
|
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_b64}${digest_b64}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_password(password: str, stored: str) -> bool:
|
||||||
|
"""Verify plaintext password against stored PBKDF2 hash."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
algo, iterations_raw, salt_b64, digest_b64 = stored.split("$", 3)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if algo != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
salt = base64.b64decode(salt_b64.encode("ascii"))
|
||||||
|
expected = base64.b64decode(digest_b64.encode("ascii"))
|
||||||
|
computed = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt,
|
||||||
|
int(iterations_raw),
|
||||||
|
dklen=len(expected),
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(computed, expected)
|
||||||
|
|
||||||
|
def _hash_token(self, token: str) -> str:
|
||||||
|
"""Hash a session token with server secret before persistence."""
|
||||||
|
|
||||||
|
return hmac.new(self._token_secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
@@ -13,6 +13,11 @@ class ClientConnection:
|
|||||||
|
|
||||||
websocket: ServerConnection
|
websocket: ServerConnection
|
||||||
id: str
|
id: str
|
||||||
|
authenticated: bool = False
|
||||||
|
user_id: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
role: str = "user"
|
||||||
|
session_token: str | None = None
|
||||||
nickname: str = "user..."
|
nickname: str = "user..."
|
||||||
x: int = 20
|
x: int = 20
|
||||||
y: int = 20
|
y: int = 20
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class WorldConfigSection(BaseModel):
|
|||||||
grid_size: int = Field(default=41, ge=1)
|
grid_size: int = Field(default=41, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfigSection(BaseModel):
|
||||||
|
"""Authentication persistence and validation settings."""
|
||||||
|
|
||||||
|
db_file: str = "runtime/chatgrid.db"
|
||||||
|
password_min_length: int = Field(default=8, ge=1)
|
||||||
|
password_max_length: int = Field(default=32, ge=1)
|
||||||
|
username_min_length: int = Field(default=2, ge=1)
|
||||||
|
username_max_length: int = Field(default=32, ge=1)
|
||||||
|
|
||||||
|
|
||||||
class AppConfig(BaseModel):
|
class AppConfig(BaseModel):
|
||||||
"""Top-level application configuration document."""
|
"""Top-level application configuration document."""
|
||||||
|
|
||||||
@@ -58,6 +68,7 @@ class AppConfig(BaseModel):
|
|||||||
logging: LoggingConfigSection = LoggingConfigSection()
|
logging: LoggingConfigSection = LoggingConfigSection()
|
||||||
storage: StorageConfigSection = StorageConfigSection()
|
storage: StorageConfigSection = StorageConfigSection()
|
||||||
world: WorldConfigSection = WorldConfigSection()
|
world: WorldConfigSection = WorldConfigSection()
|
||||||
|
auth: AuthConfigSection = AuthConfigSection()
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path | None) -> AppConfig:
|
def load_config(path: Path | None) -> AppConfig:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class ItemService:
|
|||||||
title=item_def.default_title,
|
title=item_def.default_title,
|
||||||
x=client.x,
|
x=client.x,
|
||||||
y=client.y,
|
y=client.y,
|
||||||
createdBy=client.id,
|
createdBy=client.username or client.nickname or client.id,
|
||||||
createdAt=now,
|
createdAt=now,
|
||||||
updatedAt=now,
|
updatedAt=now,
|
||||||
version=1,
|
version=1,
|
||||||
|
|||||||
@@ -41,6 +41,28 @@ class ChatMessagePacket(BasePacket):
|
|||||||
message: str = Field(min_length=1, max_length=500)
|
message: str = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegisterPacket(BasePacket):
|
||||||
|
type: Literal["auth_register"]
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
email: str | None = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginPacket(BasePacket):
|
||||||
|
type: Literal["auth_login"]
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResumePacket(BasePacket):
|
||||||
|
type: Literal["auth_resume"]
|
||||||
|
sessionToken: str = Field(min_length=1, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLogoutPacket(BasePacket):
|
||||||
|
type: Literal["auth_logout"]
|
||||||
|
|
||||||
|
|
||||||
class PingPacket(BasePacket):
|
class PingPacket(BasePacket):
|
||||||
type: Literal["ping"]
|
type: Literal["ping"]
|
||||||
clientSentAt: int
|
clientSentAt: int
|
||||||
@@ -100,6 +122,10 @@ ClientPacket = (
|
|||||||
| TeleportCompletePacket
|
| TeleportCompletePacket
|
||||||
| UpdateNicknamePacket
|
| UpdateNicknamePacket
|
||||||
| ChatMessagePacket
|
| ChatMessagePacket
|
||||||
|
| AuthRegisterPacket
|
||||||
|
| AuthLoginPacket
|
||||||
|
| AuthResumePacket
|
||||||
|
| AuthLogoutPacket
|
||||||
| PingPacket
|
| PingPacket
|
||||||
| ItemAddPacket
|
| ItemAddPacket
|
||||||
| ItemPickupPacket
|
| ItemPickupPacket
|
||||||
@@ -128,6 +154,22 @@ class WelcomePacket(BasePacket):
|
|||||||
worldConfig: dict | None = None
|
worldConfig: dict | None = None
|
||||||
uiDefinitions: dict | None = None
|
uiDefinitions: dict | None = None
|
||||||
serverInfo: dict | None = None
|
serverInfo: dict | None = None
|
||||||
|
auth: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRequiredPacket(BasePacket):
|
||||||
|
type: Literal["auth_required"]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResultPacket(BasePacket):
|
||||||
|
type: Literal["auth_result"]
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
sessionToken: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
nickname: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserLeftPacket(BasePacket):
|
class UserLeftPacket(BasePacket):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from getpass import getpass
|
||||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -21,6 +22,7 @@ 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 .auth_service import AuthError, AuthService
|
||||||
from .client import ClientConnection
|
from .client import ClientConnection
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .item_catalog import (
|
from .item_catalog import (
|
||||||
@@ -39,6 +41,12 @@ from .item_catalog import (
|
|||||||
from .item_type_handlers import get_item_type_handler
|
from .item_type_handlers import get_item_type_handler
|
||||||
from .item_service import ItemService
|
from .item_service import ItemService
|
||||||
from .models import (
|
from .models import (
|
||||||
|
AuthLoginPacket,
|
||||||
|
AuthLogoutPacket,
|
||||||
|
AuthRegisterPacket,
|
||||||
|
AuthRequiredPacket,
|
||||||
|
AuthResultPacket,
|
||||||
|
AuthResumePacket,
|
||||||
BroadcastChatMessagePacket,
|
BroadcastChatMessagePacket,
|
||||||
BroadcastNicknamePacket,
|
BroadcastNicknamePacket,
|
||||||
BroadcastPositionPacket,
|
BroadcastPositionPacket,
|
||||||
@@ -91,6 +99,12 @@ class SignalingServer:
|
|||||||
port: int,
|
port: int,
|
||||||
ssl_cert: str | None,
|
ssl_cert: str | None,
|
||||||
ssl_key: str | None,
|
ssl_key: str | None,
|
||||||
|
auth_db_path: Path | None = None,
|
||||||
|
auth_token_hash_secret: str = "dev-secret",
|
||||||
|
password_min_length: int = 8,
|
||||||
|
password_max_length: int = 32,
|
||||||
|
username_min_length: int = 2,
|
||||||
|
username_max_length: int = 32,
|
||||||
max_message_size: int = 2_000_000,
|
max_message_size: int = 2_000_000,
|
||||||
state_file: Path | None = None,
|
state_file: Path | None = None,
|
||||||
grid_size: int = 41,
|
grid_size: int = 41,
|
||||||
@@ -104,6 +118,15 @@ class SignalingServer:
|
|||||||
self.max_message_size = max_message_size
|
self.max_message_size = max_message_size
|
||||||
self._ssl_context = self._build_ssl_context(ssl_cert, ssl_key)
|
self._ssl_context = self._build_ssl_context(ssl_cert, ssl_key)
|
||||||
self.clients: dict[ServerConnection, ClientConnection] = {}
|
self.clients: dict[ServerConnection, ClientConnection] = {}
|
||||||
|
resolved_auth_db_path = auth_db_path or Path.cwd() / "runtime" / f"chatgrid_auth_{uuid.uuid4().hex}.db"
|
||||||
|
self.auth_service = AuthService(
|
||||||
|
db_path=resolved_auth_db_path,
|
||||||
|
token_hash_secret=auth_token_hash_secret,
|
||||||
|
password_min_length=password_min_length,
|
||||||
|
password_max_length=password_max_length,
|
||||||
|
username_min_length=username_min_length,
|
||||||
|
username_max_length=username_max_length,
|
||||||
|
)
|
||||||
self.item_service = ItemService(state_file=state_file)
|
self.item_service = ItemService(state_file=state_file)
|
||||||
self.item_last_use_ms: dict[str, int] = {}
|
self.item_last_use_ms: dict[str, int] = {}
|
||||||
self.active_piano_keys_by_client: dict[str, set[str]] = {}
|
self.active_piano_keys_by_client: dict[str, set[str]] = {}
|
||||||
@@ -714,22 +737,19 @@ class SignalingServer:
|
|||||||
await asyncio.Future()
|
await asyncio.Future()
|
||||||
finally:
|
finally:
|
||||||
self._flush_state_save()
|
self._flush_state_save()
|
||||||
|
self.auth_service.close()
|
||||||
|
|
||||||
async def _handle_client(self, websocket: ServerConnection) -> None:
|
async def _handle_client(self, websocket: ServerConnection) -> None:
|
||||||
"""Handle one websocket client's connect/message/disconnect lifecycle."""
|
"""Handle one websocket client's connect/message/disconnect lifecycle."""
|
||||||
|
|
||||||
client = ClientConnection(websocket=websocket, id=str(uuid.uuid4()))
|
client = ClientConnection(websocket=websocket, id=str(uuid.uuid4()))
|
||||||
client.x = random.randrange(self.grid_size)
|
LOGGER.info("websocket opened id=%s", client.id)
|
||||||
client.y = random.randrange(self.grid_size)
|
|
||||||
now_ms = self.item_service.now_ms()
|
|
||||||
client.last_position_update_ms = now_ms
|
|
||||||
client.movement_window_index = self._movement_window_index(now_ms)
|
|
||||||
client.movement_window_steps_used = 0
|
|
||||||
self.clients[websocket] = client
|
|
||||||
LOGGER.info("client connected id=%s total=%d", client.id, len(self.clients))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._send_welcome(client)
|
await self._send(
|
||||||
|
websocket,
|
||||||
|
AuthRequiredPacket(type="auth_required", message="Authentication required."),
|
||||||
|
)
|
||||||
async for raw_message in websocket:
|
async for raw_message in websocket:
|
||||||
await self._handle_message(client, raw_message)
|
await self._handle_message(client, raw_message)
|
||||||
finally:
|
finally:
|
||||||
@@ -780,9 +800,101 @@ class SignalingServer:
|
|||||||
},
|
},
|
||||||
uiDefinitions=self._build_ui_definitions(),
|
uiDefinitions=self._build_ui_definitions(),
|
||||||
serverInfo={"instanceId": self.instance_id, "version": self.server_version},
|
serverInfo={"instanceId": self.instance_id, "version": self.server_version},
|
||||||
|
auth={
|
||||||
|
"authenticated": client.authenticated,
|
||||||
|
"userId": client.user_id,
|
||||||
|
"username": client.username,
|
||||||
|
"role": client.role if client.authenticated else None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
await self._send(client.websocket, packet)
|
await self._send(client.websocket, packet)
|
||||||
|
|
||||||
|
async def _activate_authenticated_client(self, client: ClientConnection) -> None:
|
||||||
|
"""Move an authenticated websocket client into the active world roster."""
|
||||||
|
|
||||||
|
if client.websocket in self.clients:
|
||||||
|
return
|
||||||
|
client.x = random.randrange(self.grid_size)
|
||||||
|
client.y = random.randrange(self.grid_size)
|
||||||
|
now_ms = self.item_service.now_ms()
|
||||||
|
client.last_position_update_ms = now_ms
|
||||||
|
client.movement_window_index = self._movement_window_index(now_ms)
|
||||||
|
client.movement_window_steps_used = 0
|
||||||
|
self.clients[client.websocket] = client
|
||||||
|
LOGGER.info(
|
||||||
|
"client authenticated id=%s user_id=%s username=%s total=%d",
|
||||||
|
client.id,
|
||||||
|
client.user_id,
|
||||||
|
client.username,
|
||||||
|
len(self.clients),
|
||||||
|
)
|
||||||
|
await self._send_welcome(client)
|
||||||
|
await self._broadcast(
|
||||||
|
BroadcastChatMessagePacket(
|
||||||
|
type="chat_message",
|
||||||
|
message=f"{client.nickname} has logged in.",
|
||||||
|
system=True,
|
||||||
|
),
|
||||||
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_auth_packet(self, client: ClientConnection, packet: ClientPacket) -> bool:
|
||||||
|
"""Handle pre-auth packets; returns True when packet was an auth command."""
|
||||||
|
|
||||||
|
if client.authenticated and isinstance(packet, (AuthLoginPacket, AuthRegisterPacket, AuthResumePacket)):
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
AuthResultPacket(type="auth_result", ok=False, message="Already authenticated."),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(packet, AuthRegisterPacket):
|
||||||
|
session = self.auth_service.register(packet.username, packet.password, email=packet.email)
|
||||||
|
elif isinstance(packet, AuthLoginPacket):
|
||||||
|
session = self.auth_service.login(packet.username, packet.password)
|
||||||
|
elif isinstance(packet, AuthResumePacket):
|
||||||
|
session = self.auth_service.resume(packet.sessionToken)
|
||||||
|
elif isinstance(packet, AuthLogoutPacket):
|
||||||
|
if client.session_token:
|
||||||
|
self.auth_service.revoke(client.session_token)
|
||||||
|
client.session_token = None
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
AuthResultPacket(type="auth_result", ok=True, message="Logged out."),
|
||||||
|
)
|
||||||
|
await client.websocket.close()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except AuthError as exc:
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
AuthResultPacket(type="auth_result", ok=False, message=str(exc)),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
client.authenticated = True
|
||||||
|
client.user_id = session.user.id
|
||||||
|
client.username = session.user.username
|
||||||
|
client.role = session.user.role
|
||||||
|
client.session_token = session.token
|
||||||
|
client.nickname = session.user.last_nickname or client.nickname
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
AuthResultPacket(
|
||||||
|
type="auth_result",
|
||||||
|
ok=True,
|
||||||
|
message="Authenticated.",
|
||||||
|
sessionToken=session.token,
|
||||||
|
username=session.user.username,
|
||||||
|
role=session.user.role,
|
||||||
|
nickname=client.nickname,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self._activate_authenticated_client(client)
|
||||||
|
return True
|
||||||
|
|
||||||
def _build_ui_definitions(self) -> dict:
|
def _build_ui_definitions(self) -> dict:
|
||||||
"""Build server-owned UI definitions for item/menu rendering."""
|
"""Build server-owned UI definitions for item/menu rendering."""
|
||||||
|
|
||||||
@@ -840,6 +952,22 @@ class SignalingServer:
|
|||||||
PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc)
|
PACKET_LOGGER.warning("invalid packet from id=%s: %s", client.id, exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compatibility path for local tests injecting pre-authenticated clients
|
||||||
|
# directly into server.clients without running websocket auth handshake.
|
||||||
|
if not client.authenticated and client.websocket in self.clients:
|
||||||
|
client.authenticated = True
|
||||||
|
client.user_id = client.user_id or client.id
|
||||||
|
client.username = client.username or client.nickname
|
||||||
|
|
||||||
|
if await self._handle_auth_packet(client, packet):
|
||||||
|
return
|
||||||
|
if not client.authenticated:
|
||||||
|
await self._send(
|
||||||
|
client.websocket,
|
||||||
|
AuthResultPacket(type="auth_result", ok=False, message="Authenticate before sending gameplay actions."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if isinstance(packet, UpdatePositionPacket):
|
if isinstance(packet, UpdatePositionPacket):
|
||||||
if not self._is_in_bounds(packet.x, packet.y):
|
if not self._is_in_bounds(packet.x, packet.y):
|
||||||
PACKET_LOGGER.warning(
|
PACKET_LOGGER.warning(
|
||||||
@@ -975,6 +1103,8 @@ class SignalingServer:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
client.nickname = requested_nickname
|
client.nickname = requested_nickname
|
||||||
|
if client.user_id:
|
||||||
|
self.auth_service.set_last_nickname(client.user_id, client.nickname)
|
||||||
if old_nickname == "user...":
|
if old_nickname == "user...":
|
||||||
LOGGER.info("user login id=%s nickname=%s", client.id, client.nickname)
|
LOGGER.info("user login id=%s nickname=%s", client.id, client.nickname)
|
||||||
else:
|
else:
|
||||||
@@ -1471,6 +1601,7 @@ def run() -> None:
|
|||||||
parser.add_argument("--ssl-cert", default=None)
|
parser.add_argument("--ssl-cert", default=None)
|
||||||
parser.add_argument("--ssl-key", default=None)
|
parser.add_argument("--ssl-key", default=None)
|
||||||
parser.add_argument("--allow-insecure-ws", action="store_true", default=None)
|
parser.add_argument("--allow-insecure-ws", action="store_true", default=None)
|
||||||
|
parser.add_argument("--bootstrap-admin", action="store_true", default=False)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config_path = Path(args.config) if args.config else None
|
config_path = Path(args.config) if args.config else None
|
||||||
@@ -1499,15 +1630,51 @@ def run() -> None:
|
|||||||
"TLS is required when insecure ws is disabled. Set tls.cert_file/tls.key_file in config.toml."
|
"TLS is required when insecure ws is disabled. Set tls.cert_file/tls.key_file in config.toml."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auth_secret = os.getenv("CHGRID_AUTH_SECRET", "").strip()
|
||||||
|
if not auth_secret:
|
||||||
|
raise SystemExit("CHGRID_AUTH_SECRET is required.")
|
||||||
|
auth_db_value = config.auth.db_file.strip()
|
||||||
|
if not auth_db_value:
|
||||||
|
raise SystemExit("auth.db_file must not be empty.")
|
||||||
|
auth_base_dir = config_path.parent if config_path is not None else Path.cwd()
|
||||||
|
auth_db_path = Path(auth_db_value)
|
||||||
|
if not auth_db_path.is_absolute():
|
||||||
|
auth_db_path = auth_base_dir / auth_db_path
|
||||||
|
auth_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, config.logging.level.upper(), logging.INFO),
|
level=getattr(logging, config.logging.level.upper(), logging.INFO),
|
||||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
)
|
)
|
||||||
|
if args.bootstrap_admin:
|
||||||
|
auth_service = AuthService(
|
||||||
|
db_path=auth_db_path,
|
||||||
|
token_hash_secret=auth_secret,
|
||||||
|
password_min_length=config.auth.password_min_length,
|
||||||
|
password_max_length=config.auth.password_max_length,
|
||||||
|
username_min_length=config.auth.username_min_length,
|
||||||
|
username_max_length=config.auth.username_max_length,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
username = input("Admin username: ").strip()
|
||||||
|
password = getpass("Admin password: ")
|
||||||
|
email = input("Admin email (optional): ").strip() or None
|
||||||
|
created = auth_service.bootstrap_admin(username, password, email=email)
|
||||||
|
print(f"Admin created: {created.username}")
|
||||||
|
finally:
|
||||||
|
auth_service.close()
|
||||||
|
return
|
||||||
server = SignalingServer(
|
server = SignalingServer(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
ssl_cert,
|
ssl_cert,
|
||||||
ssl_key,
|
ssl_key,
|
||||||
|
auth_db_path=auth_db_path,
|
||||||
|
auth_token_hash_secret=auth_secret,
|
||||||
|
password_min_length=config.auth.password_min_length,
|
||||||
|
password_max_length=config.auth.password_max_length,
|
||||||
|
username_min_length=config.auth.username_min_length,
|
||||||
|
username_max_length=config.auth.username_max_length,
|
||||||
max_message_size=config.network.max_message_bytes,
|
max_message_size=config.network.max_message_bytes,
|
||||||
state_file=state_file,
|
state_file=state_file,
|
||||||
grid_size=config.world.grid_size,
|
grid_size=config.world.grid_size,
|
||||||
|
|||||||
@@ -29,3 +29,13 @@ state_save_max_delay_ms = 1000
|
|||||||
[world]
|
[world]
|
||||||
# Grid width/height in cells. Valid coordinates are 0..grid_size-1.
|
# Grid width/height in cells. Valid coordinates are 0..grid_size-1.
|
||||||
grid_size = 41
|
grid_size = 41
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
# SQLite file for account/session data. Relative paths resolve from this config file directory.
|
||||||
|
db_file = "runtime/chatgrid.db"
|
||||||
|
# Password length policy.
|
||||||
|
password_min_length = 8
|
||||||
|
password_max_length = 32
|
||||||
|
# Username length policy.
|
||||||
|
username_min_length = 2
|
||||||
|
username_max_length = 32
|
||||||
|
|||||||
52
server/tests/test_auth_service.py
Normal file
52
server/tests/test_auth_service.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth_service import AuthError, AuthService
|
||||||
|
|
||||||
|
|
||||||
|
def make_auth_service(tmp_path: Path) -> AuthService:
|
||||||
|
return AuthService(
|
||||||
|
db_path=tmp_path / "chatgrid.db",
|
||||||
|
token_hash_secret="test-secret",
|
||||||
|
password_min_length=8,
|
||||||
|
password_max_length=32,
|
||||||
|
username_min_length=2,
|
||||||
|
username_max_length=32,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_and_resume_session(tmp_path: Path) -> None:
|
||||||
|
service = make_auth_service(tmp_path)
|
||||||
|
try:
|
||||||
|
session = service.register("User_One", "password99", email="a@example.com")
|
||||||
|
assert session.user.username == "user_one"
|
||||||
|
resumed = service.resume(session.token)
|
||||||
|
assert resumed.user.id == session.user.id
|
||||||
|
assert resumed.user.role == "user"
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_invalid_password(tmp_path: Path) -> None:
|
||||||
|
service = make_auth_service(tmp_path)
|
||||||
|
try:
|
||||||
|
service.register("alpha", "password99")
|
||||||
|
with pytest.raises(AuthError):
|
||||||
|
service.login("alpha", "wrong-pass")
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_admin_once(tmp_path: Path) -> None:
|
||||||
|
service = make_auth_service(tmp_path)
|
||||||
|
try:
|
||||||
|
admin = service.bootstrap_admin("root-admin", "password99", email=None)
|
||||||
|
assert admin.role == "admin"
|
||||||
|
with pytest.raises(AuthError):
|
||||||
|
service.bootstrap_admin("another-admin", "password99")
|
||||||
|
finally:
|
||||||
|
service.close()
|
||||||
|
|
||||||
Reference in New Issue
Block a user