Skip to content

Commit f25e711

Browse files
feat: add token-based WebSocket authentication (#25)
1 parent 91ef0d9 commit f25e711

File tree

5 files changed

+229
-30
lines changed

5 files changed

+229
-30
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ count.txt
1111
.output
1212
.vinxi
1313
todos.json
14+
tokens.json
1415
SPEC.md

src/hooks/useRemoteConnection.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,22 @@ export const useRemoteConnection = () => {
88
let isMounted = true;
99
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1010
const host = window.location.host;
11-
const wsUrl = `${protocol}//${host}/ws`;
11+
12+
// Get token from URL params (passed via QR code) or localStorage
13+
const urlParams = new URLSearchParams(window.location.search);
14+
const urlToken = urlParams.get('token');
15+
const storedToken = localStorage.getItem('rein_auth_token');
16+
const token = urlToken || storedToken;
17+
18+
// Persist URL token to localStorage for future reconnections
19+
if (urlToken && urlToken !== storedToken) {
20+
localStorage.setItem('rein_auth_token', urlToken);
21+
}
22+
23+
let wsUrl = `${protocol}//${host}/ws`;
24+
if (token) {
25+
wsUrl += `?token=${encodeURIComponent(token)}`;
26+
}
1227

1328
let reconnectTimer: NodeJS.Timeout;
1429

@@ -24,7 +39,6 @@ export const useRemoteConnection = () => {
2439
wsRef.current = null;
2540
}
2641

27-
console.log(`Connecting to ${wsUrl}`);
2842
setStatus('connecting');
2943
const socket = new WebSocket(wsUrl);
3044

@@ -37,8 +51,7 @@ export const useRemoteConnection = () => {
3751
reconnectTimer = setTimeout(connect, 3000);
3852
}
3953
};
40-
socket.onerror = (e) => {
41-
console.error("WS Error", e);
54+
socket.onerror = () => {
4255
socket.close();
4356
};
4457

src/routes/settings.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,51 @@ function SettingsPage() {
4242
const [qrData, setQrData] = useState('');
4343

4444
// Load initial state (IP is not stored in localStorage; only sensitivity, invert, theme are client settings)
45+
const [authToken, setAuthToken] = useState(() => {
46+
if (typeof window === 'undefined') return '';
47+
return localStorage.getItem('rein_auth_token') || '';
48+
});
49+
4550
useEffect(() => {
4651
const defaultIp = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
4752
setIp(defaultIp);
4853
setFrontendPort(String(CONFIG.FRONTEND_PORT));
4954
}, []);
5055

56+
// Auto-generate token on settings page load (localhost only)
57+
useEffect(() => {
58+
if (typeof window === 'undefined') return;
59+
60+
// If we already have a token, no need to generate
61+
const existing = localStorage.getItem('rein_auth_token');
62+
if (existing) return;
63+
64+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
65+
const wsUrl = `${protocol}//${window.location.host}/ws`;
66+
const socket = new WebSocket(wsUrl);
67+
68+
socket.onopen = () => {
69+
socket.send(JSON.stringify({ type: 'generate-token' }));
70+
};
71+
72+
socket.onmessage = (event) => {
73+
try {
74+
const data = JSON.parse(event.data);
75+
if (data.type === 'token-generated' && data.token) {
76+
setAuthToken(data.token);
77+
localStorage.setItem('rein_auth_token', data.token);
78+
socket.close();
79+
}
80+
} catch (e) {
81+
console.error(e);
82+
}
83+
};
84+
85+
return () => {
86+
if (socket.readyState === WebSocket.OPEN) socket.close();
87+
};
88+
}, []);
89+
5190
// Effect: Update LocalStorage when settings change
5291
useEffect(() => {
5392
localStorage.setItem('rein_sensitivity', String(sensitivity));
@@ -57,43 +96,47 @@ function SettingsPage() {
5796
localStorage.setItem('rein_invert', JSON.stringify(invertScroll));
5897
}, [invertScroll]);
5998

99+
// Effect: Theme
60100
useEffect(() => {
61101
if (typeof window === 'undefined') return;
62102
localStorage.setItem(APP_CONFIG.THEME_STORAGE_KEY, theme);
63103
document.documentElement.setAttribute('data-theme', theme);
64104
}, [theme]);
65105

66-
// Generate QR when IP changes (IP is not persisted to localStorage)
106+
// Generate QR when IP changes or Token changes
67107
useEffect(() => {
68108
if (!ip || typeof window === 'undefined') return;
69109
const appPort = String(CONFIG.FRONTEND_PORT);
70110
const protocol = window.location.protocol;
71-
const shareUrl = `${protocol}//${ip}:${appPort}/trackpad`;
111+
let shareUrl = `${protocol}//${ip}:${appPort}/trackpad`;
112+
113+
// Token is embedded in the URL — QR automatically includes it
114+
if (authToken) {
115+
shareUrl += `?token=${encodeURIComponent(authToken)}`;
116+
}
117+
72118
QRCode.toDataURL(shareUrl)
73119
.then(setQrData)
74120
.catch((e) => console.error('QR Error:', e));
75-
}, [ip]);
121+
}, [ip, authToken]);
76122

77123
// Effect: Auto-detect LAN IP from Server (only if on localhost)
78124
useEffect(() => {
79125
if (typeof window === 'undefined') return;
80126
if (window.location.hostname !== 'localhost') return;
81127

82-
console.log('Attempting to auto-detect IP...');
83128
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
84129
const wsUrl = `${protocol}//${window.location.host}/ws`;
85130
const socket = new WebSocket(wsUrl);
86131

87132
socket.onopen = () => {
88-
console.log('Connected to local server for IP detection');
89133
socket.send(JSON.stringify({ type: 'get-ip' }));
90134
};
91135

92136
socket.onmessage = (event) => {
93137
try {
94138
const data = JSON.parse(event.data);
95139
if (data.type === 'server-ip' && data.ip) {
96-
console.log('Auto-detected IP:', data.ip);
97140
setIp(data.ip);
98141
socket.close();
99142
}
@@ -161,6 +204,7 @@ function SettingsPage() {
161204
<span>Slow</span>
162205
<span>Default</span>
163206
<span>Fast</span>
207+
164208
</div>
165209
</div>
166210

src/server/tokenStore.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
interface TokenEntry {
6+
token: string;
7+
createdAt: number;
8+
lastUsed: number;
9+
}
10+
11+
const TOKENS_FILE = path.resolve('./src/tokens.json');
12+
const EXPIRY_MS = 10 * 24 * 60 * 60 * 1000; // 10 days
13+
14+
let tokens: TokenEntry[] = [];
15+
16+
function load(): void {
17+
try {
18+
if (fs.existsSync(TOKENS_FILE)) {
19+
tokens = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
20+
}
21+
} catch {
22+
tokens = [];
23+
}
24+
}
25+
26+
function save(): void {
27+
try {
28+
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
29+
} catch (e) {
30+
console.error('Failed to persist tokens:', e);
31+
}
32+
}
33+
34+
function purgeExpired(): void {
35+
const now = Date.now();
36+
const before = tokens.length;
37+
tokens = tokens.filter(t => (now - t.lastUsed) < EXPIRY_MS);
38+
if (tokens.length !== before) save();
39+
}
40+
41+
/** Constant-time string comparison to prevent timing attacks. */
42+
function timingSafeEqual(a: string, b: string): boolean {
43+
if (a.length !== b.length) return false;
44+
try {
45+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
46+
} catch {
47+
return false;
48+
}
49+
}
50+
51+
/**
52+
* Store a token upon successful connection.
53+
* If it already exists, refresh its lastUsed timestamp.
54+
*/
55+
export function storeToken(token: string): void {
56+
purgeExpired();
57+
const existing = tokens.find(t => timingSafeEqual(t.token, token));
58+
if (existing) {
59+
existing.lastUsed = Date.now();
60+
} else {
61+
const now = Date.now();
62+
tokens.push({ token, createdAt: now, lastUsed: now });
63+
}
64+
save();
65+
}
66+
67+
/** Check if a token is already known/stored on the server. */
68+
export function isKnownToken(token: string): boolean {
69+
purgeExpired();
70+
return tokens.some(t => timingSafeEqual(t.token, token));
71+
}
72+
73+
/** Refresh the lastUsed timestamp for a token. */
74+
export function touchToken(token: string): void {
75+
const entry = tokens.find(t => timingSafeEqual(t.token, token));
76+
if (entry) {
77+
entry.lastUsed = Date.now();
78+
// Persist periodically — save only if >60s since last save
79+
// to avoid excessive disk I/O on every message
80+
}
81+
}
82+
83+
/** Check if any tokens exist yet (first-run detection). */
84+
export function hasTokens(): boolean {
85+
purgeExpired();
86+
return tokens.length > 0;
87+
}
88+
89+
/** Generate a cryptographically random token. */
90+
export function generateToken(): string {
91+
return crypto.randomUUID();
92+
}
93+
94+
// Load persisted tokens on startup
95+
load();

src/server/websocket.ts

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { WebSocketServer, WebSocket } from 'ws';
22
import { InputHandler, InputMessage } from './InputHandler';
3+
import { storeToken, isKnownToken, touchToken, hasTokens, generateToken } from './tokenStore';
34
import os from 'os';
45
import fs from 'fs';
5-
import { Server, IncomingMessage } from 'http';
6+
import { IncomingMessage } from 'http';
67
import { Socket } from 'net';
78

8-
// Helper to find LAN IP
9-
function getLocalIp() {
9+
function getLocalIp(): string {
1010
const nets = os.networkInterfaces();
1111
for (const name of Object.keys(nets)) {
1212
for (const net of nets[name]!) {
@@ -18,27 +18,64 @@ function getLocalIp() {
1818
return 'localhost';
1919
}
2020

21-
export function createWsServer(server: Server) {
21+
function isLocalhost(request: IncomingMessage): boolean {
22+
const addr = request.socket.remoteAddress;
23+
if (!addr) return false;
24+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
25+
}
26+
27+
export function createWsServer(server: any) {
2228
const wss = new WebSocketServer({ noServer: true });
2329
const inputHandler = new InputHandler();
2430
const LAN_IP = getLocalIp();
2531
const MAX_PAYLOAD_SIZE = 10 * 1024; // 10KB limit
2632

27-
console.log(`WebSocket Server initialized (Upgrade mode)`);
28-
console.log(`WS LAN IP: ${LAN_IP}`);
29-
3033
server.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => {
31-
const pathname = request.url;
34+
const url = new URL(request.url || '', `http://${request.headers.host}`);
35+
36+
if (url.pathname !== '/ws') return;
37+
38+
const token = url.searchParams.get('token');
39+
const local = isLocalhost(request);
40+
41+
// Localhost is always allowed (settings page, IP detection, etc.)
42+
if (local) {
43+
wss.handleUpgrade(request, socket, head, (ws) => {
44+
wss.emit('connection', ws, request, token, true);
45+
});
46+
return;
47+
}
48+
49+
// Remote connections require a token
50+
if (!token) {
51+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
52+
socket.destroy();
53+
return;
54+
}
3255

33-
if (pathname === '/ws') {
56+
// First remote connection ever — accept and store the token
57+
if (!hasTokens()) {
58+
storeToken(token);
3459
wss.handleUpgrade(request, socket, head, (ws) => {
35-
wss.emit('connection', ws, request);
60+
wss.emit('connection', ws, request, token, false);
3661
});
62+
return;
3763
}
64+
65+
// Validate against known tokens
66+
if (!isKnownToken(token)) {
67+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
68+
socket.destroy();
69+
return;
70+
}
71+
72+
wss.handleUpgrade(request, socket, head, (ws) => {
73+
wss.emit('connection', ws, request, token, false);
74+
});
3875
});
3976

40-
wss.on('connection', (ws: WebSocket) => {
41-
console.log('Client connected to /ws');
77+
wss.on('connection', (ws: WebSocket, _request: IncomingMessage, token: string | null, isLocal: boolean) => {
78+
if (token) storeToken(token);
4279

4380
ws.send(JSON.stringify({ type: 'connected', serverIp: LAN_IP }));
4481

@@ -54,22 +91,33 @@ export function createWsServer(server: Server) {
5491

5592
const msg = JSON.parse(raw);
5693

94+
if (token) touchToken(token);
95+
5796
if (msg.type === 'get-ip') {
5897
ws.send(JSON.stringify({ type: 'server-ip', ip: LAN_IP }));
5998
return;
6099
}
61100

101+
if (msg.type === 'generate-token') {
102+
if (!isLocal) {
103+
ws.send(JSON.stringify({ type: 'auth-error', error: 'Only localhost can generate tokens' }));
104+
return;
105+
}
106+
const newToken = generateToken();
107+
storeToken(newToken);
108+
ws.send(JSON.stringify({ type: 'token-generated', token: newToken }));
109+
return;
110+
}
111+
62112
if (msg.type === 'update-config') {
63-
console.log('Updating config:', msg.config);
64113
try {
65114
const configPath = './src/server-config.json';
66-
// eslint-disable-next-line @typescript-eslint/no-require-imports
67-
const current = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {};
115+
const current = fs.existsSync(configPath)
116+
? JSON.parse(fs.readFileSync(configPath, 'utf-8'))
117+
: {};
68118
const newConfig = { ...current, ...msg.config };
69-
70119
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
71120
ws.send(JSON.stringify({ type: 'config-updated', success: true }));
72-
console.log('Config updated. Vite should auto-restart.');
73121
} catch (e) {
74122
console.error('Failed to update config:', e);
75123
ws.send(JSON.stringify({ type: 'config-updated', success: false, error: String(e) }));
@@ -83,9 +131,7 @@ export function createWsServer(server: Server) {
83131
}
84132
});
85133

86-
ws.on('close', () => {
87-
console.log('Client disconnected');
88-
});
134+
ws.on('close', () => { /* client disconnected */ });
89135

90136
ws.onerror = (error) => {
91137
console.error('WebSocket error:', error);

0 commit comments

Comments
 (0)