Show server auth policy limits in client auth UI
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
<label for="authPassword">Password</label>
|
||||
<input id="authPassword" type="password" maxlength="64" autocomplete="current-password" />
|
||||
</div>
|
||||
<p id="authPolicyHintLogin" class="auth-hint"></p>
|
||||
<button id="showRegisterButton" type="button">Create account</button>
|
||||
</section>
|
||||
<section id="registerView" class="auth-panel hidden">
|
||||
@@ -35,6 +36,7 @@
|
||||
<label for="registerEmail">Email (optional)</label>
|
||||
<input id="registerEmail" type="email" maxlength="320" autocomplete="email" />
|
||||
</div>
|
||||
<p id="authPolicyHintRegister" class="auth-hint"></p>
|
||||
<button id="showLoginButton" type="button">Back to login</button>
|
||||
</section>
|
||||
<div class="controls" id="button-container">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||
window.CHGRID_WEB_VERSION = "2026.02.25 R244";
|
||||
window.CHGRID_WEB_VERSION = "2026.02.25 R245";
|
||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
@@ -79,6 +79,7 @@ const RECONNECT_MAX_ATTEMPTS = 3;
|
||||
const AUDIO_SUBSCRIPTION_REFRESH_MS = 500;
|
||||
const TELEPORT_SQUARES_PER_SECOND = 20;
|
||||
const TELEPORT_SYNC_INTERVAL_MS = 100;
|
||||
const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -94,9 +95,11 @@ type Dom = {
|
||||
registerView: HTMLElement;
|
||||
authUsername: HTMLInputElement;
|
||||
authPassword: HTMLInputElement;
|
||||
authPolicyHintLogin: HTMLParagraphElement;
|
||||
registerUsername: HTMLInputElement;
|
||||
registerPassword: HTMLInputElement;
|
||||
registerEmail: HTMLInputElement;
|
||||
authPolicyHintRegister: HTMLParagraphElement;
|
||||
showRegisterButton: HTMLButtonElement;
|
||||
showLoginButton: HTMLButtonElement;
|
||||
updatesSection: HTMLElement;
|
||||
@@ -125,9 +128,11 @@ const dom: Dom = {
|
||||
registerView: requiredById('registerView'),
|
||||
authUsername: requiredById('authUsername'),
|
||||
authPassword: requiredById('authPassword'),
|
||||
authPolicyHintLogin: requiredById('authPolicyHintLogin'),
|
||||
registerUsername: requiredById('registerUsername'),
|
||||
registerPassword: requiredById('registerPassword'),
|
||||
registerEmail: requiredById('registerEmail'),
|
||||
authPolicyHintRegister: requiredById('authPolicyHintRegister'),
|
||||
showRegisterButton: requiredById('showRegisterButton'),
|
||||
showLoginButton: requiredById('showLoginButton'),
|
||||
updatesSection: requiredById('updatesSection'),
|
||||
@@ -172,6 +177,13 @@ type HelpData = {
|
||||
sections: HelpSection[];
|
||||
};
|
||||
|
||||
type AuthPolicy = {
|
||||
usernameMinLength: number;
|
||||
usernameMaxLength: number;
|
||||
passwordMinLength: number;
|
||||
passwordMaxLength: number;
|
||||
};
|
||||
|
||||
/** Builds linearized help-view lines from sectioned help content. */
|
||||
function buildHelpLines(help: HelpData): string[] {
|
||||
const lines: string[] = [];
|
||||
@@ -222,6 +234,7 @@ let outputMode = settings.loadOutputMode();
|
||||
let authMode: 'login' | 'register' = 'login';
|
||||
let authSessionToken = settings.loadAuthSessionToken();
|
||||
let authUsername = settings.loadAuthUsername();
|
||||
let authPolicy: AuthPolicy | null = null;
|
||||
let pendingAuthRequest = false;
|
||||
const messageBuffer: string[] = [];
|
||||
let messageCursor = -1;
|
||||
@@ -504,11 +517,63 @@ function sanitizeName(value: string): string {
|
||||
|
||||
/** Normalizes auth username according to server policy. */
|
||||
function sanitizeAuthUsername(value: string): string {
|
||||
const maxLength = authPolicy?.usernameMaxLength ?? 128;
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '')
|
||||
.slice(0, 32);
|
||||
.slice(0, Math.max(1, maxLength));
|
||||
}
|
||||
|
||||
/** Normalizes and stores server-advertised auth policy limits, then refreshes auth UI hints. */
|
||||
function applyAuthPolicy(policy: unknown): void {
|
||||
if (!policy || typeof policy !== 'object') return;
|
||||
const raw = policy as Partial<AuthPolicy>;
|
||||
const usernameMin = Number(raw.usernameMinLength);
|
||||
const usernameMax = Number(raw.usernameMaxLength);
|
||||
const passwordMin = Number(raw.passwordMinLength);
|
||||
const passwordMax = Number(raw.passwordMaxLength);
|
||||
if (
|
||||
!Number.isInteger(usernameMin) ||
|
||||
!Number.isInteger(usernameMax) ||
|
||||
!Number.isInteger(passwordMin) ||
|
||||
!Number.isInteger(passwordMax)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (usernameMin < 1 || usernameMax < usernameMin || passwordMin < 1 || passwordMax < passwordMin) {
|
||||
return;
|
||||
}
|
||||
authPolicy = {
|
||||
usernameMinLength: usernameMin,
|
||||
usernameMaxLength: usernameMax,
|
||||
passwordMinLength: passwordMin,
|
||||
passwordMaxLength: passwordMax,
|
||||
};
|
||||
localStorage.setItem(AUTH_POLICY_STORAGE_KEY, JSON.stringify(authPolicy));
|
||||
const hint = `Username: ${usernameMin}-${usernameMax} chars (a-z, 0-9, _, -). Password: ${passwordMin}-${passwordMax} chars.`;
|
||||
dom.authPolicyHintLogin.textContent = hint;
|
||||
dom.authPolicyHintRegister.textContent = hint;
|
||||
dom.authUsername.minLength = usernameMin;
|
||||
dom.authUsername.maxLength = usernameMax;
|
||||
dom.registerUsername.minLength = usernameMin;
|
||||
dom.registerUsername.maxLength = usernameMax;
|
||||
dom.authPassword.minLength = passwordMin;
|
||||
dom.authPassword.maxLength = passwordMax;
|
||||
dom.registerPassword.minLength = passwordMin;
|
||||
dom.registerPassword.maxLength = passwordMax;
|
||||
updateConnectAvailability();
|
||||
}
|
||||
|
||||
/** Loads most recently-seen auth policy limits from local storage for pre-connect UI hints. */
|
||||
function loadPersistedAuthPolicy(): void {
|
||||
const raw = localStorage.getItem(AUTH_POLICY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
applyAuthPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
// Ignore malformed persisted policy and keep live server policy source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
/** Enables/disables the connect button based on state and nickname validity. */
|
||||
@@ -524,10 +589,13 @@ function updateConnectAvailability(): void {
|
||||
dom.loginView.classList.toggle('hidden', authMode !== 'login');
|
||||
dom.registerView.classList.toggle('hidden', authMode !== 'register');
|
||||
const hasSessionToken = authSessionToken.trim().length > 0;
|
||||
const usernameMin = authPolicy?.usernameMinLength ?? 1;
|
||||
const passwordMin = authPolicy?.passwordMinLength ?? 1;
|
||||
const hasLoginCredentials =
|
||||
sanitizeAuthUsername(dom.authUsername.value).length >= 2 && dom.authPassword.value.trim().length >= 8;
|
||||
sanitizeAuthUsername(dom.authUsername.value).length >= usernameMin && dom.authPassword.value.trim().length >= passwordMin;
|
||||
const hasRegisterCredentials =
|
||||
sanitizeAuthUsername(dom.registerUsername.value).length >= 2 && dom.registerPassword.value.trim().length >= 8;
|
||||
sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin &&
|
||||
dom.registerPassword.value.trim().length >= passwordMin;
|
||||
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials);
|
||||
dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect';
|
||||
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
|
||||
@@ -1379,14 +1447,16 @@ function sendAuthRequest(): void {
|
||||
}
|
||||
|
||||
/** Handles server auth-required prompts prior to world welcome. */
|
||||
function handleAuthRequired(message: string): void {
|
||||
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
||||
applyAuthPolicy(message.authPolicy);
|
||||
setConnectionStatus('Authentication required.');
|
||||
updateStatus(message);
|
||||
updateStatus(message.message);
|
||||
}
|
||||
|
||||
/** Applies auth result state and terminates failed auth attempts quickly. */
|
||||
async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> {
|
||||
pendingAuthRequest = false;
|
||||
applyAuthPolicy(message.authPolicy);
|
||||
if (!message.ok) {
|
||||
dom.authPassword.value = '';
|
||||
dom.registerPassword.value = '';
|
||||
@@ -1581,6 +1651,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
||||
let restartAnnouncement: string | null = null;
|
||||
let connectedAnnouncement: string | null = null;
|
||||
if (message.type === 'welcome') {
|
||||
applyAuthPolicy(message.auth?.policy);
|
||||
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null;
|
||||
const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown';
|
||||
connectedAnnouncement = reconnectInFlight
|
||||
@@ -2608,6 +2679,7 @@ setupInputHandlers();
|
||||
setupUiHandlers();
|
||||
dom.authUsername.value = sanitizeAuthUsername(authUsername);
|
||||
dom.registerUsername.value = sanitizeAuthUsername(authUsername);
|
||||
loadPersistedAuthPolicy();
|
||||
setAuthMode('login');
|
||||
updateConnectAvailability();
|
||||
updateDeviceSummary();
|
||||
|
||||
@@ -70,7 +70,7 @@ type MessageHandlerDeps = {
|
||||
playLocateToneAt: (x: number, y: number) => void;
|
||||
resolveIncomingSoundUrl: (url: string) => string;
|
||||
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
||||
handleAuthRequired: (message: string) => void;
|
||||
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
|
||||
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
||||
return async function onMessage(message: IncomingMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'auth_required':
|
||||
deps.handleAuthRequired(message.message);
|
||||
deps.handleAuthRequired(message);
|
||||
break;
|
||||
|
||||
case 'auth_result':
|
||||
|
||||
@@ -54,6 +54,14 @@ export const welcomeMessageSchema = z.object({
|
||||
userId: z.string().nullable().optional(),
|
||||
username: z.string().nullable().optional(),
|
||||
role: z.string().nullable().optional(),
|
||||
policy: z
|
||||
.object({
|
||||
usernameMinLength: z.number().int().positive(),
|
||||
usernameMaxLength: z.number().int().positive(),
|
||||
passwordMinLength: z.number().int().positive(),
|
||||
passwordMaxLength: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
uiDefinitions: z
|
||||
@@ -96,6 +104,14 @@ export const welcomeMessageSchema = z.object({
|
||||
export const authRequiredSchema = z.object({
|
||||
type: z.literal('auth_required'),
|
||||
message: z.string(),
|
||||
authPolicy: z
|
||||
.object({
|
||||
usernameMinLength: z.number().int().positive(),
|
||||
usernameMaxLength: z.number().int().positive(),
|
||||
passwordMinLength: z.number().int().positive(),
|
||||
passwordMaxLength: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const authResultSchema = z.object({
|
||||
@@ -106,6 +122,14 @@ export const authResultSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
authPolicy: z
|
||||
.object({
|
||||
usernameMinLength: z.number().int().positive(),
|
||||
usernameMaxLength: z.number().int().positive(),
|
||||
passwordMinLength: z.number().int().positive(),
|
||||
passwordMaxLength: z.number().int().positive(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const signalMessageSchema = z.object({
|
||||
|
||||
@@ -117,6 +117,12 @@ body {
|
||||
width: min(280px, 60vw);
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
color: #94a3b8;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.nickname-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -62,6 +62,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
||||
- `userId`
|
||||
- `username`
|
||||
- `role`
|
||||
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
||||
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
||||
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
||||
- `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.movementMaxStepsPerTick`: max allowed grid steps per movement window.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
2. Client validates auth form/session token and sets up local media.
|
||||
3. Client connects signaling websocket.
|
||||
4. Server sends `auth_required`.
|
||||
- includes `authPolicy` limits for username/password.
|
||||
5. Client sends `auth_login`, `auth_register`, or `auth_resume`.
|
||||
6. Server sends `auth_result`.
|
||||
7. Server sends `welcome` with users/items snapshot.
|
||||
@@ -42,7 +43,7 @@ Core incoming message effects:
|
||||
|
||||
- `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.
|
||||
- `auth_result`: auth success/failure with optional session token + account metadata + `authPolicy`.
|
||||
- `update_position`: update peer position; may play movement/teleport world sound.
|
||||
- `teleport_complete`: play peer teleport landing sound at final tile.
|
||||
- `update_nickname`: update peer display name.
|
||||
|
||||
@@ -160,6 +160,7 @@ class WelcomePacket(BasePacket):
|
||||
class AuthRequiredPacket(BasePacket):
|
||||
type: Literal["auth_required"]
|
||||
message: str
|
||||
authPolicy: dict | None = None
|
||||
|
||||
|
||||
class AuthResultPacket(BasePacket):
|
||||
@@ -170,6 +171,7 @@ class AuthResultPacket(BasePacket):
|
||||
username: str | None = None
|
||||
role: str | None = None
|
||||
nickname: str | None = None
|
||||
authPolicy: dict | None = None
|
||||
|
||||
|
||||
class UserLeftPacket(BasePacket):
|
||||
|
||||
@@ -177,6 +177,16 @@ class SignalingServer:
|
||||
|
||||
return nickname.casefold()
|
||||
|
||||
def _auth_policy(self) -> dict[str, int]:
|
||||
"""Return server-auth policy limits advertised to clients."""
|
||||
|
||||
return {
|
||||
"usernameMinLength": self.auth_service.username_min_length,
|
||||
"usernameMaxLength": self.auth_service.username_max_length,
|
||||
"passwordMinLength": self.auth_service.password_min_length,
|
||||
"passwordMaxLength": self.auth_service.password_max_length,
|
||||
}
|
||||
|
||||
def _flush_state_save(self) -> None:
|
||||
"""Immediately flush pending state persistence and clear debounce state."""
|
||||
|
||||
@@ -748,7 +758,11 @@ class SignalingServer:
|
||||
try:
|
||||
await self._send(
|
||||
websocket,
|
||||
AuthRequiredPacket(type="auth_required", message="Authentication required."),
|
||||
AuthRequiredPacket(
|
||||
type="auth_required",
|
||||
message="Authentication required.",
|
||||
authPolicy=self._auth_policy(),
|
||||
),
|
||||
)
|
||||
async for raw_message in websocket:
|
||||
await self._handle_message(client, raw_message)
|
||||
@@ -805,6 +819,7 @@ class SignalingServer:
|
||||
"userId": client.user_id,
|
||||
"username": client.username,
|
||||
"role": client.role if client.authenticated else None,
|
||||
"policy": self._auth_policy(),
|
||||
},
|
||||
)
|
||||
await self._send(client.websocket, packet)
|
||||
@@ -844,7 +859,12 @@ class SignalingServer:
|
||||
if client.authenticated and isinstance(packet, (AuthLoginPacket, AuthRegisterPacket, AuthResumePacket)):
|
||||
await self._send(
|
||||
client.websocket,
|
||||
AuthResultPacket(type="auth_result", ok=False, message="Already authenticated."),
|
||||
AuthResultPacket(
|
||||
type="auth_result",
|
||||
ok=False,
|
||||
message="Already authenticated.",
|
||||
authPolicy=self._auth_policy(),
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -861,7 +881,12 @@ class SignalingServer:
|
||||
client.session_token = None
|
||||
await self._send(
|
||||
client.websocket,
|
||||
AuthResultPacket(type="auth_result", ok=True, message="Logged out."),
|
||||
AuthResultPacket(
|
||||
type="auth_result",
|
||||
ok=True,
|
||||
message="Logged out.",
|
||||
authPolicy=self._auth_policy(),
|
||||
),
|
||||
)
|
||||
await client.websocket.close()
|
||||
return True
|
||||
@@ -870,7 +895,12 @@ class SignalingServer:
|
||||
except AuthError as exc:
|
||||
await self._send(
|
||||
client.websocket,
|
||||
AuthResultPacket(type="auth_result", ok=False, message=str(exc)),
|
||||
AuthResultPacket(
|
||||
type="auth_result",
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
authPolicy=self._auth_policy(),
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -890,6 +920,7 @@ class SignalingServer:
|
||||
username=session.user.username,
|
||||
role=session.user.role,
|
||||
nickname=client.nickname,
|
||||
authPolicy=self._auth_policy(),
|
||||
),
|
||||
)
|
||||
await self._activate_authenticated_client(client)
|
||||
|
||||
Reference in New Issue
Block a user