feat: 2FA/TOTP, config templates (v0.11.4 Beta)
- 2FA: TOTP setup wizard in Settings, QR code enrollment, login second-step, password-confirmed disable - Config templates: reusable DNS/NTP/syslog config sets pushed to selected devices via RouterOS API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
49ab1b002e
commit
a1ca4d0a44
+108
-1
@@ -1,9 +1,14 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as qrcode from 'qrcode';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULT_JWT_SECRET = 'changeme';
|
||||
@@ -47,16 +52,118 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
|
||||
totp_enabled: boolean;
|
||||
}>(`SELECT id, username, password_hash, role, totp_enabled FROM users WHERE username = $1`, [username]);
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (user.totp_enabled) {
|
||||
const totpToken = jwt.sign({ userId: user.id, partial: true }, JWT_SECRET, { expiresIn: '5m' });
|
||||
return res.json({ requires_totp: true, totp_token: totpToken });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// Exchange partial TOTP token + code for a full session token
|
||||
router.post('/totp/verify', async (req: Request, res: Response) => {
|
||||
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
|
||||
if (!totp_token || !code) {
|
||||
return res.status(400).json({ error: 'totp_token and code are required' });
|
||||
}
|
||||
let payload: { userId: number; partial: boolean };
|
||||
try {
|
||||
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
|
||||
}
|
||||
if (!payload.partial) {
|
||||
return res.status(401).json({ error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ id: number; username: string; role: string; totp_secret: string | null; totp_enabled: boolean }>(
|
||||
`SELECT id, username, role, totp_secret, totp_enabled FROM users WHERE id = $1`,
|
||||
[payload.userId]
|
||||
);
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'TOTP not configured' });
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) {
|
||||
return res.status(401).json({ error: 'Invalid TOTP code' });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// Generate a new TOTP secret + QR code for the current user (does not enable yet)
|
||||
router.post('/totp/setup', requireAuth, async (req: Request, res: Response) => {
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'MikroTik Manager',
|
||||
label: req.user!.username,
|
||||
secret,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
const uri = totp.toString();
|
||||
const qrDataUrl = await qrcode.toDataURL(uri);
|
||||
// Store the pending secret temporarily on the user row (not yet enabled)
|
||||
await query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret.base32, req.user!.userId]);
|
||||
return res.json({ secret: secret.base32, uri, qr: qrDataUrl });
|
||||
});
|
||||
|
||||
// Confirm the TOTP setup by validating a code — enables TOTP
|
||||
router.post('/totp/confirm', requireAuth, async (req: Request, res: Response) => {
|
||||
const { code } = req.body as { code?: string };
|
||||
if (!code) return res.status(400).json({ error: 'code is required' });
|
||||
|
||||
const user = await queryOne<{ totp_secret: string | null }>(
|
||||
`SELECT totp_secret FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
if (!user?.totp_secret) return res.status(400).json({ error: 'Run /totp/setup first' });
|
||||
|
||||
const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(user.totp_secret), digits: 6, period: 30 });
|
||||
const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) return res.status(401).json({ error: 'Invalid code' });
|
||||
|
||||
await query(`UPDATE users SET totp_enabled = true WHERE id = $1`, [req.user!.userId]);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Disable TOTP — requires password confirmation
|
||||
router.post('/totp/disable', requireAuth, async (req: Request, res: Response) => {
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password) return res.status(400).json({ error: 'password is required' });
|
||||
|
||||
const user = await queryOne<{ password_hash: string }>(
|
||||
`SELECT password_hash FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
|
||||
await query(`UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1`, [req.user!.userId]);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Return current TOTP status for the authenticated user
|
||||
router.get('/totp/status', requireAuth, async (req: Request, res: Response) => {
|
||||
const user = await queryOne<{ totp_enabled: boolean }>(
|
||||
`SELECT totp_enabled FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
return res.json({ totp_enabled: user?.totp_enabled ?? false });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
|
||||
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
interface ConfigTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
applies_to_type: string | null;
|
||||
template_json: {
|
||||
dns_servers?: string[];
|
||||
ntp_servers?: string[];
|
||||
syslog_host?: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// GET /api/config-templates
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const rows = await query<ConfigTemplate>(`SELECT * FROM config_templates ORDER BY name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// GET /api/config-templates/:id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const row = await queryOne<ConfigTemplate>(`SELECT * FROM config_templates WHERE id = $1`, [req.params.id]);
|
||||
if (!row) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// POST /api/config-templates (admin)
|
||||
router.post('/', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
|
||||
if (!name?.trim()) { res.status(400).json({ error: 'name is required' }); return; }
|
||||
const rows = await query<ConfigTemplate>(
|
||||
`INSERT INTO config_templates (name, description, applies_to_type, template_json)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name.trim(), description ?? null, applies_to_type ?? null, JSON.stringify(template_json ?? {})]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
});
|
||||
|
||||
// PUT /api/config-templates/:id (admin)
|
||||
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { name, description, applies_to_type, template_json } = req.body as Partial<ConfigTemplate>;
|
||||
const rows = await query<ConfigTemplate>(
|
||||
`UPDATE config_templates SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
applies_to_type = COALESCE($3, applies_to_type),
|
||||
template_json = COALESCE($4, template_json),
|
||||
updated_at = NOW()
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name, description, applies_to_type, template_json ? JSON.stringify(template_json) : null, req.params.id]
|
||||
);
|
||||
if (!rows[0]) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// DELETE /api/config-templates/:id (admin)
|
||||
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
await query(`DELETE FROM config_templates WHERE id = $1`, [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/config-templates/:id/apply — push template to one or more devices (operator+)
|
||||
router.post('/:id/apply', requireWrite, async (req: Request, res: Response) => {
|
||||
const template = await queryOne<ConfigTemplate>(
|
||||
`SELECT * FROM config_templates WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
|
||||
|
||||
const { device_ids } = req.body as { device_ids?: number[] };
|
||||
if (!Array.isArray(device_ids) || device_ids.length === 0) {
|
||||
res.status(400).json({ error: 'device_ids array required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRows = await query<{
|
||||
id: number; name: string; ip_address: string; api_port: number;
|
||||
api_username: string; api_password_encrypted: string;
|
||||
}>(
|
||||
`SELECT id, name, ip_address, api_port, api_username, api_password_encrypted
|
||||
FROM devices WHERE id = ANY($1::int[])`,
|
||||
[device_ids]
|
||||
);
|
||||
|
||||
const tpl = template.template_json;
|
||||
const results: { device_id: number; device_name: string; ok: boolean; error?: string }[] = [];
|
||||
|
||||
await Promise.allSettled(
|
||||
deviceRows.map(async (dev) => {
|
||||
const client = new RouterOSClient(
|
||||
dev.ip_address,
|
||||
dev.api_port,
|
||||
dev.api_username,
|
||||
decrypt(dev.api_password_encrypted),
|
||||
15_000
|
||||
);
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
if (tpl.dns_servers && tpl.dns_servers.length > 0) {
|
||||
await client.execute('/ip/dns/set', { servers: tpl.dns_servers.join(',') });
|
||||
}
|
||||
|
||||
if (tpl.ntp_servers && tpl.ntp_servers.length > 0) {
|
||||
await client.execute('/system/ntp/client/set', {
|
||||
enabled: 'yes',
|
||||
servers: tpl.ntp_servers.join(','),
|
||||
}).catch(() =>
|
||||
// RouterOS 6 uses different NTP path
|
||||
client.execute('/system/ntp/client/set', {
|
||||
enabled: 'yes',
|
||||
'primary-ntp': tpl.ntp_servers![0] ?? '',
|
||||
'secondary-ntp': tpl.ntp_servers![1] ?? '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (tpl.syslog_host) {
|
||||
// Find existing remote logging action and update it
|
||||
const actions = await client.execute('/system/logging/action/print');
|
||||
const remoteAction = (actions as Record<string, string>[]).find((a) => a.target === 'remote');
|
||||
if (remoteAction) {
|
||||
await client.execute('/system/logging/action/set', {
|
||||
'.id': remoteAction['.id'],
|
||||
'remote': tpl.syslog_host,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ device_id: dev.id, device_name: dev.name, ok: true });
|
||||
} catch (err) {
|
||||
results.push({ device_id: dev.id, device_name: dev.name, ok: false, error: (err as Error).message });
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user