TOTP + Pushover
This commit is contained in:
Generated
+92
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user