TOTP + Pushover

This commit is contained in:
2026-01-27 15:46:54 +00:00
parent 6dfaed34b8
commit abbfe65206
18 changed files with 1923 additions and 1622 deletions
+92
View File
@@ -10,10 +10,88 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"otplib": "^13.2.1",
"undici": "^6.18.1",
"ws": "^8.18.0"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@otplib/core": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz",
"integrity": "sha512-IyfHvYNCyipDxhmJdcUUvUeT3Hz84/GgM6G2G6BTEmnAKPzNA7U0kYGkxKZWY9h23W94RJk4qiClJRJN5zKGvg==",
"license": "MIT"
},
"node_modules/@otplib/hotp": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.2.1.tgz",
"integrity": "sha512-iRKqvj0TnemtXXtEswzBX50Z0yMNa0lH9PSdr5N4CJc1mDEuUmFFZQqnu3PfA3fPd3WeAU+mHgmK/xq18+K1QA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/@otplib/plugin-base32-scure": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.2.1.tgz",
"integrity": "sha512-vnA2qqgJ/FbFbDNGOLAS8dKfCsJFXwFsZKYklE8yl2INkCOUR0vbVdJ2TVmufzC8R1RRZHW+cDR20ACgc9XFYg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@scure/base": "^2.0.0"
}
},
"node_modules/@otplib/plugin-crypto-noble": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.2.1.tgz",
"integrity": "sha512-Dxjmt4L+5eDWJf5EvbcMp+fxcliyKoB9N9sNQq/vuVAUvq+KiqpiiCQZ/wHyrN0ArB0NdevtK1KByyAq080ldg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1",
"@otplib/core": "13.2.1"
}
},
"node_modules/@otplib/totp": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.2.1.tgz",
"integrity": "sha512-LzDzAAK3w8rspF3urBnWjOlxso1SCGxX9Pnu/iy+HkC0y0HgiLsW7jhkr2hJ3u4cyBdL/tOKUhhELwsjyvunwQ==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/hotp": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/@otplib/uri": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.2.1.tgz",
"integrity": "sha512-ssYnfiUrFTs/rPRUW8h59m0MVLYOC+UKk7tVGYgtG15lLaLBrNBQjM2YFanuzn9Jm4iv9JxiNG7TRkwcnyR09A==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -578,6 +656,20 @@
"node": ">= 0.8"
}
},
"node_modules/otplib": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.2.1.tgz",
"integrity": "sha512-Cft9h/m34LtvnoB2TjP1E1E6v0biwcUntl6U4e+HgWrTa0bpwmb+u/D9gLFA+U6/ztlvrult0811Bu30nUVUuA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/hotp": "13.2.1",
"@otplib/plugin-base32-scure": "13.2.1",
"@otplib/plugin-crypto-noble": "13.2.1",
"@otplib/totp": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"otplib": "^13.2.1",
"undici": "^6.18.1",
"ws": "^8.18.0"
}
+230 -4
View File
@@ -7,6 +7,17 @@ import { addServer, applyServerLayout, deleteServer, readServers, setPoolsCollap
import { detectServerType } from './detect.js';
import { readAlertsConfig, writeAlertsConfig } from './store/alerts.js';
import { evaluateAndSendAlerts, sendTestAlert } from './alerts/evaluate.js';
import { createInstanceId } from './store/instance.js';
import {
clearPendingTotp,
clearSessions,
createSession,
readTotpConfig,
setPendingTotp,
validateSession,
writeTotpConfig
} from './store/totp.js';
import { generateSecret, generateURI, verifySync } from 'otplib';
const app = express();
const port = process.env.PORT || 5001;
@@ -14,10 +25,43 @@ const port = process.env.PORT || 5001;
const STORAGE_CACHE_TTL_MS = 15000;
let storageCache = null;
let storageRefresh = null;
let instanceMeta = { instanceId: 'unknown', startedAt: null };
app.use(cors());
app.use(express.json());
const TOTP_OPEN_PATHS = new Set([
'/api/health',
'/api/totp',
'/api/totp/setup',
'/api/totp/activate',
'/api/totp/verify',
'/api/totp/disable',
'/api/totp/regenerate'
]);
app.use(async (req, res, next) => {
if (!req.path.startsWith('/api')) {
next();
return;
}
const totp = await readTotpConfig();
if (!totp.enabled || TOTP_OPEN_PATHS.has(req.path)) {
next();
return;
}
const token = String(req.headers['x-vault-session'] || '').trim();
const { valid } = await validateSession(token);
if (valid) {
next();
return;
}
res.status(401).json({ error: 'TOTP required.', code: 'TOTP_REQUIRED' });
});
const normalizeType = (type) => {
if (!type) {
return null;
@@ -53,7 +97,14 @@ const resolveStorageForServer = async (server) => {
};
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'vault', uptime: process.uptime(), timestamp: new Date().toISOString() });
res.json({
ok: true,
service: 'vault',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
startedAt: instanceMeta.startedAt
});
});
app.get('/api/servers', async (_req, res) => {
@@ -283,12 +334,178 @@ app.post('/api/alerts/test', async (_req, res) => {
}
});
app.get('/api/totp', async (_req, res) => {
const config = await readTotpConfig();
res.json({
totp: {
enabled: Boolean(config.enabled),
issuer: config.issuer,
label: config.label,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
hasSecret: Boolean(config.secret),
pending: Boolean(config.pendingSecret),
pendingCreatedAt: config.pendingCreatedAt,
pendingOtpauthUrl: config.pendingSecret ? config.pendingOtpauthUrl || '' : ''
}
});
});
app.post('/api/totp/setup', async (_req, res) => {
const current = await readTotpConfig();
const issuer = current.issuer || 'Vault';
const label = current.label || 'vault';
const secret = generateSecret();
const otpauthUrl = generateURI({ secret, issuer, label });
const updated = await setPendingTotp({ secret, otpauthUrl });
res.json({
totp: {
enabled: Boolean(updated.enabled),
issuer: updated.issuer,
label: updated.label,
pending: true,
pendingCreatedAt: updated.pendingCreatedAt,
pendingOtpauthUrl: updated.pendingOtpauthUrl || '',
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/regenerate', async (_req, res) => {
const current = await readTotpConfig();
const issuer = current.issuer || 'Vault';
const label = current.label || 'vault';
const secret = generateSecret();
const otpauthUrl = generateURI({ secret, issuer, label });
const updated = await setPendingTotp({ secret, otpauthUrl });
res.json({
totp: {
enabled: Boolean(updated.enabled),
issuer: updated.issuer,
label: updated.label,
pending: true,
pendingCreatedAt: updated.pendingCreatedAt,
pendingOtpauthUrl: updated.pendingOtpauthUrl || '',
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/disable', async (req, res) => {
const { code } = req.body ?? {};
const current = await readTotpConfig();
if (current.enabled && current.secret) {
const token = String(code || '').replace(/\s+/g, '');
const valid = token ? verifySync({ token, secret: current.secret }) : false;
if (!valid) {
res.status(400).json({ error: 'Valid TOTP code required to disable.' });
return;
}
}
await clearSessions();
await clearPendingTotp();
const updated = await writeTotpConfig({
enabled: false,
secret: '',
otpauthUrl: ''
});
res.json({
totp: {
enabled: false,
issuer: updated.issuer,
label: updated.label,
pending: false,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/activate', async (req, res) => {
const { code, rememberDays } = req.body ?? {};
const current = await readTotpConfig();
if (!current.pendingSecret) {
res.status(400).json({ error: 'No pending TOTP setup found.' });
return;
}
const token = String(code || '').replace(/\s+/g, '');
if (!token) {
res.status(400).json({ error: 'code is required.' });
return;
}
const valid = verifySync({ token, secret: current.pendingSecret });
if (!valid) {
res.status(400).json({ error: 'Invalid TOTP code.' });
return;
}
await clearSessions();
const updated = await writeTotpConfig({
enabled: true,
secret: current.pendingSecret,
otpauthUrl: current.pendingOtpauthUrl || ''
});
await clearPendingTotp();
const ttlDays = Math.max(0, Math.min(365, Number(rememberDays) || 0));
const userAgent = String(req.headers['user-agent'] || 'device').slice(0, 80);
const session = await createSession({ ttlDays: ttlDays || 1, label: userAgent });
res.json({
ok: true,
sessionToken: session.token,
expiresAt: session.expiresAt,
totp: {
enabled: true,
issuer: updated.issuer,
label: updated.label,
pending: false,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/verify', async (req, res) => {
const { code, rememberDays } = req.body ?? {};
const totp = await readTotpConfig();
if (!totp.enabled || !totp.secret) {
res.status(400).json({ error: 'TOTP is not configured.' });
return;
}
const token = String(code || '').replace(/\s+/g, '');
if (!token) {
res.status(400).json({ error: 'code is required.' });
return;
}
const valid = verifySync({ token, secret: totp.secret });
if (!valid) {
res.status(400).json({ error: 'Invalid TOTP code.' });
return;
}
const ttlDays = Math.max(0, Math.min(365, Number(rememberDays) || 0));
const userAgent = String(req.headers['user-agent'] || 'device').slice(0, 80);
const session = await createSession({ ttlDays: ttlDays || 1, label: userAgent });
res.json({
ok: true,
sessionToken: session.token,
expiresAt: session.expiresAt
});
});
const buildStoragePayload = async () => {
const stored = await readServers();
const configured = stored.filter((server) => server.state === 'configured');
if (configured.length === 0) {
return { source: 'empty', generatedAt: new Date().toISOString(), servers: [] };
return {
source: 'empty',
generatedAt: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
servers: []
};
}
const results = await Promise.allSettled(configured.map((server) => resolveStorageForServer(server)));
@@ -324,6 +541,7 @@ const buildStoragePayload = async () => {
return {
source: errors.length ? 'error' : 'live',
generatedAt: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
servers,
errors
};
@@ -385,6 +603,14 @@ app.get('/api/storage', async (_req, res) => {
}
});
app.listen(port, () => {
console.log(`Vault backend listening on port ${port}`);
const start = async () => {
instanceMeta = await createInstanceId();
app.listen(port, () => {
console.log(`Vault backend listening on port ${port} (instance ${instanceMeta.instanceId})`);
});
};
start().catch((error) => {
console.error('Failed to start backend:', error.message || error);
process.exit(1);
});
+5 -2
View File
@@ -1,7 +1,11 @@
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const dataDir = path.resolve(process.cwd(), 'data');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'alerts.json');
const DEFAULT_ALERTS = {
@@ -63,4 +67,3 @@ export async function writeAlertsConfig(patch) {
}
export { DEFAULT_ALERTS };
+40
View File
@@ -0,0 +1,40 @@
import { promises as fs } from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'instance.json');
async function ensureDir() {
await fs.mkdir(dataDir, { recursive: true });
}
export async function createInstanceId() {
await ensureDir();
const instanceId = randomUUID();
const payload = {
instanceId,
startedAt: new Date().toISOString()
};
await fs.writeFile(dataFile, JSON.stringify(payload, null, 2), 'utf-8');
return payload;
}
export async function readInstanceId() {
await ensureDir();
try {
const raw = await fs.readFile(dataFile, 'utf-8');
const parsed = JSON.parse(raw);
if (parsed?.instanceId) {
return parsed;
}
} catch {
// ignore and recreate below
}
return createInstanceId();
}
+5 -1
View File
@@ -1,8 +1,12 @@
import { promises as fs } from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
const dataDir = path.resolve(process.cwd(), 'data');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'servers.json');
async function ensureStore() {
+139
View File
@@ -0,0 +1,139 @@
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { randomUUID, randomBytes } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'totp.json');
const DEFAULT_TOTP = {
enabled: false,
secret: '',
otpauthUrl: '',
issuer: 'Vault',
label: 'vault',
pendingSecret: '',
pendingOtpauthUrl: '',
pendingCreatedAt: null,
sessions: [],
createdAt: null,
updatedAt: null
};
async function ensureStore() {
await fs.mkdir(dataDir, { recursive: true });
try {
await fs.access(dataFile);
} catch {
await fs.writeFile(dataFile, JSON.stringify(DEFAULT_TOTP, null, 2), 'utf-8');
}
}
export async function readTotpConfig() {
await ensureStore();
try {
const raw = await fs.readFile(dataFile, 'utf-8');
const parsed = JSON.parse(raw);
return {
...DEFAULT_TOTP,
...parsed
};
} catch {
return { ...DEFAULT_TOTP };
}
}
export async function writeTotpConfig(patch) {
const current = await readTotpConfig();
const timestamp = new Date().toISOString();
const next = {
...current,
...patch,
updatedAt: timestamp
};
if (!current.createdAt) {
next.createdAt = timestamp;
}
await fs.writeFile(dataFile, JSON.stringify(next, null, 2), 'utf-8');
return next;
}
const normalizeSessions = (sessions) => {
if (!Array.isArray(sessions)) {
return [];
}
const now = Date.now();
return sessions.filter((session) => {
const expiresAt = new Date(session.expiresAt || 0).getTime();
return Number.isFinite(expiresAt) && expiresAt > now;
});
};
const withPrunedSessions = async () => {
const config = await readTotpConfig();
const pruned = normalizeSessions(config.sessions);
if (pruned.length === (config.sessions || []).length) {
return { config, sessions: pruned };
}
const updated = await writeTotpConfig({ sessions: pruned });
return { config: updated, sessions: pruned };
};
const createToken = () => {
const raw = randomBytes(24).toString('base64url');
return `vault_${raw}`;
};
export async function createSession({ ttlDays = 30, label = 'device' } = {}) {
const { sessions } = await withPrunedSessions();
const now = Date.now();
const ttlMs = Math.max(1, Number(ttlDays) || 30) * 24 * 60 * 60 * 1000;
const expiresAt = new Date(now + ttlMs).toISOString();
const session = {
id: randomUUID(),
token: createToken(),
label,
createdAt: new Date(now).toISOString(),
expiresAt
};
const nextSessions = [...sessions, session];
await writeTotpConfig({ sessions: nextSessions });
return session;
}
export async function validateSession(token) {
if (!token) {
return { valid: false, session: null };
}
const { sessions } = await withPrunedSessions();
const match = sessions.find((session) => session.token === token) || null;
return { valid: Boolean(match), session: match };
}
export async function clearSessions() {
const updated = await writeTotpConfig({ sessions: [] });
return updated.sessions || [];
}
export async function setPendingTotp({ secret, otpauthUrl }) {
const updated = await writeTotpConfig({
pendingSecret: secret,
pendingOtpauthUrl: otpauthUrl,
pendingCreatedAt: new Date().toISOString()
});
return updated;
}
export async function clearPendingTotp() {
const updated = await writeTotpConfig({
pendingSecret: '',
pendingOtpauthUrl: '',
pendingCreatedAt: null
});
return updated;
}
export { DEFAULT_TOTP };