fluxy-bot 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-fluxy/assets/{fluxy-BYgsBU2u.js → fluxy-B3uetGlY.js} +26 -26
- package/dist-fluxy/assets/globals-C2PnXkaM.js +18 -0
- package/dist-fluxy/assets/globals-SDpY_go3.css +1 -0
- package/dist-fluxy/assets/{onboard-CT-gVJYw.js → onboard-CLFtmM94.js} +1 -1
- package/dist-fluxy/fluxy.html +3 -3
- package/dist-fluxy/onboard.html +3 -3
- package/package.json +4 -1
- package/supervisor/chat/OnboardWizard.tsx +284 -2
- package/supervisor/chat/fluxy-main.tsx +4 -1
- package/supervisor/chat/src/components/LoginScreen.tsx +190 -42
- package/supervisor/index.ts +6 -0
- package/worker/db.ts +32 -0
- package/worker/index.ts +251 -4
- package/dist-fluxy/assets/globals-4_SxttFw.js +0 -17
- package/dist-fluxy/assets/globals-DXmThOn-.css +0 -1
|
@@ -1,30 +1,51 @@
|
|
|
1
|
-
import { useState, type KeyboardEvent } from 'react';
|
|
2
|
-
import { Lock, LoaderCircle, ArrowRight } from 'lucide-react';
|
|
1
|
+
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
|
2
|
+
import { Lock, LoaderCircle, ArrowRight, ArrowLeft, Shield, Check } from 'lucide-react';
|
|
3
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
onLogin: (token: string) => void;
|
|
7
|
+
totpEnabled?: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export default function LoginScreen({ onLogin }: Props) {
|
|
10
|
+
export default function LoginScreen({ onLogin, totpEnabled }: Props) {
|
|
9
11
|
const [password, setPassword] = useState('');
|
|
10
12
|
const [error, setError] = useState('');
|
|
11
13
|
const [loading, setLoading] = useState(false);
|
|
12
14
|
|
|
15
|
+
// TOTP state
|
|
16
|
+
const [phase, setPhase] = useState<'password' | 'totp'>('password');
|
|
17
|
+
const [totpCode, setTotpCode] = useState('');
|
|
18
|
+
const [pendingToken, setPendingToken] = useState('');
|
|
19
|
+
const [trustDevice, setTrustDevice] = useState(true);
|
|
20
|
+
const [useRecovery, setUseRecovery] = useState(false);
|
|
21
|
+
const totpInputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
// Auto-focus TOTP input when switching to TOTP phase
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (phase === 'totp') {
|
|
26
|
+
setTimeout(() => totpInputRef.current?.focus(), 100);
|
|
27
|
+
}
|
|
28
|
+
}, [phase]);
|
|
29
|
+
|
|
13
30
|
const handleSubmit = async () => {
|
|
14
31
|
if (!password.trim() || loading) return;
|
|
15
32
|
setLoading(true);
|
|
16
33
|
setError('');
|
|
17
34
|
|
|
18
35
|
try {
|
|
19
|
-
// Use GET + Basic Auth header (relay proxies don't forward POST bodies)
|
|
20
36
|
const credentials = btoa(`admin:${password}`);
|
|
21
37
|
const res = await fetch('/api/portal/login', {
|
|
22
38
|
headers: { 'Authorization': `Basic ${credentials}` },
|
|
39
|
+
credentials: 'include',
|
|
23
40
|
});
|
|
24
41
|
const data = await res.json();
|
|
25
42
|
|
|
26
43
|
if (res.ok && data.token) {
|
|
27
44
|
onLogin(data.token);
|
|
45
|
+
} else if (res.ok && data.requiresTOTP) {
|
|
46
|
+
setPendingToken(data.pendingToken);
|
|
47
|
+
setPhase('totp');
|
|
48
|
+
setError('');
|
|
28
49
|
} else {
|
|
29
50
|
setError(data.error || 'Invalid password');
|
|
30
51
|
}
|
|
@@ -35,8 +56,35 @@ export default function LoginScreen({ onLogin }: Props) {
|
|
|
35
56
|
}
|
|
36
57
|
};
|
|
37
58
|
|
|
59
|
+
const handleTotpSubmit = async () => {
|
|
60
|
+
if (!totpCode.trim() || loading) return;
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setError('');
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(
|
|
66
|
+
`/api/portal/login/totp?pending=${encodeURIComponent(pendingToken)}&code=${encodeURIComponent(totpCode)}&trust=${trustDevice ? '1' : '0'}`,
|
|
67
|
+
{ credentials: 'include' },
|
|
68
|
+
);
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
|
|
71
|
+
if (res.ok && data.token) {
|
|
72
|
+
onLogin(data.token);
|
|
73
|
+
} else {
|
|
74
|
+
setError(data.error || 'Invalid code');
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
setError('Could not reach server');
|
|
78
|
+
} finally {
|
|
79
|
+
setLoading(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
38
83
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
39
|
-
if (e.key === 'Enter')
|
|
84
|
+
if (e.key === 'Enter') {
|
|
85
|
+
if (phase === 'password') handleSubmit();
|
|
86
|
+
else handleTotpSubmit();
|
|
87
|
+
}
|
|
40
88
|
};
|
|
41
89
|
|
|
42
90
|
const inputCls = 'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none input-glow placeholder:text-white/20 transition-all';
|
|
@@ -44,45 +92,145 @@ export default function LoginScreen({ onLogin }: Props) {
|
|
|
44
92
|
return (
|
|
45
93
|
<div className="flex flex-col items-center justify-center h-dvh px-6">
|
|
46
94
|
<div className="w-full max-w-[320px] flex flex-col items-center">
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
<AnimatePresence mode="wait">
|
|
96
|
+
{phase === 'password' ? (
|
|
97
|
+
<motion.div
|
|
98
|
+
key="password"
|
|
99
|
+
initial={{ opacity: 0, x: -20 }}
|
|
100
|
+
animate={{ opacity: 1, x: 0 }}
|
|
101
|
+
exit={{ opacity: 0, x: -20 }}
|
|
102
|
+
transition={{ duration: 0.2 }}
|
|
103
|
+
className="w-full flex flex-col items-center"
|
|
104
|
+
>
|
|
105
|
+
<div className="w-14 h-14 rounded-2xl bg-white/[0.04] border border-white/[0.08] flex items-center justify-center mb-5">
|
|
106
|
+
<Lock className="h-6 w-6 text-white/40" />
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<h1 className="text-xl font-bold text-white tracking-tight mb-1">
|
|
110
|
+
Welcome back
|
|
111
|
+
</h1>
|
|
112
|
+
<p className="text-white/40 text-[13px] mb-6">
|
|
113
|
+
Enter your password to continue.
|
|
114
|
+
</p>
|
|
115
|
+
|
|
116
|
+
{error && (
|
|
117
|
+
<div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
|
|
118
|
+
<p className="text-red-400/90 text-[12px]">{error}</p>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<input
|
|
123
|
+
type="password"
|
|
124
|
+
value={password}
|
|
125
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
126
|
+
onKeyDown={handleKeyDown}
|
|
127
|
+
placeholder="Password"
|
|
128
|
+
autoFocus
|
|
129
|
+
autoComplete="current-password"
|
|
130
|
+
className={inputCls}
|
|
131
|
+
/>
|
|
132
|
+
|
|
133
|
+
<button
|
|
134
|
+
onClick={handleSubmit}
|
|
135
|
+
disabled={!password.trim() || loading}
|
|
136
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
137
|
+
>
|
|
138
|
+
{loading ? (
|
|
139
|
+
<><LoaderCircle className="h-4 w-4 animate-spin" />Signing in...</>
|
|
140
|
+
) : (
|
|
141
|
+
<>Sign In<ArrowRight className="h-4 w-4" /></>
|
|
142
|
+
)}
|
|
143
|
+
</button>
|
|
144
|
+
</motion.div>
|
|
82
145
|
) : (
|
|
83
|
-
|
|
146
|
+
<motion.div
|
|
147
|
+
key="totp"
|
|
148
|
+
initial={{ opacity: 0, x: 20 }}
|
|
149
|
+
animate={{ opacity: 1, x: 0 }}
|
|
150
|
+
exit={{ opacity: 0, x: 20 }}
|
|
151
|
+
transition={{ duration: 0.2 }}
|
|
152
|
+
className="w-full flex flex-col items-center"
|
|
153
|
+
>
|
|
154
|
+
<div className="w-14 h-14 rounded-2xl bg-[#AF27E3]/10 border border-[#AF27E3]/20 flex items-center justify-center mb-5">
|
|
155
|
+
<Shield className="h-6 w-6 text-[#AF27E3]" />
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<h1 className="text-xl font-bold text-white tracking-tight mb-1">
|
|
159
|
+
{useRecovery ? 'Recovery code' : 'Enter your 2FA code'}
|
|
160
|
+
</h1>
|
|
161
|
+
<p className="text-white/40 text-[13px] mb-6">
|
|
162
|
+
{useRecovery
|
|
163
|
+
? 'Enter one of your recovery codes.'
|
|
164
|
+
: 'Open your authenticator app and enter the 6-digit code.'}
|
|
165
|
+
</p>
|
|
166
|
+
|
|
167
|
+
{error && (
|
|
168
|
+
<div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
|
|
169
|
+
<p className="text-red-400/90 text-[12px]">{error}</p>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<input
|
|
174
|
+
ref={totpInputRef}
|
|
175
|
+
type="text"
|
|
176
|
+
inputMode={useRecovery ? 'text' : 'numeric'}
|
|
177
|
+
autoComplete="one-time-code"
|
|
178
|
+
maxLength={useRecovery ? 20 : 6}
|
|
179
|
+
value={totpCode}
|
|
180
|
+
onChange={(e) => {
|
|
181
|
+
setTotpCode(useRecovery ? e.target.value : e.target.value.replace(/\D/g, ''));
|
|
182
|
+
setError('');
|
|
183
|
+
}}
|
|
184
|
+
onKeyDown={handleKeyDown}
|
|
185
|
+
placeholder={useRecovery ? 'Recovery code' : '000000'}
|
|
186
|
+
className={inputCls + (useRecovery ? '' : ' tracking-[0.3em] text-center font-mono')}
|
|
187
|
+
/>
|
|
188
|
+
|
|
189
|
+
{/* Trust device checkbox */}
|
|
190
|
+
<label className="flex items-center gap-2.5 mt-4 w-full cursor-pointer">
|
|
191
|
+
<div
|
|
192
|
+
onClick={() => setTrustDevice(v => !v)}
|
|
193
|
+
className={`w-5 h-5 rounded-md border flex items-center justify-center transition-all ${
|
|
194
|
+
trustDevice
|
|
195
|
+
? 'bg-[#AF27E3] border-[#AF27E3]'
|
|
196
|
+
: 'bg-white/[0.04] border-white/[0.12]'
|
|
197
|
+
}`}
|
|
198
|
+
>
|
|
199
|
+
{trustDevice && <Check className="h-3.5 w-3.5 text-white" />}
|
|
200
|
+
</div>
|
|
201
|
+
<span className="text-[13px] text-white/50">Trust this device for 90 days</span>
|
|
202
|
+
</label>
|
|
203
|
+
|
|
204
|
+
<button
|
|
205
|
+
onClick={handleTotpSubmit}
|
|
206
|
+
disabled={!totpCode.trim() || loading}
|
|
207
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
208
|
+
>
|
|
209
|
+
{loading ? (
|
|
210
|
+
<><LoaderCircle className="h-4 w-4 animate-spin" />Verifying...</>
|
|
211
|
+
) : (
|
|
212
|
+
<>Verify<ArrowRight className="h-4 w-4" /></>
|
|
213
|
+
)}
|
|
214
|
+
</button>
|
|
215
|
+
|
|
216
|
+
<div className="flex items-center gap-4 mt-4">
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => { setPhase('password'); setError(''); setTotpCode(''); setUseRecovery(false); }}
|
|
219
|
+
className="text-[12px] text-white/30 hover:text-white/50 flex items-center gap-1 transition-colors"
|
|
220
|
+
>
|
|
221
|
+
<ArrowLeft className="h-3 w-3" />
|
|
222
|
+
Back
|
|
223
|
+
</button>
|
|
224
|
+
<button
|
|
225
|
+
onClick={() => { setUseRecovery(v => !v); setTotpCode(''); setError(''); }}
|
|
226
|
+
className="text-[12px] text-white/30 hover:text-white/50 transition-colors"
|
|
227
|
+
>
|
|
228
|
+
{useRecovery ? 'Use authenticator code' : 'Use a recovery code'}
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</motion.div>
|
|
84
232
|
)}
|
|
85
|
-
</
|
|
233
|
+
</AnimatePresence>
|
|
86
234
|
</div>
|
|
87
235
|
</div>
|
|
88
236
|
);
|
package/supervisor/index.ts
CHANGED
|
@@ -198,6 +198,12 @@ export async function startSupervisor() {
|
|
|
198
198
|
'POST /api/auth/codex/start',
|
|
199
199
|
'POST /api/auth/codex/cancel',
|
|
200
200
|
'GET /api/auth/codex/status',
|
|
201
|
+
'POST /api/portal/totp/setup',
|
|
202
|
+
'POST /api/portal/totp/verify-setup',
|
|
203
|
+
'POST /api/portal/totp/disable',
|
|
204
|
+
'GET /api/portal/totp/status',
|
|
205
|
+
'GET /api/portal/login/totp',
|
|
206
|
+
'POST /api/portal/devices/revoke',
|
|
201
207
|
];
|
|
202
208
|
|
|
203
209
|
function isExemptRoute(method: string, url: string): boolean {
|
package/worker/db.ts
CHANGED
|
@@ -39,6 +39,15 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
|
39
39
|
keys_auth TEXT NOT NULL,
|
|
40
40
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
41
41
|
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS trusted_devices (
|
|
43
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
44
|
+
token TEXT NOT NULL UNIQUE,
|
|
45
|
+
label TEXT,
|
|
46
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
47
|
+
expires_at DATETIME NOT NULL,
|
|
48
|
+
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_td_token ON trusted_devices(token);
|
|
42
51
|
`;
|
|
43
52
|
|
|
44
53
|
let db: Database.Database;
|
|
@@ -145,6 +154,29 @@ export function getPushSubscriptionByEndpoint(endpoint: string) {
|
|
|
145
154
|
return db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?').get(endpoint) as { id: number; endpoint: string; keys_p256dh: string; keys_auth: string } | undefined;
|
|
146
155
|
}
|
|
147
156
|
|
|
157
|
+
// Trusted devices (2FA)
|
|
158
|
+
export function createTrustedDevice(token: string, label: string, expiresAt: string) {
|
|
159
|
+
return db.prepare('INSERT INTO trusted_devices (token, label, expires_at) VALUES (?, ?, ?) RETURNING *').get(token, label, expiresAt) as any;
|
|
160
|
+
}
|
|
161
|
+
export function getTrustedDevice(token: string): { id: string; token: string; label: string; created_at: string; expires_at: string; last_seen: string } | undefined {
|
|
162
|
+
return db.prepare("SELECT * FROM trusted_devices WHERE token = ? AND expires_at > datetime('now')").get(token) as any;
|
|
163
|
+
}
|
|
164
|
+
export function updateDeviceLastSeen(token: string) {
|
|
165
|
+
db.prepare("UPDATE trusted_devices SET last_seen = CURRENT_TIMESTAMP WHERE token = ?").run(token);
|
|
166
|
+
}
|
|
167
|
+
export function listTrustedDevices() {
|
|
168
|
+
return db.prepare("SELECT id, label, last_seen, created_at FROM trusted_devices WHERE expires_at > datetime('now') ORDER BY last_seen DESC").all() as { id: string; label: string; last_seen: string; created_at: string }[];
|
|
169
|
+
}
|
|
170
|
+
export function deleteTrustedDevice(id: string) {
|
|
171
|
+
db.prepare('DELETE FROM trusted_devices WHERE id = ?').run(id);
|
|
172
|
+
}
|
|
173
|
+
export function deleteExpiredDevices() {
|
|
174
|
+
db.prepare("DELETE FROM trusted_devices WHERE expires_at <= datetime('now')").run();
|
|
175
|
+
}
|
|
176
|
+
export function deleteAllTrustedDevices() {
|
|
177
|
+
db.prepare('DELETE FROM trusted_devices').run();
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
// Recent messages (for context injection)
|
|
149
181
|
export function getRecentMessages(convId: string, limit = 20) {
|
|
150
182
|
return db.prepare(`
|
package/worker/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ import path from 'path';
|
|
|
5
5
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
6
6
|
import { paths, WORKSPACE_DIR } from '../shared/paths.js';
|
|
7
7
|
import { log } from '../shared/logger.js';
|
|
8
|
-
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint } from './db.js';
|
|
8
|
+
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
|
|
9
9
|
import webpush from 'web-push';
|
|
10
|
+
import { TOTP } from 'otpauth';
|
|
11
|
+
import QRCode from 'qrcode';
|
|
10
12
|
import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
|
|
11
13
|
import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
|
|
12
14
|
import { checkAvailability, registerHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
|
|
@@ -26,6 +28,45 @@ function verifyPassword(password: string, stored: string): boolean {
|
|
|
26
28
|
return hash === test;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// ── TOTP helpers ──
|
|
32
|
+
|
|
33
|
+
function generateTOTPSecret(): string {
|
|
34
|
+
return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function verifyTOTPCode(code: string, secret: string): boolean {
|
|
38
|
+
const totp = new TOTP({ issuer: 'Fluxy', algorithm: 'SHA1', digits: 6, period: 30, secret });
|
|
39
|
+
const delta = totp.validate({ token: code, window: 1 });
|
|
40
|
+
return delta !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function generateRecoveryCodes(): string[] {
|
|
44
|
+
const codes: string[] = [];
|
|
45
|
+
for (let i = 0; i < 8; i++) {
|
|
46
|
+
codes.push(crypto.randomBytes(4).toString('hex'));
|
|
47
|
+
}
|
|
48
|
+
return codes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hashRecoveryCode(code: string): string {
|
|
52
|
+
return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } {
|
|
56
|
+
const h = hashRecoveryCode(code);
|
|
57
|
+
const idx = hashes.indexOf(h);
|
|
58
|
+
if (idx === -1) return { valid: false, remaining: hashes };
|
|
59
|
+
const remaining = [...hashes];
|
|
60
|
+
remaining.splice(idx, 1);
|
|
61
|
+
return { valid: true, remaining };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
|
|
65
|
+
if (!cookieHeader) return undefined;
|
|
66
|
+
const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
|
|
67
|
+
return match ? match.slice(name.length + 1) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
const port = parseInt(process.env.WORKER_PORT || '3001', 10);
|
|
30
71
|
const config = loadConfig();
|
|
31
72
|
|
|
@@ -293,6 +334,7 @@ app.get('/api/onboard/status', (_, res) => {
|
|
|
293
334
|
tunnelMode: cfg.tunnel?.mode || 'quick',
|
|
294
335
|
tunnelDomain: cfg.tunnel?.domain || '',
|
|
295
336
|
tunnelUrl: cfg.tunnelUrl || '',
|
|
337
|
+
totpEnabled: settings.totp_enabled === 'true',
|
|
296
338
|
});
|
|
297
339
|
});
|
|
298
340
|
|
|
@@ -304,12 +346,38 @@ app.post('/api/portal/verify-password', (req, res) => {
|
|
|
304
346
|
});
|
|
305
347
|
|
|
306
348
|
// Shared login logic (used by both POST and GET handlers)
|
|
307
|
-
function handleLogin(username: string | undefined, password: string | undefined, res: any) {
|
|
349
|
+
function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) {
|
|
308
350
|
if (!password) { res.status(400).json({ error: 'Password required' }); return; }
|
|
309
351
|
const storedPass = getSetting('portal_pass');
|
|
310
352
|
if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; }
|
|
311
353
|
if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; }
|
|
312
354
|
|
|
355
|
+
// Check if TOTP is enabled
|
|
356
|
+
const totpEnabled = getSetting('totp_enabled') === 'true';
|
|
357
|
+
if (totpEnabled) {
|
|
358
|
+
// Check for trusted device cookie
|
|
359
|
+
const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
|
|
360
|
+
if (deviceToken) {
|
|
361
|
+
const device = getTrustedDevice(deviceToken);
|
|
362
|
+
if (device) {
|
|
363
|
+
updateDeviceLastSeen(deviceToken);
|
|
364
|
+
// Trusted device — skip TOTP
|
|
365
|
+
deleteExpiredSessions();
|
|
366
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
367
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
368
|
+
createSession(token, expiresAt);
|
|
369
|
+
res.json({ token, expiresAt });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// No valid trusted device — require TOTP
|
|
374
|
+
const pendingToken = crypto.randomBytes(32).toString('hex');
|
|
375
|
+
const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
376
|
+
setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
|
|
377
|
+
res.json({ requiresTOTP: true, pendingToken });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
313
381
|
deleteExpiredSessions();
|
|
314
382
|
const token = crypto.randomBytes(64).toString('hex');
|
|
315
383
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
@@ -320,7 +388,7 @@ function handleLogin(username: string | undefined, password: string | undefined,
|
|
|
320
388
|
// POST: credentials in JSON body
|
|
321
389
|
app.post('/api/portal/login', (req, res) => {
|
|
322
390
|
const { username, password } = req.body;
|
|
323
|
-
handleLogin(username, password, res);
|
|
391
|
+
handleLogin(username, password, req, res);
|
|
324
392
|
});
|
|
325
393
|
|
|
326
394
|
// GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies)
|
|
@@ -330,7 +398,7 @@ app.get('/api/portal/login', (req, res) => {
|
|
|
330
398
|
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
331
399
|
const sep = decoded.indexOf(':');
|
|
332
400
|
if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; }
|
|
333
|
-
handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), res);
|
|
401
|
+
handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
|
|
334
402
|
});
|
|
335
403
|
|
|
336
404
|
// POST + GET for validate-token (same relay issue)
|
|
@@ -348,6 +416,185 @@ app.get('/api/portal/validate-token', (req, res) => {
|
|
|
348
416
|
handleValidateToken(req.query.token as string, res);
|
|
349
417
|
});
|
|
350
418
|
|
|
419
|
+
// ── TOTP 2FA endpoints ──
|
|
420
|
+
|
|
421
|
+
app.get('/api/portal/totp/status', (_req, res) => {
|
|
422
|
+
res.json({ enabled: getSetting('totp_enabled') === 'true' });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
app.post('/api/portal/totp/setup', async (req, res) => {
|
|
426
|
+
// Verify caller has auth: either valid session token or correct password
|
|
427
|
+
const authHeader = req.headers['authorization'];
|
|
428
|
+
let authorized = false;
|
|
429
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
430
|
+
const session = getSession(authHeader.slice(7));
|
|
431
|
+
if (session) authorized = true;
|
|
432
|
+
}
|
|
433
|
+
if (!authorized && req.body?.password) {
|
|
434
|
+
const storedPass = getSetting('portal_pass');
|
|
435
|
+
if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
|
|
436
|
+
}
|
|
437
|
+
if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
438
|
+
|
|
439
|
+
const secret = generateTOTPSecret();
|
|
440
|
+
setSetting('totp_pending_secret', secret);
|
|
441
|
+
|
|
442
|
+
const botName = getSetting('agent_name') || 'Fluxy';
|
|
443
|
+
const totp = new TOTP({ issuer: 'Fluxy', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret });
|
|
444
|
+
const otpauthUri = totp.toString();
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
|
|
448
|
+
res.json({ secret, qrDataUri, otpauthUri });
|
|
449
|
+
} catch (err: any) {
|
|
450
|
+
res.status(500).json({ error: 'Failed to generate QR code' });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
app.post('/api/portal/totp/verify-setup', (req, res) => {
|
|
455
|
+
const authHeader = req.headers['authorization'];
|
|
456
|
+
let authorized = false;
|
|
457
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
458
|
+
const session = getSession(authHeader.slice(7));
|
|
459
|
+
if (session) authorized = true;
|
|
460
|
+
}
|
|
461
|
+
if (!authorized && req.body?.password) {
|
|
462
|
+
const storedPass = getSetting('portal_pass');
|
|
463
|
+
if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
|
|
464
|
+
}
|
|
465
|
+
if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
466
|
+
|
|
467
|
+
const { code } = req.body;
|
|
468
|
+
if (!code) { res.status(400).json({ error: 'Code required' }); return; }
|
|
469
|
+
|
|
470
|
+
const pendingSecret = getSetting('totp_pending_secret');
|
|
471
|
+
if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; }
|
|
472
|
+
|
|
473
|
+
if (!verifyTOTPCode(code, pendingSecret)) {
|
|
474
|
+
res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Success: persist TOTP config
|
|
479
|
+
setSetting('totp_secret', pendingSecret);
|
|
480
|
+
setSetting('totp_enabled', 'true');
|
|
481
|
+
|
|
482
|
+
// Generate recovery codes
|
|
483
|
+
const codes = generateRecoveryCodes();
|
|
484
|
+
const hashes = codes.map(hashRecoveryCode);
|
|
485
|
+
setSetting('totp_recovery_codes', JSON.stringify(hashes));
|
|
486
|
+
|
|
487
|
+
// Clean up pending secret
|
|
488
|
+
setSetting('totp_pending_secret', '');
|
|
489
|
+
|
|
490
|
+
res.json({ success: true, recoveryCodes: codes });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
app.post('/api/portal/totp/disable', (req, res) => {
|
|
494
|
+
const { password, code } = req.body;
|
|
495
|
+
if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; }
|
|
496
|
+
|
|
497
|
+
const storedPass = getSetting('portal_pass');
|
|
498
|
+
if (!storedPass || !verifyPassword(password, storedPass)) {
|
|
499
|
+
res.status(401).json({ error: 'Invalid password' });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const secret = getSetting('totp_secret');
|
|
504
|
+
if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; }
|
|
505
|
+
|
|
506
|
+
if (!verifyTOTPCode(code, secret)) {
|
|
507
|
+
res.status(400).json({ error: 'Invalid TOTP code' });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Clear all TOTP settings
|
|
512
|
+
setSetting('totp_enabled', 'false');
|
|
513
|
+
setSetting('totp_secret', '');
|
|
514
|
+
setSetting('totp_recovery_codes', '');
|
|
515
|
+
setSetting('totp_pending_secret', '');
|
|
516
|
+
deleteAllTrustedDevices();
|
|
517
|
+
|
|
518
|
+
res.json({ success: true });
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
app.get('/api/portal/login/totp', (req, res) => {
|
|
522
|
+
const pending = req.query.pending as string;
|
|
523
|
+
const code = req.query.code as string;
|
|
524
|
+
const trust = req.query.trust as string;
|
|
525
|
+
|
|
526
|
+
if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; }
|
|
527
|
+
|
|
528
|
+
// Validate pending token
|
|
529
|
+
const expiry = getSetting(`totp_pending_login:${pending}`);
|
|
530
|
+
if (!expiry || new Date(expiry) < new Date()) {
|
|
531
|
+
res.status(401).json({ error: 'Login session expired. Please start over.' });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Clean up pending token
|
|
536
|
+
setSetting(`totp_pending_login:${pending}`, '');
|
|
537
|
+
|
|
538
|
+
const secret = getSetting('totp_secret');
|
|
539
|
+
if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; }
|
|
540
|
+
|
|
541
|
+
// Try TOTP code first
|
|
542
|
+
let valid = verifyTOTPCode(code, secret);
|
|
543
|
+
|
|
544
|
+
// If not valid as TOTP, try as recovery code
|
|
545
|
+
if (!valid) {
|
|
546
|
+
const hashesJson = getSetting('totp_recovery_codes');
|
|
547
|
+
if (hashesJson) {
|
|
548
|
+
try {
|
|
549
|
+
const hashes = JSON.parse(hashesJson) as string[];
|
|
550
|
+
const result = verifyRecoveryCode(code, hashes);
|
|
551
|
+
if (result.valid) {
|
|
552
|
+
valid = true;
|
|
553
|
+
setSetting('totp_recovery_codes', JSON.stringify(result.remaining));
|
|
554
|
+
}
|
|
555
|
+
} catch {}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!valid) {
|
|
560
|
+
res.status(401).json({ error: 'Invalid code' });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Create session
|
|
565
|
+
deleteExpiredSessions();
|
|
566
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
567
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
568
|
+
createSession(token, expiresAt);
|
|
569
|
+
|
|
570
|
+
// Trust device if requested
|
|
571
|
+
if (trust === '1') {
|
|
572
|
+
deleteExpiredDevices();
|
|
573
|
+
const deviceToken = crypto.randomBytes(32).toString('hex');
|
|
574
|
+
const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
575
|
+
createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
|
|
576
|
+
res.setHeader('Set-Cookie', `fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
res.json({ token, expiresAt });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
app.get('/api/portal/devices', (_req, res) => {
|
|
583
|
+
res.json(listTrustedDevices());
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
app.delete('/api/portal/devices/:id', (req, res) => {
|
|
587
|
+
deleteTrustedDevice(req.params.id);
|
|
588
|
+
res.json({ ok: true });
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
app.post('/api/portal/devices/revoke', (req, res) => {
|
|
592
|
+
const { id } = req.body;
|
|
593
|
+
if (!id) { res.status(400).json({ error: 'Device ID required' }); return; }
|
|
594
|
+
deleteTrustedDevice(id);
|
|
595
|
+
res.json({ ok: true });
|
|
596
|
+
});
|
|
597
|
+
|
|
351
598
|
app.post('/api/onboard', (req, res) => {
|
|
352
599
|
const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;
|
|
353
600
|
|