mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +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:
+35
-1
@@ -255,8 +255,9 @@ export class WebServer extends EventEmitter {
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
private authSessions: StaleExpirationMap<string, string> | null = null;
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private teamWatcherHandlers: {
|
||||
@@ -321,6 +322,31 @@ export class WebServer extends EventEmitter {
|
||||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||||
this.broadcast('tunnel:progress', data);
|
||||
});
|
||||
|
||||
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
||||
this.tunnelManager.on('qrTokenRotated', async () => {
|
||||
const url = this.tunnelManager.getUrl();
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRotated', { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip this rotation
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.tunnelManager.on('qrTokenRegenerated', async () => {
|
||||
const url = this.tunnelManager.getUrl();
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRegenerated', { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,6 +511,9 @@ export class WebServer extends EventEmitter {
|
||||
pushStore: this.pushStore,
|
||||
startScheduledRun: this.startScheduledRun.bind(this),
|
||||
stopScheduledRun: this.stopScheduledRun.bind(this),
|
||||
// AuthPort
|
||||
authSessions: this.authSessions,
|
||||
qrAuthFailures: this.qrAuthFailures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -510,6 +539,7 @@ export class WebServer extends EventEmitter {
|
||||
if (authState) {
|
||||
this.authSessions = authState.authSessions;
|
||||
this.authFailures = authState.authFailures;
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
}
|
||||
|
||||
// Security headers + CORS
|
||||
@@ -2652,6 +2682,10 @@ export class WebServer extends EventEmitter {
|
||||
this.authFailures.dispose();
|
||||
this.authFailures = null;
|
||||
}
|
||||
if (this.qrAuthFailures) {
|
||||
this.qrAuthFailures.dispose();
|
||||
this.qrAuthFailures = null;
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user