feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ec026195eb
commit
fff0828d79
11 changed files with 734 additions and 17 deletions
|
|
@ -22,7 +22,8 @@
|
|||
"bullmq": "^5.5.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
|
|
|||
37
services/mam-api/src/auth/mfa-tickets.js
Normal file
37
services/mam-api/src/auth/mfa-tickets.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Short-lived MFA tickets bridging the two login steps.
|
||||
//
|
||||
// When a user with TOTP enabled passes password auth, we don't create a session
|
||||
// yet — we hand back an opaque ticket. The second request (code or recovery
|
||||
// code) redeems the ticket to finish login. Tickets are single-use and expire
|
||||
// fast so a stolen ticket is near-useless.
|
||||
//
|
||||
// In-memory + single-instance, matching the existing login rate-limiter
|
||||
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
|
||||
// the second step must hit the same node. Acceptable for Dragonflight's
|
||||
// one-mam-api-per-node shape; revisit if that changes.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
|
||||
const tickets = new Map(); // id -> { userId, expiresAt }
|
||||
|
||||
function sweep() {
|
||||
const now = Date.now();
|
||||
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
|
||||
}
|
||||
|
||||
export function issueTicket(userId) {
|
||||
sweep();
|
||||
const id = randomBytes(32).toString('hex');
|
||||
tickets.set(id, { userId, expiresAt: Date.now() + TTL_MS });
|
||||
return id;
|
||||
}
|
||||
|
||||
// Redeem (and consume) a ticket. Returns the userId, or null if missing/expired.
|
||||
export function redeemTicket(id) {
|
||||
if (!id) return null;
|
||||
const t = tickets.get(id);
|
||||
if (!t) return null;
|
||||
tickets.delete(id); // single-use
|
||||
if (t.expiresAt <= Date.now()) return null;
|
||||
return t.userId;
|
||||
}
|
||||
114
services/mam-api/src/auth/totp.js
Normal file
114
services/mam-api/src/auth/totp.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
|
||||
//
|
||||
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
|
||||
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
|
||||
// in test/auth/totp.test.js.
|
||||
//
|
||||
// Defaults match every mainstream authenticator app (Google Authenticator,
|
||||
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const DIGITS = 6;
|
||||
const STEP_SECONDS = 30;
|
||||
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
|
||||
export function base32Encode(buf) {
|
||||
let bits = 0, value = 0, out = '';
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(str) {
|
||||
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
|
||||
let bits = 0, value = 0;
|
||||
const out = [];
|
||||
for (const ch of clean) {
|
||||
const idx = RFC4648_B32.indexOf(ch);
|
||||
if (idx === -1) continue; // skip stray chars
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
|
||||
// SHA-1 key length).
|
||||
export function generateSecret() {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
// HOTP for a specific counter (RFC 4226).
|
||||
function hotp(secretBuf, counter) {
|
||||
const buf = Buffer.alloc(8);
|
||||
// 64-bit big-endian counter.
|
||||
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
||||
buf.writeUInt32BE(counter >>> 0, 4);
|
||||
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||
const code = ((hmac[offset] & 0x7f) << 24)
|
||||
| ((hmac[offset + 1] & 0xff) << 16)
|
||||
| ((hmac[offset + 2] & 0xff) << 8)
|
||||
| (hmac[offset + 3] & 0xff);
|
||||
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
|
||||
}
|
||||
|
||||
// The TOTP code for a given time (defaults to now).
|
||||
export function generateToken(base32Secret, atMs = Date.now()) {
|
||||
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||
return hotp(base32Decode(base32Secret), counter);
|
||||
}
|
||||
|
||||
// Verify a user-supplied code, allowing ±`window` steps of clock drift
|
||||
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
|
||||
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
|
||||
if (!base32Secret || !token) return false;
|
||||
const cleaned = String(token).replace(/\s+/g, '');
|
||||
if (!/^\d{6}$/.test(cleaned)) return false;
|
||||
const secretBuf = base32Decode(base32Secret);
|
||||
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||
const want = Buffer.from(cleaned);
|
||||
for (let w = -window; w <= window; w++) {
|
||||
const candidate = Buffer.from(hotp(secretBuf, counter + w));
|
||||
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
|
||||
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
|
||||
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
||||
const params = new URLSearchParams({
|
||||
secret: base32Secret,
|
||||
issuer,
|
||||
algorithm: 'SHA1',
|
||||
digits: String(DIGITS),
|
||||
period: String(STEP_SECONDS),
|
||||
});
|
||||
return `otpauth://totp/${label}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
|
||||
// them before storage and shows the raw set to the user exactly once.
|
||||
export function generateRecoveryCodes(n = 10) {
|
||||
const codes = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
|
||||
const hex = randomBytes(5).toString('hex');
|
||||
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Migration 027 — TOTP two-factor auth.
|
||||
--
|
||||
-- totp_secret holds the base32 shared secret once enrollment is confirmed
|
||||
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
|
||||
-- the user verifies their first code, so a half-finished enrollment never locks
|
||||
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
|
||||
-- a code as spent.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);
|
||||
|
|
@ -104,7 +104,7 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|||
|
||||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required']);
|
||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async function destroyAnd401(req, res) {
|
|||
|
||||
async function loadUser(id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
|
||||
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import pool from '../db/pool.js';
|
|||
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
||||
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||
import { ipBackoff } from '../auth/rate-limit.js';
|
||||
import {
|
||||
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
||||
} from '../auth/totp.js';
|
||||
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
||||
|
||||
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ router.post('/login', async (req, res, next) => {
|
|||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
|
||||
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
|
||||
[username.trim(), DEV_USER_ID]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -93,6 +97,23 @@ router.post('/login', async (req, res, next) => {
|
|||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
// Password is correct — clear the per-IP backoff regardless of MFA outcome.
|
||||
ipBackoff.recordSuccess(ip);
|
||||
|
||||
// Second factor: if TOTP is enabled, don't create a session yet. Hand back a
|
||||
// short-lived ticket the client redeems via /login/totp with a code.
|
||||
if (user.totp_enabled) {
|
||||
return res.json({ mfa_required: true, ticket: issueTicket(user.id) });
|
||||
}
|
||||
|
||||
await establishSession(req, user);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Write the session and wait for it to persist before responding. Extracted so
|
||||
// both the password-only and the MFA-completion paths share one implementation.
|
||||
async function establishSession(req, user) {
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
|
|
@ -100,14 +121,55 @@ router.post('/login', async (req, res, next) => {
|
|||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
|
||||
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||
}
|
||||
|
||||
ipBackoff.recordSuccess(ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
|
||||
// POST /api/v1/auth/login/totp { ticket, code } — second login step. `code` is
|
||||
// either a 6-digit TOTP or a one-time recovery code.
|
||||
router.post('/login/totp', async (req, res, next) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
const { ticket, code } = req.body || {};
|
||||
const userId = redeemTicket(ticket);
|
||||
if (!userId) return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||
if (!code) return res.status(400).json({ error: 'code required' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, totp_secret, totp_enabled FROM users WHERE id = $1`, [userId]);
|
||||
const user = rows[0];
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
let ok = verifyToken(user.totp_secret, code);
|
||||
if (!ok) ok = await consumeRecoveryCode(user.id, code);
|
||||
if (!ok) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
// The ticket was single-use; the client must restart from /login.
|
||||
return res.status(401).json({ error: 'invalid code' });
|
||||
}
|
||||
|
||||
await establishSession(req, user);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||
async function consumeRecoveryCode(userId, code) {
|
||||
const cleaned = String(code).trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||
for (const row of rows) {
|
||||
if (await comparePassword(cleaned, row.code_hash)) {
|
||||
await pool.query(`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1`, [row.id]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!req.session) return res.status(204).end();
|
||||
|
|
@ -125,6 +187,7 @@ router.get('/me', requireAuth, (req, res) => {
|
|||
username: req.user.username,
|
||||
display_name: req.user.display_name,
|
||||
role: req.user.role,
|
||||
totp_enabled: !!req.user.totp_enabled,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -149,5 +212,89 @@ router.post('/password', requireAuth, async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── TOTP enrollment (all require an active session) ─────────────────────────
|
||||
|
||||
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
|
||||
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
|
||||
// base32 secret for manual entry. Enrollment isn't active until /enable
|
||||
// confirms a code, so a started-but-abandoned setup never locks the user out.
|
||||
router.post('/totp/setup', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||
|
||||
const secret = generateSecret();
|
||||
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
|
||||
const uri = otpauthURI(secret, req.user.username || 'user');
|
||||
|
||||
// QR rendering is optional — the otpauth URI + manual secret are sufficient
|
||||
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
|
||||
// present, so a missing dependency degrades instead of 500-ing.
|
||||
let qr = null;
|
||||
try {
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
qr = await QRCode.toDataURL(uri);
|
||||
} catch { /* qrcode not installed — client falls back to manual entry */ }
|
||||
|
||||
res.json({ secret, otpauth_uri: uri, qr });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
|
||||
// the authenticator. On success, flips totp_enabled and returns one-time
|
||||
// recovery codes (shown exactly once).
|
||||
router.post('/totp/enable', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = req.body || {};
|
||||
if (!code) return badRequest(res, 'code required');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||
const row = rows[0];
|
||||
if (!row?.totp_secret) return badRequest(res, 'start setup first');
|
||||
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||
if (!verifyToken(row.totp_secret, code)) return badRequest(res, 'incorrect code');
|
||||
|
||||
const recovery = generateRecoveryCodes(10);
|
||||
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
|
||||
// Enable + replace any stale recovery codes atomically.
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`UPDATE users SET totp_enabled = TRUE WHERE id = $1`, [req.user.id]);
|
||||
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||
for (const h of hashes) {
|
||||
await client.query(
|
||||
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally { client.release(); }
|
||||
|
||||
res.json({ enabled: true, recovery_codes: recovery });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
|
||||
// account password as a confirmation so a hijacked live session can't silently
|
||||
// strip the second factor.
|
||||
router.post('/totp/disable', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
if (!password) return badRequest(res, 'password required');
|
||||
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
||||
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (!(await comparePassword(password, rows[0].password_hash))) {
|
||||
return badRequest(res, 'incorrect password');
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL WHERE id = $1`, [req.user.id]);
|
||||
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||
res.status(204).end();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { realUserCount };
|
||||
|
|
|
|||
82
services/mam-api/test/auth/totp.test.js
Normal file
82
services/mam-api/test/auth/totp.test.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
base32Encode, base32Decode, generateSecret, generateToken,
|
||||
verifyToken, otpauthURI, generateRecoveryCodes,
|
||||
} from '../../src/auth/totp.js';
|
||||
|
||||
// ── base32 round-trips ──────────────────────────────────────────────────────
|
||||
test('base32 encode/decode round-trips arbitrary bytes', () => {
|
||||
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
||||
const buf = Buffer.from(s);
|
||||
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
|
||||
}
|
||||
});
|
||||
|
||||
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
|
||||
// low 6 here, so compare the last 6 digits of each published value). ──────────
|
||||
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
|
||||
// check the 6-digit code at each published timestamp.
|
||||
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
|
||||
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
||||
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
|
||||
const vectors = [
|
||||
[59, '94287082'],
|
||||
[1111111109, '07081804'],
|
||||
[1111111111, '14050471'],
|
||||
[1234567890, '89005924'],
|
||||
[2000000000, '69279037'],
|
||||
[20000000000, '65353130'],
|
||||
];
|
||||
for (const [secs, full8] of vectors) {
|
||||
const got = generateToken(secret, secs * 1000);
|
||||
assert.equal(got, full8.slice(-6), `t=${secs}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── verify with drift window ────────────────────────────────────────────────
|
||||
test('verifyToken accepts the current code and ±1 step of drift', () => {
|
||||
const secret = generateSecret();
|
||||
const now = 1_700_000_000_000;
|
||||
const code = generateToken(secret, now);
|
||||
assert.equal(verifyToken(secret, code, now), true);
|
||||
// 30s earlier / later still inside ±1 window.
|
||||
assert.equal(verifyToken(secret, code, now + 30_000), true);
|
||||
assert.equal(verifyToken(secret, code, now - 30_000), true);
|
||||
// 2 steps away → rejected.
|
||||
assert.equal(verifyToken(secret, code, now + 90_000), false);
|
||||
});
|
||||
|
||||
test('verifyToken rejects malformed / empty input without throwing', () => {
|
||||
const secret = generateSecret();
|
||||
assert.equal(verifyToken(secret, ''), false);
|
||||
assert.equal(verifyToken(secret, 'abcdef'), false);
|
||||
assert.equal(verifyToken(secret, '12345'), false); // too short
|
||||
assert.equal(verifyToken(secret, '1234567'), false); // too long
|
||||
assert.equal(verifyToken('', '123456'), false);
|
||||
});
|
||||
|
||||
test('verifyToken tolerates spaces in the user-entered code', () => {
|
||||
const secret = generateSecret();
|
||||
const now = 1_700_000_000_000;
|
||||
const code = generateToken(secret, now);
|
||||
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), true);
|
||||
});
|
||||
|
||||
// ── otpauth URI ─────────────────────────────────────────────────────────────
|
||||
test('otpauthURI embeds secret, issuer, and account', () => {
|
||||
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
|
||||
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
|
||||
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
|
||||
assert.match(uri, /issuer=Dragonflight/);
|
||||
assert.match(uri, /digits=6/);
|
||||
assert.match(uri, /period=30/);
|
||||
});
|
||||
|
||||
// ── recovery codes ──────────────────────────────────────────────────────────
|
||||
test('generateRecoveryCodes returns N distinct formatted codes', () => {
|
||||
const codes = generateRecoveryCodes(10);
|
||||
assert.equal(codes.length, 10);
|
||||
assert.equal(new Set(codes).size, 10);
|
||||
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
||||
});
|
||||
143
services/mam-api/test/routes/totp.test.js
Normal file
143
services/mam-api/test/routes/totp.test.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Integration test for the TOTP two-step login + recovery codes.
|
||||
//
|
||||
// Mounts the real auth router with a session store on the throwaway test DB.
|
||||
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
|
||||
// → complete with a generated code → and the recovery-code single-use path.
|
||||
// Skips when TEST_DATABASE_URL is unset.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
import { hashPassword } from '../../src/auth/passwords.js';
|
||||
import { generateToken } from '../../src/auth/totp.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
async function appWithAuth(pool) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
process.env.SESSION_SECRET = 'test';
|
||||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||||
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({
|
||||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||||
secret: 'test', name: 'dragonflight.sid',
|
||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||
rolling: false, resave: false, saveUninitialized: false,
|
||||
}));
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
return new Promise(r => {
|
||||
const srv = app.listen(0, '127.0.0.1', () =>
|
||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
||||
});
|
||||
}
|
||||
|
||||
const J = (cookie, body) => ({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function loginPassword(baseUrl, username, password) {
|
||||
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
|
||||
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
|
||||
return { r, body: await r.json().catch(() => ({})), cookie };
|
||||
}
|
||||
|
||||
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
// 1. Password login (no TOTP yet) → 200 with a session cookie.
|
||||
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
assert.equal(first.r.status, 200);
|
||||
assert.ok(!first.body.mfa_required);
|
||||
const cookie = first.cookie;
|
||||
|
||||
// 2. Enroll: setup returns a secret; enable confirms with a live code.
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
|
||||
assert.match(setup.secret, /^[A-Z2-7]+$/);
|
||||
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
|
||||
assert.equal(enableRes.status, 200);
|
||||
const enableBody = await enableRes.json();
|
||||
assert.equal(enableBody.enabled, true);
|
||||
assert.equal(enableBody.recovery_codes.length, 10);
|
||||
|
||||
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
|
||||
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
assert.equal(second.r.status, 200);
|
||||
assert.equal(second.body.mfa_required, true);
|
||||
assert.ok(second.body.ticket);
|
||||
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
|
||||
|
||||
// 4. Wrong code → 401; the ticket is now spent.
|
||||
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
|
||||
assert.equal(bad.status, 401);
|
||||
|
||||
// 5. New login + correct code → 200 with a session cookie.
|
||||
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
|
||||
assert.equal(ok.status, 200);
|
||||
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
|
||||
|
||||
await close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
||||
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
|
||||
const recovery = enableBody.recovery_codes[0];
|
||||
|
||||
// Use a recovery code to complete a fresh login.
|
||||
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
|
||||
assert.equal(use1.status, 200, 'recovery code should complete login once');
|
||||
|
||||
// The same recovery code must NOT work a second time.
|
||||
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
|
||||
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
|
||||
|
||||
await close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
||||
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
|
||||
|
||||
// Disabling requires the password.
|
||||
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
|
||||
assert.equal(wrongPw.status, 400);
|
||||
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
|
||||
assert.equal(disabled.status, 204);
|
||||
|
||||
// Password login is single-factor again.
|
||||
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
||||
assert.equal(relog.r.status, 200);
|
||||
assert.ok(!relog.body.mfa_required);
|
||||
|
||||
await close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
|
@ -1488,6 +1488,135 @@ function AccountSection() {
|
|||
);
|
||||
}
|
||||
|
||||
// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
|
||||
function TotpSection() {
|
||||
const me = window.ZAMPP_DATA?.ME || {};
|
||||
const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
|
||||
const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery
|
||||
const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr }
|
||||
const [code, setCode] = React.useState('');
|
||||
const [recovery, setRecovery] = React.useState(null); // string[]
|
||||
const [disablePw, setDisablePw] = React.useState('');
|
||||
const [showDisable, setShowDisable] = React.useState(false);
|
||||
const [msg, setMsg] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const api = (path, body) => fetch('/api/v1/auth' + path, {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const startSetup = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/setup');
|
||||
if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
|
||||
else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const confirmEnable = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/enable', { code: code.trim() });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (r.status === 200) {
|
||||
setRecovery(body.recovery_codes || []); setPhase('recovery');
|
||||
setEnabled(true); setCode('');
|
||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
|
||||
} else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/disable', { password: disablePw });
|
||||
if (r.status === 204) {
|
||||
setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
|
||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
|
||||
setMsg({ kind: 'ok', text: 'Two-factor disabled' });
|
||||
} else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Two-factor authentication</h3>
|
||||
|
||||
{/* Status line */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
|
||||
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
|
||||
{enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
|
||||
</div>
|
||||
|
||||
{/* Enrolling: show QR / secret + code field */}
|
||||
{phase === 'enrolling' && enroll && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
|
||||
<div>
|
||||
{enroll.qr
|
||||
? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
|
||||
: <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
Scan the QR with Google Authenticator, Authy, or 1Password — or enter this secret manually:
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Enter the 6-digit code to confirm</label>
|
||||
<input className="field-input mono" value={code} autoFocus
|
||||
onChange={e => setCode(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
|
||||
placeholder="123456" style={{ width: 140 }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
|
||||
<button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recovery codes — shown exactly once */}
|
||||
{phase === 'recovery' && recovery && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
|
||||
</div>
|
||||
<div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
|
||||
{recovery.map(c => <div key={c}>{c}</div>)}
|
||||
</div>
|
||||
<button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
|
||||
<button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable confirmation */}
|
||||
{showDisable && (
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
|
||||
<div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
|
||||
<label className="field-label">Confirm your password to disable</label>
|
||||
<input className="field-input" type="password" value={disablePw} autoComplete="current-password"
|
||||
onChange={e => setDisablePw(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
|
||||
</div>
|
||||
<button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
|
||||
<button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiTokensSection() {
|
||||
const [tokens, setTokens] = React.useState([]);
|
||||
const [name, setName] = React.useState('');
|
||||
|
|
@ -1597,6 +1726,7 @@ function Settings() {
|
|||
{section === 'account' && (
|
||||
<>
|
||||
<AccountSection />
|
||||
<TotpSection />
|
||||
<ApiTokensSection />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -121,16 +121,59 @@
|
|||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
// Second factor: when the server returns { mfa_required, ticket }, we switch
|
||||
// to the code step instead of completing login.
|
||||
const [ticket, setTicket] = React.useState(null);
|
||||
const [code, setCode] = React.useState('');
|
||||
|
||||
const submit = async () => {
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
const r = await postJson('/auth/login', { username, password });
|
||||
if (r.status === 200) { onDone(); return; }
|
||||
if (r.status === 200) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
|
||||
onDone(); return;
|
||||
}
|
||||
const body = await r.json().catch(() => ({}));
|
||||
setError(body.error || ('Login failed: ' + r.status));
|
||||
} catch (e) { setError(e.message || 'Login failed'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
const r = await postJson('/auth/login/totp', { ticket, code: code.trim() });
|
||||
if (r.status === 200) { onDone(); return; }
|
||||
const body = await r.json().catch(() => ({}));
|
||||
// An expired/used ticket means the user must start over.
|
||||
if (r.status === 401 && /ticket/.test(body.error || '')) {
|
||||
setTicket(null); setCode(''); setPassword('');
|
||||
setError('Session timed out — please sign in again.');
|
||||
} else {
|
||||
setError(body.error || ('Verification failed: ' + r.status));
|
||||
}
|
||||
} catch (e) { setError(e.message || 'Verification failed'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (ticket) {
|
||||
return (
|
||||
<Screen>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
||||
Two-factor authentication
|
||||
</div>
|
||||
<ErrorRow text={error} />
|
||||
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
|
||||
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||
Enter the 6-digit code from your authenticator app, or a recovery code.
|
||||
</div>
|
||||
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ErrorRow text={error} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue