mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
feat: implement QR code authentication for tunnel access
Adds ephemeral single-use QR tokens for passwordless tunnel login. Scanning the QR auto-authenticates; bare tunnel URL requires Basic Auth. Backend: - TunnelManager: 60s token rotation, 90s grace, rejection-sampled 6-char base62 short codes, Map-based O(1) lookup, SVG caching, global rate limit - Auth middleware: /q/ bypass, separate qrAuthFailures counter, enhanced AuthSessionRecord with device context (ip, ua, createdAt, method) - Routes: GET /q/:code (consume + cookie + redirect), POST /api/tunnel/qr/ regenerate, POST /api/auth/revoke, updated GET /api/tunnel/qr with cache - SSE: tunnel:qrRotated, tunnel:qrRegenerated, tunnel:qrAuthUsed events - Audit: qr_auth lifecycle log entries Frontend: - Auto-refresh QR via inline SVG in SSE (fallback fetch if absent) - 60s countdown indicator on QR badge - Regenerate QR button - QRLjacking detection toast with [Revoke All] action button (10s duration) - showToast enhanced with optional duration and action button support Fixes: - /api/logout now invalidates server-side session token (was only clearing browser cookie, leaving token valid for replay) Tests: 20 new tests in test/qr-auth.test.ts covering token lifecycle, bias check, rate limiting, SVG caching, and full server integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
|
||||
// Auth session cookie TTL (24h — matches autonomous run length)
|
||||
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -25,8 +26,9 @@ const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||
export interface AuthState {
|
||||
authSessions: StaleExpirationMap<string, string> | null;
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const state: AuthState = {
|
||||
authSessions: null,
|
||||
authFailures: null,
|
||||
qrAuthFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
@@ -48,7 +51,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
|
||||
// Session token store — active sessions extend TTL on access
|
||||
state.authSessions = new StaleExpirationMap<string, string>({
|
||||
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||
ttlMs: AUTH_SESSION_TTL_MS,
|
||||
refreshOnGet: true,
|
||||
});
|
||||
@@ -59,6 +62,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
// Separate QR auth failure counter — independent from Basic Auth failures
|
||||
state.qrAuthFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
|
||||
@@ -75,6 +84,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
}
|
||||
|
||||
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||
if (req.url?.startsWith('/q/')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Rate limit: reject if too many failed attempts from this IP
|
||||
@@ -106,7 +121,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
if (oldestKey !== undefined) authSessions.delete(oldestKey);
|
||||
}
|
||||
|
||||
authSessions.set(token, clientIp);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
});
|
||||
|
||||
// Reset failure count on successful auth
|
||||
authFailures.delete(clientIp);
|
||||
|
||||
Reference in New Issue
Block a user