tacilytics 2.0.2 → 2.1.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/.env.example +7 -1
- package/README.md +32 -3
- package/auth.js +608 -0
- package/cli.js +36 -0
- package/package.json +5 -2
- package/public/account.js +335 -0
- package/public/app.js +15 -18
- package/public/assets/icons.svg +5 -0
- package/public/index.html +76 -33
- package/public/style.css +39 -0
- package/server.js +34 -44
package/auth.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* Tacilytics — accounts, team, optional 2FA (TOTP) and passkeys (WebAuthn).
|
|
3
|
+
*
|
|
4
|
+
* Two modes, decided by whether the users table is empty:
|
|
5
|
+
* - Legacy (no accounts yet): sign in with WEB_PASSWORD, exactly like before 2.1.
|
|
6
|
+
* The signed-in owner can create the first account from Settings → Team.
|
|
7
|
+
* - Accounts (≥ 1 user): WEB_PASSWORD no longer opens anything (otherwise it would be a
|
|
8
|
+
* way around 2FA). Sign in with username + password (+ TOTP if enabled) or a passkey.
|
|
9
|
+
* API_KEY (Bearer) keeps owner rights in both modes — it is for agents / the CLI.
|
|
10
|
+
*
|
|
11
|
+
* Roles: owner (everything incl. team) > admin (sites, shares, funnels) > viewer (read only,
|
|
12
|
+
* optionally limited to a list of sites).
|
|
13
|
+
*
|
|
14
|
+
* Secrets at rest: passwords = scrypt, session/invite tokens + recovery codes = sha256,
|
|
15
|
+
* TOTP secrets are stored as-is (they must be readable to verify codes).
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
const ROLES = { viewer: 1, admin: 2, owner: 3 };
|
|
20
|
+
const SESSION_DAYS = 30;
|
|
21
|
+
const INVITE_DAYS = 7;
|
|
22
|
+
const MIN_PW = 10;
|
|
23
|
+
|
|
24
|
+
// ── small crypto helpers ───────────────────────────────────────────────────
|
|
25
|
+
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
26
|
+
const randToken = (n = 24) => crypto.randomBytes(n).toString('hex');
|
|
27
|
+
function safeEq(a, b) {
|
|
28
|
+
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
29
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
30
|
+
}
|
|
31
|
+
const SCRYPT = { N: 16384, r: 8, p: 1 };
|
|
32
|
+
const scryptAsync = (pw, salt, len, o) => new Promise((ok, no) => crypto.scrypt(pw, salt, len, o, (e, k) => (e ? no(e) : ok(k))));
|
|
33
|
+
function fmtHash(salt, h) { return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64')}$${h.toString('base64')}`; }
|
|
34
|
+
// sync variants: only for module start and the offline CLI (no event loop to block there)
|
|
35
|
+
function hashPasswordSync(pw) {
|
|
36
|
+
const salt = crypto.randomBytes(16);
|
|
37
|
+
return fmtHash(salt, crypto.scryptSync(String(pw), salt, 32, SCRYPT));
|
|
38
|
+
}
|
|
39
|
+
// async variants: everything that runs inside a request, so a login never blocks /api/collect
|
|
40
|
+
async function hashPassword(pw) {
|
|
41
|
+
const salt = crypto.randomBytes(16);
|
|
42
|
+
return fmtHash(salt, await scryptAsync(String(pw), salt, 32, SCRYPT));
|
|
43
|
+
}
|
|
44
|
+
async function verifyPassword(pw, stored) {
|
|
45
|
+
const parts = String(stored || '').split('$');
|
|
46
|
+
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
|
47
|
+
const [, N, r, p, salt, hash] = parts;
|
|
48
|
+
const want = Buffer.from(hash, 'base64');
|
|
49
|
+
const got = await scryptAsync(String(pw), Buffer.from(salt, 'base64'), want.length, { N: +N, r: +r, p: +p });
|
|
50
|
+
return crypto.timingSafeEqual(got, want);
|
|
51
|
+
}
|
|
52
|
+
// a fixed dummy hash so unknown usernames cost the same time as wrong passwords
|
|
53
|
+
const DUMMY_HASH = hashPasswordSync(randToken());
|
|
54
|
+
|
|
55
|
+
// ── TOTP (RFC 6238: HMAC-SHA1, 30 s, 6 digits) ────────────────────────────
|
|
56
|
+
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
57
|
+
function base32Encode(buf) {
|
|
58
|
+
let bits = 0, val = 0, out = '';
|
|
59
|
+
for (const byte of buf) {
|
|
60
|
+
val = (val << 8) | byte; bits += 8;
|
|
61
|
+
while (bits >= 5) { out += B32[(val >>> (bits - 5)) & 31]; bits -= 5; }
|
|
62
|
+
}
|
|
63
|
+
if (bits > 0) out += B32[(val << (5 - bits)) & 31];
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function base32Decode(str) {
|
|
67
|
+
const s = String(str).toUpperCase().replace(/=+$/, '').replace(/\s/g, '');
|
|
68
|
+
let bits = 0, val = 0; const out = [];
|
|
69
|
+
for (const ch of s) {
|
|
70
|
+
const i = B32.indexOf(ch); if (i < 0) throw new Error('bad base32');
|
|
71
|
+
val = (val << 5) | i; bits += 5;
|
|
72
|
+
if (bits >= 8) { out.push((val >>> (bits - 8)) & 255); bits -= 8; }
|
|
73
|
+
}
|
|
74
|
+
return Buffer.from(out);
|
|
75
|
+
}
|
|
76
|
+
function totpAt(secretB32, step) {
|
|
77
|
+
const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(step));
|
|
78
|
+
const h = crypto.createHmac('sha1', base32Decode(secretB32)).update(msg).digest();
|
|
79
|
+
const o = h[h.length - 1] & 15;
|
|
80
|
+
const code = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
|
|
81
|
+
return String(code % 1e6).padStart(6, '0');
|
|
82
|
+
}
|
|
83
|
+
/** returns the matched time step (for replay protection) or -1 */
|
|
84
|
+
function totpCheck(secretB32, code, now = Date.now()) {
|
|
85
|
+
const c = String(code || '').replace(/\s/g, '');
|
|
86
|
+
if (!/^\d{6}$/.test(c)) return -1;
|
|
87
|
+
const step = Math.floor(now / 30000);
|
|
88
|
+
for (const d of [0, -1, 1]) if (safeEq(totpAt(secretB32, step + d), c)) return step + d;
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
function newRecoveryCodes() {
|
|
92
|
+
// 10 codes like "k3f9-x2qa-7mde", alphabet without look-alikes
|
|
93
|
+
const A = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
94
|
+
return Array.from({ length: 10 }, () => {
|
|
95
|
+
const b = crypto.randomBytes(12);
|
|
96
|
+
let s = ''; for (let i = 0; i < 12; i++) s += A[b[i] % A.length];
|
|
97
|
+
return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const normRecovery = (c) => String(c || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
101
|
+
|
|
102
|
+
// ── base64url helpers for WebAuthn ─────────────────────────────────────────
|
|
103
|
+
const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
|
104
|
+
const fromB64u = (s) => new Uint8Array(Buffer.from(String(s), 'base64url'));
|
|
105
|
+
|
|
106
|
+
function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, siteIdByDomain, allSiteIds }) {
|
|
107
|
+
const webauthn = require('@simplewebauthn/server');
|
|
108
|
+
const QRCode = require('qrcode');
|
|
109
|
+
|
|
110
|
+
db.exec(`
|
|
111
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
112
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
113
|
+
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
114
|
+
pw_hash TEXT NOT NULL,
|
|
115
|
+
role TEXT NOT NULL DEFAULT 'viewer',
|
|
116
|
+
sites TEXT, -- JSON array of site ids; NULL = all sites
|
|
117
|
+
totp_secret TEXT, -- set once 2FA is enabled
|
|
118
|
+
totp_pending TEXT, -- secret shown during setup, not active yet
|
|
119
|
+
totp_last_step INTEGER, -- last accepted TOTP step (no code reuse)
|
|
120
|
+
recovery TEXT, -- JSON array of sha256(code)
|
|
121
|
+
webauthn_id TEXT NOT NULL, -- random user handle for passkeys (base64url)
|
|
122
|
+
created INTEGER NOT NULL,
|
|
123
|
+
last_login INTEGER
|
|
124
|
+
);
|
|
125
|
+
CREATE TABLE IF NOT EXISTS passkeys (
|
|
126
|
+
id TEXT PRIMARY KEY, -- credential id (base64url)
|
|
127
|
+
user_id INTEGER NOT NULL,
|
|
128
|
+
public_key BLOB NOT NULL,
|
|
129
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
transports TEXT,
|
|
131
|
+
name TEXT,
|
|
132
|
+
created INTEGER NOT NULL,
|
|
133
|
+
last_used INTEGER
|
|
134
|
+
);
|
|
135
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
136
|
+
token_hash TEXT PRIMARY KEY,
|
|
137
|
+
user_id INTEGER, -- NULL = legacy WEB_PASSWORD session
|
|
138
|
+
created INTEGER NOT NULL,
|
|
139
|
+
expires INTEGER NOT NULL
|
|
140
|
+
);
|
|
141
|
+
CREATE TABLE IF NOT EXISTS invites (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
144
|
+
role TEXT NOT NULL,
|
|
145
|
+
sites TEXT,
|
|
146
|
+
created_by INTEGER,
|
|
147
|
+
created INTEGER NOT NULL,
|
|
148
|
+
expires INTEGER NOT NULL,
|
|
149
|
+
used_at INTEGER,
|
|
150
|
+
used_by INTEGER
|
|
151
|
+
);
|
|
152
|
+
`);
|
|
153
|
+
|
|
154
|
+
const q = {
|
|
155
|
+
userCount: db.prepare('SELECT COUNT(*) n FROM users'),
|
|
156
|
+
ownerCount: db.prepare("SELECT COUNT(*) n FROM users WHERE role = 'owner'"),
|
|
157
|
+
userById: db.prepare('SELECT * FROM users WHERE id = ?'),
|
|
158
|
+
userByName: db.prepare('SELECT * FROM users WHERE username = ?'),
|
|
159
|
+
userByHandle: db.prepare('SELECT * FROM users WHERE webauthn_id = ?'),
|
|
160
|
+
insUser: db.prepare('INSERT INTO users (username, pw_hash, role, sites, webauthn_id, created) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
161
|
+
sessGet: db.prepare('SELECT * FROM sessions WHERE token_hash = ?'),
|
|
162
|
+
sessIns: db.prepare('INSERT INTO sessions (token_hash, user_id, created, expires) VALUES (?, ?, ?, ?)'),
|
|
163
|
+
sessDel: db.prepare('DELETE FROM sessions WHERE token_hash = ?'),
|
|
164
|
+
sessDelUser: db.prepare('DELETE FROM sessions WHERE user_id = ?'),
|
|
165
|
+
sessDelUserExcept: db.prepare('DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?'),
|
|
166
|
+
sessDelLegacy: db.prepare('DELETE FROM sessions WHERE user_id IS NULL'),
|
|
167
|
+
sessPurge: db.prepare('DELETE FROM sessions WHERE expires < ?'),
|
|
168
|
+
pkByUser: db.prepare('SELECT id, name, created, last_used, transports FROM passkeys WHERE user_id = ? ORDER BY created'),
|
|
169
|
+
pkGet: db.prepare('SELECT * FROM passkeys WHERE id = ?'),
|
|
170
|
+
};
|
|
171
|
+
const hasUsers = () => q.userCount.get().n > 0;
|
|
172
|
+
if (hasUsers() && !process.env.TACILYTICS_ORIGIN) {
|
|
173
|
+
console.warn('⚠️ TACILYTICS_ORIGIN is not set. Passkeys use the request host, so they break if the dashboard is reached under another host/port or behind a proxy without X-Forwarded-Proto. Set it to the public dashboard URL.');
|
|
174
|
+
}
|
|
175
|
+
setInterval(() => q.sessPurge.run(Date.now()), 3600e3).unref();
|
|
176
|
+
|
|
177
|
+
// ── sessions ──
|
|
178
|
+
function createSession(userId) {
|
|
179
|
+
const token = randToken();
|
|
180
|
+
q.sessIns.run(sha256(token), userId, Date.now(), Date.now() + SESSION_DAYS * 864e5);
|
|
181
|
+
if (userId) db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(Date.now(), userId);
|
|
182
|
+
return token;
|
|
183
|
+
}
|
|
184
|
+
function principal(req) {
|
|
185
|
+
// agents / CLI: API key = owner over everything
|
|
186
|
+
if (API_KEY) {
|
|
187
|
+
const hdr = String(req.headers['authorization'] || '');
|
|
188
|
+
const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
|
|
189
|
+
if (key && safeEq(key, API_KEY)) return { kind: 'apikey', role: 'owner', sites: null, user: null };
|
|
190
|
+
}
|
|
191
|
+
const t = String(req.headers['x-session-token'] || '');
|
|
192
|
+
if (!t) return null;
|
|
193
|
+
const s = q.sessGet.get(sha256(t));
|
|
194
|
+
if (!s) return null;
|
|
195
|
+
if (s.expires < Date.now()) { q.sessDel.run(s.token_hash); return null; }
|
|
196
|
+
if (s.user_id == null) {
|
|
197
|
+
// legacy session only valid while there are no accounts
|
|
198
|
+
if (hasUsers()) { q.sessDel.run(s.token_hash); return null; }
|
|
199
|
+
return { kind: 'legacy', role: 'owner', sites: null, user: null, tokenHash: s.token_hash };
|
|
200
|
+
}
|
|
201
|
+
const u = q.userById.get(s.user_id);
|
|
202
|
+
if (!u) { q.sessDel.run(s.token_hash); return null; }
|
|
203
|
+
return { kind: 'user', role: u.role, sites: u.sites ? JSON.parse(u.sites) : null, user: u, tokenHash: s.token_hash };
|
|
204
|
+
}
|
|
205
|
+
function auth(req, res, next) {
|
|
206
|
+
const p = principal(req);
|
|
207
|
+
if (!p) return res.status(401).json({ error: 'Not authenticated' });
|
|
208
|
+
req.principal = p;
|
|
209
|
+
next();
|
|
210
|
+
}
|
|
211
|
+
const requireRole = (role) => (req, res, next) => auth(req, res, () => {
|
|
212
|
+
if (ROLES[req.principal.role] >= ROLES[role]) return next();
|
|
213
|
+
res.status(403).json({ error: 'Your role does not allow this' });
|
|
214
|
+
});
|
|
215
|
+
/** may the current principal (or share link) see this site id? */
|
|
216
|
+
function siteAllowed(req, siteId) {
|
|
217
|
+
if (req.shareSiteId != null) return req.shareSiteId === siteId;
|
|
218
|
+
const p = req.principal;
|
|
219
|
+
if (!p) return false;
|
|
220
|
+
return p.sites == null || p.sites.includes(siteId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── helpers ──
|
|
224
|
+
const cleanName = (s) => String(s || '').trim().slice(0, 40);
|
|
225
|
+
const validName = (s) => /^[A-Za-z0-9._@-]{2,40}$/.test(s);
|
|
226
|
+
function cleanSites(raw) {
|
|
227
|
+
if (raw == null) return null;
|
|
228
|
+
if (!Array.isArray(raw)) return undefined;
|
|
229
|
+
const valid = new Set(allSiteIds());
|
|
230
|
+
const ids = [...new Set(raw.map(Number).filter((n) => valid.has(n)))];
|
|
231
|
+
return ids;
|
|
232
|
+
}
|
|
233
|
+
const cleanRole = (r) => (Object.prototype.hasOwnProperty.call(ROLES, r) ? r : null);
|
|
234
|
+
function publicUser(u) {
|
|
235
|
+
return {
|
|
236
|
+
id: u.id, username: u.username, role: u.role, sites: u.sites ? JSON.parse(u.sites) : null,
|
|
237
|
+
totp: !!u.totp_secret, passkeys: q.pkByUser.all(u.id).length,
|
|
238
|
+
created: u.created, last_login: u.last_login,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function rpFor(req) {
|
|
242
|
+
// TACILYTICS_ORIGIN pins the passkey domain (recommended behind proxies). Otherwise the
|
|
243
|
+
// request host is used: a passkey signs the hash of its own rpID, so a forged Host header
|
|
244
|
+
// cannot produce a valid assertion for another domain.
|
|
245
|
+
const fixed = process.env.TACILYTICS_ORIGIN;
|
|
246
|
+
const origin = fixed ? fixed.replace(/\/$/, '') : `${req.protocol}://${req.get('host')}`;
|
|
247
|
+
return { origin, rpID: new URL(origin).hostname };
|
|
248
|
+
}
|
|
249
|
+
// short-lived in-memory state: login tickets (password ok, waiting for TOTP) + WebAuthn challenges
|
|
250
|
+
const pending = new Map();
|
|
251
|
+
const PENDING_MAX = 10000; // hard cap: /auth/passkey/options is unauthenticated, the map must not grow without bound
|
|
252
|
+
function putPending(kind, data, ms = 5 * 60000, expires = null) {
|
|
253
|
+
if (pending.size >= PENDING_MAX) {
|
|
254
|
+
const n = Date.now();
|
|
255
|
+
for (const [k, v] of pending) if (v.expires < n) pending.delete(k);
|
|
256
|
+
if (pending.size >= PENDING_MAX) return null;
|
|
257
|
+
}
|
|
258
|
+
const id = randToken(16);
|
|
259
|
+
pending.set(id, { kind, data, expires: expires || Date.now() + ms });
|
|
260
|
+
return id;
|
|
261
|
+
}
|
|
262
|
+
function takePending(id, kind) {
|
|
263
|
+
const e = pending.get(String(id || ''));
|
|
264
|
+
if (!e) return null;
|
|
265
|
+
pending.delete(String(id));
|
|
266
|
+
if (e.kind !== kind || e.expires < Date.now()) return null;
|
|
267
|
+
return { ...e.data, expires: e.expires };
|
|
268
|
+
}
|
|
269
|
+
const BUSY = (res) => res.status(503).json({ error: 'Too many sign-ins in progress — try again in a minute' });
|
|
270
|
+
const TOTP_TRIES = 5; // wrong codes per password entry; after that the password is needed again
|
|
271
|
+
setInterval(() => { const n = Date.now(); for (const [k, v] of pending) if (v.expires < n) pending.delete(k); }, 60000).unref();
|
|
272
|
+
|
|
273
|
+
function checkSecondFactor(u, code) {
|
|
274
|
+
// TOTP first, then a one-time recovery code
|
|
275
|
+
const step = totpCheck(u.totp_secret, code);
|
|
276
|
+
if (step >= 0) {
|
|
277
|
+
if (u.totp_last_step != null && step <= u.totp_last_step) return false; // replay
|
|
278
|
+
db.prepare('UPDATE users SET totp_last_step = ? WHERE id = ?').run(step, u.id);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
const rc = normRecovery(code);
|
|
282
|
+
if (rc.length !== 12) return false;
|
|
283
|
+
const list = JSON.parse(u.recovery || '[]');
|
|
284
|
+
const h = sha256(rc);
|
|
285
|
+
const i = list.findIndex((x) => safeEq(x, h));
|
|
286
|
+
if (i < 0) return false;
|
|
287
|
+
list.splice(i, 1);
|
|
288
|
+
db.prepare('UPDATE users SET recovery = ? WHERE id = ?').run(JSON.stringify(list), u.id);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── public auth endpoints ──────────────────────────────────────────────────
|
|
293
|
+
app.get('/auth/status', (_req, res) => res.json({ accounts: hasUsers() }));
|
|
294
|
+
|
|
295
|
+
app.post('/auth/login', async (req, res) => {
|
|
296
|
+
const ip = clientIp(req);
|
|
297
|
+
if (rateLimited('login:' + ip, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
298
|
+
const b = req.body || {};
|
|
299
|
+
const pw = String(b.password || '');
|
|
300
|
+
if (!hasUsers()) {
|
|
301
|
+
if (!safeEq(pw, WEB_PASSWORD)) return res.status(401).json({ error: 'Wrong password' });
|
|
302
|
+
return res.json({ token: createSession(null) });
|
|
303
|
+
}
|
|
304
|
+
const name = cleanName(b.username);
|
|
305
|
+
if (name && rateLimited('login-user:' + name.toLowerCase(), 20, 60 * 60000)) return res.status(429).json({ error: 'Too many attempts for this account — wait an hour' });
|
|
306
|
+
const u = name ? q.userByName.get(name) : null;
|
|
307
|
+
const ok = (await verifyPassword(pw, u ? u.pw_hash : DUMMY_HASH)) && !!u;
|
|
308
|
+
if (!ok) return res.status(401).json({ error: 'Wrong username or password' });
|
|
309
|
+
if (u.totp_secret) {
|
|
310
|
+
const ticket = putPending('mfa', { userId: u.id, tries: 0 });
|
|
311
|
+
return ticket ? res.json({ mfa: 'totp', ticket }) : BUSY(res);
|
|
312
|
+
}
|
|
313
|
+
res.json({ token: createSession(u.id) });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.post('/auth/login/totp', (req, res) => {
|
|
317
|
+
if (rateLimited('totp:' + clientIp(req), 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
318
|
+
const b = req.body || {};
|
|
319
|
+
const t = takePending(b.ticket, 'mfa');
|
|
320
|
+
if (!t) return res.status(401).json({ error: 'Sign-in expired — enter your password again' });
|
|
321
|
+
const u = q.userById.get(t.userId);
|
|
322
|
+
if (!u || !u.totp_secret) return res.status(401).json({ error: 'Sign-in expired — enter your password again' });
|
|
323
|
+
// per account, independent of the IP: guessing codes from many addresses does not help
|
|
324
|
+
if (rateLimited('totp-user:' + u.id, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many wrong codes for this account — wait 15 minutes' });
|
|
325
|
+
if (!checkSecondFactor(u, b.code)) {
|
|
326
|
+
const tries = (t.tries || 0) + 1;
|
|
327
|
+
if (tries >= TOTP_TRIES) return res.status(401).json({ error: 'Too many wrong codes — enter your password again' });
|
|
328
|
+
// one more try without re-entering the password; the successor keeps the original expiry and the count
|
|
329
|
+
const ticket = putPending('mfa', { userId: u.id, tries }, 0, t.expires);
|
|
330
|
+
return res.status(401).json(ticket ? { error: 'Wrong code', ticket } : { error: 'Wrong code — enter your password again' });
|
|
331
|
+
}
|
|
332
|
+
res.json({ token: createSession(u.id) });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
app.post('/auth/passkey/options', async (req, res) => {
|
|
336
|
+
if (rateLimited('pk:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
337
|
+
if (!hasUsers()) return res.status(400).json({ error: 'No accounts yet' });
|
|
338
|
+
const { rpID } = rpFor(req);
|
|
339
|
+
const options = await webauthn.generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
|
|
340
|
+
const ticket = putPending('pk-auth', { challenge: options.challenge });
|
|
341
|
+
if (!ticket) return BUSY(res);
|
|
342
|
+
res.json({ options, ticket });
|
|
343
|
+
});
|
|
344
|
+
app.post('/auth/passkey/verify', async (req, res) => {
|
|
345
|
+
if (rateLimited('pk:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
346
|
+
const b = req.body || {};
|
|
347
|
+
const t = takePending(b.ticket, 'pk-auth');
|
|
348
|
+
const fail = () => res.status(401).json({ error: 'Passkey not accepted' });
|
|
349
|
+
if (!t || !b.response || typeof b.response.id !== 'string') return fail();
|
|
350
|
+
const pk = q.pkGet.get(b.response.id);
|
|
351
|
+
if (!pk) return fail();
|
|
352
|
+
const u = q.userById.get(pk.user_id);
|
|
353
|
+
if (!u) return fail();
|
|
354
|
+
// the authenticator's user handle must belong to the same account as the credential
|
|
355
|
+
const handle = b.response.response && b.response.response.userHandle;
|
|
356
|
+
if (handle && handle !== u.webauthn_id) return fail();
|
|
357
|
+
const { origin, rpID } = rpFor(req);
|
|
358
|
+
try {
|
|
359
|
+
const v = await webauthn.verifyAuthenticationResponse({
|
|
360
|
+
response: b.response, expectedChallenge: t.challenge, expectedOrigin: origin, expectedRPID: rpID,
|
|
361
|
+
credential: { id: pk.id, publicKey: new Uint8Array(pk.public_key), counter: pk.counter, transports: pk.transports ? JSON.parse(pk.transports) : undefined },
|
|
362
|
+
requireUserVerification: false,
|
|
363
|
+
});
|
|
364
|
+
if (!v.verified) return fail();
|
|
365
|
+
db.prepare('UPDATE passkeys SET counter = ?, last_used = ? WHERE id = ?').run(v.authenticationInfo.newCounter, Date.now(), pk.id);
|
|
366
|
+
} catch { return fail(); }
|
|
367
|
+
// a passkey is possession + (usually) biometrics: it replaces password and TOTP
|
|
368
|
+
res.json({ token: createSession(u.id) });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
app.post('/auth/logout', (req, res) => {
|
|
372
|
+
const t = String(req.headers['x-session-token'] || '');
|
|
373
|
+
if (t) q.sessDel.run(sha256(t));
|
|
374
|
+
res.json({ ok: true });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ── invites (public part) ──
|
|
378
|
+
function inviteByToken(token) {
|
|
379
|
+
const inv = db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(sha256(String(token || '')));
|
|
380
|
+
if (!inv || inv.used_at || inv.expires < Date.now()) return null;
|
|
381
|
+
return inv;
|
|
382
|
+
}
|
|
383
|
+
app.get('/api/invite/:token', (req, res) => {
|
|
384
|
+
if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
385
|
+
const inv = inviteByToken(req.params.token);
|
|
386
|
+
if (!inv) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
|
|
387
|
+
res.json({ role: inv.role, expires: inv.expires });
|
|
388
|
+
});
|
|
389
|
+
app.post('/api/invite/:token', async (req, res) => {
|
|
390
|
+
if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
391
|
+
const b = req.body || {};
|
|
392
|
+
const name = cleanName(b.username), pw = String(b.password || '');
|
|
393
|
+
if (!validName(name)) return res.status(400).json({ error: 'Username: 2–40 characters, letters, digits, . _ @ -' });
|
|
394
|
+
if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
395
|
+
if (q.userByName.get(name)) return res.status(409).json({ error: 'This username is taken' });
|
|
396
|
+
const pwHash = await hashPassword(pw);
|
|
397
|
+
const tx = db.transaction(() => {
|
|
398
|
+
const inv = inviteByToken(req.params.token);
|
|
399
|
+
if (!inv) return null;
|
|
400
|
+
if (q.userByName.get(name)) return 'taken'; // re-checked inside the transaction (two sign-ups racing for one name)
|
|
401
|
+
const info = q.insUser.run(name, pwHash, inv.role, inv.sites, b64u(crypto.randomBytes(16)), Date.now());
|
|
402
|
+
db.prepare('UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?').run(Date.now(), info.lastInsertRowid, inv.id);
|
|
403
|
+
return info.lastInsertRowid;
|
|
404
|
+
});
|
|
405
|
+
const uid = tx();
|
|
406
|
+
if (uid === 'taken') return res.status(409).json({ error: 'This username is taken' });
|
|
407
|
+
if (!uid) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
|
|
408
|
+
res.json({ token: createSession(uid) });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ── me ─────────────────────────────────────────────────────────────────────
|
|
412
|
+
app.get('/api/me', auth, (req, res) => {
|
|
413
|
+
const p = req.principal;
|
|
414
|
+
const out = { kind: p.kind, role: p.role, accounts: hasUsers() };
|
|
415
|
+
if (p.user) {
|
|
416
|
+
const u = q.userById.get(p.user.id);
|
|
417
|
+
Object.assign(out, publicUser(u), {
|
|
418
|
+
recoveryLeft: JSON.parse(u.recovery || '[]').length,
|
|
419
|
+
passkeyList: q.pkByUser.all(u.id).map((k) => ({ id: k.id, name: k.name, created: k.created, last_used: k.last_used })),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
res.json(out);
|
|
423
|
+
});
|
|
424
|
+
const userOnly = (req, res, next) => (req.principal.user ? next() : res.status(400).json({ error: 'Create an account first' }));
|
|
425
|
+
async function needPassword(req, res) {
|
|
426
|
+
if (rateLimited('reauth:' + req.principal.user.id, 10, 15 * 60000)) { res.status(429).json({ error: 'Too many attempts — wait 15 minutes' }); return false; }
|
|
427
|
+
const u = q.userById.get(req.principal.user.id);
|
|
428
|
+
if (!(await verifyPassword(String((req.body || {}).password || ''), u.pw_hash))) { res.status(401).json({ error: 'Wrong password' }); return false; }
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
app.post('/api/me/password', auth, userOnly, async (req, res) => {
|
|
433
|
+
if (!(await needPassword(req, res))) return;
|
|
434
|
+
const npw = String((req.body || {}).newPassword || '');
|
|
435
|
+
if (npw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
436
|
+
db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(await hashPassword(npw), req.principal.user.id);
|
|
437
|
+
q.sessDelUserExcept.run(req.principal.user.id, req.principal.tokenHash); // sign out other devices
|
|
438
|
+
res.json({ ok: true });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
app.post('/api/me/totp/setup', auth, userOnly, async (req, res) => {
|
|
442
|
+
const u = q.userById.get(req.principal.user.id);
|
|
443
|
+
if (u.totp_secret) return res.status(400).json({ error: '2FA is already on' });
|
|
444
|
+
const secret = base32Encode(crypto.randomBytes(20));
|
|
445
|
+
db.prepare('UPDATE users SET totp_pending = ? WHERE id = ?').run(secret, u.id);
|
|
446
|
+
const { rpID } = rpFor(req);
|
|
447
|
+
const label = encodeURIComponent(`Tacilytics (${rpID}):${u.username}`);
|
|
448
|
+
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent('Tacilytics')}&algorithm=SHA1&digits=6&period=30`;
|
|
449
|
+
const svg = await QRCode.toString(uri, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
|
450
|
+
res.json({ secret, uri, qr: 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64') });
|
|
451
|
+
});
|
|
452
|
+
app.post('/api/me/totp/enable', auth, userOnly, (req, res) => {
|
|
453
|
+
if (rateLimited('totp-en:' + req.principal.user.id, 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
454
|
+
const u = q.userById.get(req.principal.user.id);
|
|
455
|
+
if (u.totp_secret) return res.status(400).json({ error: '2FA is already on' });
|
|
456
|
+
if (!u.totp_pending) return res.status(400).json({ error: 'Start the setup first' });
|
|
457
|
+
const step = totpCheck(u.totp_pending, (req.body || {}).code);
|
|
458
|
+
if (step < 0) return res.status(401).json({ error: 'Wrong code — check the time on your phone' });
|
|
459
|
+
const codes = newRecoveryCodes();
|
|
460
|
+
db.prepare('UPDATE users SET totp_secret = totp_pending, totp_pending = NULL, totp_last_step = ?, recovery = ? WHERE id = ?')
|
|
461
|
+
.run(step, JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
|
|
462
|
+
q.sessDelUserExcept.run(u.id, req.principal.tokenHash);
|
|
463
|
+
res.json({ ok: true, recoveryCodes: codes });
|
|
464
|
+
});
|
|
465
|
+
app.post('/api/me/totp/disable', auth, userOnly, async (req, res) => {
|
|
466
|
+
if (!(await needPassword(req, res))) return;
|
|
467
|
+
db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(req.principal.user.id);
|
|
468
|
+
res.json({ ok: true });
|
|
469
|
+
});
|
|
470
|
+
app.post('/api/me/recovery', auth, userOnly, async (req, res) => {
|
|
471
|
+
if (!(await needPassword(req, res))) return;
|
|
472
|
+
const u = q.userById.get(req.principal.user.id);
|
|
473
|
+
if (!u.totp_secret) return res.status(400).json({ error: '2FA is off' });
|
|
474
|
+
const codes = newRecoveryCodes();
|
|
475
|
+
db.prepare('UPDATE users SET recovery = ? WHERE id = ?').run(JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
|
|
476
|
+
res.json({ recoveryCodes: codes });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
app.post('/api/me/passkeys/options', auth, userOnly, async (req, res) => {
|
|
480
|
+
const u = q.userById.get(req.principal.user.id);
|
|
481
|
+
const { rpID } = rpFor(req);
|
|
482
|
+
const existing = db.prepare('SELECT id, transports FROM passkeys WHERE user_id = ?').all(u.id);
|
|
483
|
+
const options = await webauthn.generateRegistrationOptions({
|
|
484
|
+
rpName: 'Tacilytics', rpID, userName: u.username, userID: fromB64u(u.webauthn_id),
|
|
485
|
+
attestationType: 'none',
|
|
486
|
+
excludeCredentials: existing.map((k) => ({ id: k.id, transports: k.transports ? JSON.parse(k.transports) : undefined })),
|
|
487
|
+
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
|
488
|
+
});
|
|
489
|
+
const ticket = putPending('pk-reg', { challenge: options.challenge, userId: u.id });
|
|
490
|
+
if (!ticket) return BUSY(res);
|
|
491
|
+
res.json({ options, ticket });
|
|
492
|
+
});
|
|
493
|
+
app.post('/api/me/passkeys', auth, userOnly, async (req, res) => {
|
|
494
|
+
const b = req.body || {};
|
|
495
|
+
const t = takePending(b.ticket, 'pk-reg');
|
|
496
|
+
if (!t || t.userId !== req.principal.user.id) return res.status(400).json({ error: 'Setup expired — try again' });
|
|
497
|
+
const { origin, rpID } = rpFor(req);
|
|
498
|
+
let v;
|
|
499
|
+
try {
|
|
500
|
+
v = await webauthn.verifyRegistrationResponse({ response: b.response, expectedChallenge: t.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false });
|
|
501
|
+
} catch (e) { return res.status(400).json({ error: 'Passkey not accepted: ' + String(e.message || e).slice(0, 120) }); }
|
|
502
|
+
if (!v.verified) return res.status(400).json({ error: 'Passkey not accepted' });
|
|
503
|
+
const c = v.registrationInfo.credential;
|
|
504
|
+
if (q.pkGet.get(c.id)) return res.status(409).json({ error: 'This passkey is already registered' });
|
|
505
|
+
const name = String(b.name || '').trim().slice(0, 60) || 'Passkey';
|
|
506
|
+
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
507
|
+
.run(c.id, t.userId, Buffer.from(c.publicKey), c.counter || 0, c.transports ? JSON.stringify(c.transports) : null, name, Date.now());
|
|
508
|
+
res.json({ ok: true });
|
|
509
|
+
});
|
|
510
|
+
app.delete('/api/me/passkeys/:id', auth, userOnly, (req, res) => {
|
|
511
|
+
db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(String(req.params.id), req.principal.user.id);
|
|
512
|
+
res.json({ ok: true });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ── team (owner) ───────────────────────────────────────────────────────────
|
|
516
|
+
const ownerOnly = requireRole('owner');
|
|
517
|
+
|
|
518
|
+
// first account: only from a legacy (WEB_PASSWORD) or API-key session, only while there are none
|
|
519
|
+
app.post('/api/team/bootstrap', ownerOnly, async (req, res) => {
|
|
520
|
+
if (hasUsers()) return res.status(409).json({ error: 'Accounts already exist' });
|
|
521
|
+
const b = req.body || {};
|
|
522
|
+
const name = cleanName(b.username), pw = String(b.password || '');
|
|
523
|
+
if (!validName(name)) return res.status(400).json({ error: 'Username: 2–40 characters, letters, digits, . _ @ -' });
|
|
524
|
+
if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
525
|
+
const info = q.insUser.run(name, await hashPassword(pw), 'owner', null, b64u(crypto.randomBytes(16)), Date.now());
|
|
526
|
+
q.sessDelLegacy.run(); // WEB_PASSWORD sessions end here
|
|
527
|
+
res.json({ token: createSession(info.lastInsertRowid) });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
app.get('/api/team', ownerOnly, (_req, res) => {
|
|
531
|
+
const users = db.prepare('SELECT * FROM users ORDER BY created').all().map(publicUser);
|
|
532
|
+
const invites = db.prepare('SELECT id, role, sites, created, expires FROM invites WHERE used_at IS NULL AND expires > ? ORDER BY created DESC').all(Date.now())
|
|
533
|
+
.map((i) => ({ ...i, sites: i.sites ? JSON.parse(i.sites) : null }));
|
|
534
|
+
res.json({ users, invites });
|
|
535
|
+
});
|
|
536
|
+
app.post('/api/team/invites', ownerOnly, (req, res) => {
|
|
537
|
+
if (!hasUsers()) return res.status(400).json({ error: 'Create your own account first' });
|
|
538
|
+
const b = req.body || {};
|
|
539
|
+
const role = cleanRole(b.role);
|
|
540
|
+
if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
|
|
541
|
+
const sites = role === 'viewer' ? cleanSites(b.sites) : null;
|
|
542
|
+
if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
|
|
543
|
+
if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
|
|
544
|
+
const token = randToken();
|
|
545
|
+
const expires = Date.now() + INVITE_DAYS * 864e5;
|
|
546
|
+
db.prepare('INSERT INTO invites (token_hash, role, sites, created_by, created, expires) VALUES (?, ?, ?, ?, ?, ?)')
|
|
547
|
+
.run(sha256(token), role, sites ? JSON.stringify(sites) : null, req.principal.user ? req.principal.user.id : null, Date.now(), expires);
|
|
548
|
+
res.json({ url: '/invite/' + token, role, expires });
|
|
549
|
+
});
|
|
550
|
+
app.delete('/api/team/invites/:id', ownerOnly, (req, res) => {
|
|
551
|
+
db.prepare('DELETE FROM invites WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
|
|
552
|
+
res.json({ ok: true });
|
|
553
|
+
});
|
|
554
|
+
app.patch('/api/team/users/:id', ownerOnly, (req, res) => {
|
|
555
|
+
const u = q.userById.get(Number(req.params.id));
|
|
556
|
+
if (!u) return res.status(404).json({ error: 'Unknown user' });
|
|
557
|
+
const b = req.body || {};
|
|
558
|
+
const role = b.role === undefined ? u.role : cleanRole(b.role);
|
|
559
|
+
if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
|
|
560
|
+
if (u.role === 'owner' && role !== 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
|
|
561
|
+
let sites = role === 'viewer' ? (b.sites === undefined ? (u.sites ? JSON.parse(u.sites) : null) : cleanSites(b.sites)) : null;
|
|
562
|
+
if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
|
|
563
|
+
if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
|
|
564
|
+
db.prepare('UPDATE users SET role = ?, sites = ? WHERE id = ?').run(role, sites ? JSON.stringify(sites) : null, u.id);
|
|
565
|
+
res.json(publicUser(q.userById.get(u.id)));
|
|
566
|
+
});
|
|
567
|
+
app.delete('/api/team/users/:id', ownerOnly, (req, res) => {
|
|
568
|
+
const u = q.userById.get(Number(req.params.id));
|
|
569
|
+
if (!u) return res.status(404).json({ error: 'Unknown user' });
|
|
570
|
+
if (u.role === 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
|
|
571
|
+
db.transaction(() => {
|
|
572
|
+
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(u.id);
|
|
573
|
+
q.sessDelUser.run(u.id);
|
|
574
|
+
db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
|
|
575
|
+
})();
|
|
576
|
+
res.json({ ok: true });
|
|
577
|
+
});
|
|
578
|
+
// owner can reset 2FA of a teammate who lost their phone (the teammate then sets it up again)
|
|
579
|
+
app.post('/api/team/users/:id/reset-2fa', ownerOnly, (req, res) => {
|
|
580
|
+
const u = q.userById.get(Number(req.params.id));
|
|
581
|
+
if (!u) return res.status(404).json({ error: 'Unknown user' });
|
|
582
|
+
db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(u.id);
|
|
583
|
+
q.sessDelUser.run(u.id);
|
|
584
|
+
res.json({ ok: true });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return { auth, requireRole, siteAllowed, principal };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
|
|
591
|
+
function adminOps(db) {
|
|
592
|
+
const has = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
|
|
593
|
+
if (!has) return { list: () => [], reset: () => { throw new Error('No accounts yet'); } };
|
|
594
|
+
return {
|
|
595
|
+
list: () => db.prepare('SELECT id, username, role, totp_secret IS NOT NULL AS totp, last_login FROM users ORDER BY id').all(),
|
|
596
|
+
/** new random password, 2FA off, all sessions of that user ended. Returns the password. */
|
|
597
|
+
reset(username) {
|
|
598
|
+
const u = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(String(username));
|
|
599
|
+
if (!u) throw new Error('Unknown user: ' + username);
|
|
600
|
+
const pw = crypto.randomBytes(12).toString('base64url');
|
|
601
|
+
db.prepare('UPDATE users SET pw_hash = ?, totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(hashPasswordSync(pw), u.id);
|
|
602
|
+
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
|
|
603
|
+
return pw;
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
module.exports = { setupAuth, adminOps, _test: { hashPassword, hashPasswordSync, verifyPassword, totpAt, totpCheck, base32Encode, base32Decode, newRecoveryCodes, normRecovery } };
|
package/cli.js
CHANGED
|
@@ -137,6 +137,37 @@ async function cmdAdd() {
|
|
|
137
137
|
console.log(' ' + cyan(`<script defer data-domain="${s.domain}" src="${URL_BASE}/tacilytics.js"></script>`));
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// ── offline admin: opens the same SQLite file the server uses (same .env + DB_PATH rules) ──
|
|
141
|
+
function cmdLocalAdmin() {
|
|
142
|
+
const fs = require('fs'), path = require('path');
|
|
143
|
+
for (const dir of [...new Set([process.cwd(), __dirname])]) {
|
|
144
|
+
try {
|
|
145
|
+
for (const line of fs.readFileSync(path.join(dir, '.env'), 'utf8').split('\n')) {
|
|
146
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
147
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
const inside = __dirname.split(path.sep).includes('node_modules');
|
|
152
|
+
const dbPath = process.env.DB_PATH || path.join(inside ? process.cwd() : __dirname, 'data', 'analytics.db');
|
|
153
|
+
if (!fs.existsSync(dbPath)) die('No database at ' + dbPath + ' (set DB_PATH).');
|
|
154
|
+
const db = new (require('better-sqlite3'))(dbPath);
|
|
155
|
+
const ops = require('./auth').adminOps(db);
|
|
156
|
+
if (cmd === 'users') {
|
|
157
|
+
const rows = ops.list();
|
|
158
|
+
if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
|
|
159
|
+
if (!rows.length) return console.log(dim('No accounts yet — the dashboard still uses WEB_PASSWORD.'));
|
|
160
|
+
for (const u of rows) console.log(` ${pad(u.username, 24)} ${pad(u.role, 7)} ${u.totp ? green('2FA') : dim('no 2FA')} ${dim(u.last_login ? 'last login ' + new Date(u.last_login).toISOString().slice(0, 16).replace('T', ' ') : 'never signed in')}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const name = pos[1];
|
|
164
|
+
if (!name) die('Usage: tacilytics reset-login <username>');
|
|
165
|
+
const pw = ops.reset(name);
|
|
166
|
+
console.log(green('✔ ') + `${name}: 2FA turned off, all sessions ended. Passkeys stay registered.`);
|
|
167
|
+
console.log('New password: ' + pw);
|
|
168
|
+
console.log(dim('Sign in with it, then change it and turn 2FA on again under Settings → Security.'));
|
|
169
|
+
}
|
|
170
|
+
|
|
140
171
|
function help() {
|
|
141
172
|
console.log(`
|
|
142
173
|
${pink('Tacilytics CLI')}
|
|
@@ -149,6 +180,10 @@ ${bold('Usage')} tacilytics <command> [--json] [--watch]
|
|
|
149
180
|
${cyan('live')} <domain> [--watch] online visitors + last-30-min feed
|
|
150
181
|
${cyan('add')} <domain> add a site, print the embed snippet
|
|
151
182
|
|
|
183
|
+
${bold('On the server')} (reads the database directly, no API key)
|
|
184
|
+
${cyan('users')} list accounts
|
|
185
|
+
${cyan('reset-login')} <username> locked out? new password, 2FA off, sessions ended
|
|
186
|
+
|
|
152
187
|
${bold('Segment & range')} (on stats)
|
|
153
188
|
${dim('--country=DE --source=… --page=… --browser=… --medium=… --campaign=… --goal=…')}
|
|
154
189
|
${dim('--from=2026-01-01 --to=2026-01-31')} custom date range
|
|
@@ -164,6 +199,7 @@ ${dim('Add --json for machine-readable output (agents).')}
|
|
|
164
199
|
(async () => {
|
|
165
200
|
try {
|
|
166
201
|
if (cmd === 'serve' || cmd === 'start') { require('./server.js'); return; }
|
|
202
|
+
if (cmd === 'users' || cmd === 'reset-login') return cmdLocalAdmin();
|
|
167
203
|
if (cmd === 'sites') await cmdSites();
|
|
168
204
|
else if (cmd === 'stats') await cmdStats();
|
|
169
205
|
else if (cmd === 'live' || cmd === 'realtime') await cmdLive();
|