mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-08 06:53:20 +00:00
70 lines
3.1 KiB
JavaScript
70 lines
3.1 KiB
JavaScript
'use strict';
|
|
|
|
// The admin's most sensitive actions (downloading a backup, restoring one)
|
|
// ask for the password again, and the two-factor code when it is on. A
|
|
// backup holds every account's password hash and a restore can rewrite any
|
|
// account, so a stolen admin session alone should not be enough for either.
|
|
//
|
|
// A download then goes through a one-time ticket that lasts two minutes,
|
|
// instead of the admin's session token in the URL, where it ended up in
|
|
// browser history and proxy logs.
|
|
|
|
const crypto = require('crypto');
|
|
const bcrypt = require('bcryptjs');
|
|
const OTPAuth = require('otpauth');
|
|
|
|
const TICKET_MS = 2 * 60 * 1000;
|
|
const tickets = new Map(); // ticket -> { userId, purpose, data, expires }
|
|
|
|
// The same rule as auth.js: an SSO account has no bcrypt hash of its own.
|
|
function hasLocalPassword(user) {
|
|
return !!user && typeof user.password_hash === 'string' && user.password_hash.startsWith('$2');
|
|
}
|
|
|
|
// Null when the admin passed, or the reason they did not.
|
|
async function checkAdminReauth(db, userId, { password, code } = {}) {
|
|
const auth = require('./auth');
|
|
const user = db.prepare('SELECT id, username, password_hash, totp_enabled, totp_secret FROM users WHERE id = ?').get(userId);
|
|
if (!user) return 'Account not found';
|
|
// An SSO account has no password here and cannot turn on two-factor, so
|
|
// there would be nothing to ask for and a stolen session would be enough.
|
|
if (!hasLocalPassword(user) && !(user.totp_enabled && user.totp_secret)) {
|
|
return 'Backups and restores need an admin account with a password. Sign in with the local admin account to do this.';
|
|
}
|
|
if (hasLocalPassword(user)) {
|
|
if (typeof password !== 'string' || !password) return 'Enter your password';
|
|
if (!(await bcrypt.compare(password, user.password_hash))) return 'Incorrect password';
|
|
}
|
|
if (user.totp_enabled && user.totp_secret) {
|
|
if (auth.totpLimited(user.id)) return 'Too many wrong codes. Wait a few minutes and try again.';
|
|
const clean = typeof code === 'string' ? code.replace(/\s/g, '') : '';
|
|
if (!clean) return 'Enter your two-factor code';
|
|
const totp = new OTPAuth.TOTP({ issuer: 'Haven', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.totp_secret) });
|
|
if (!auth.acceptTotpCode(user.id, totp, clean)) {
|
|
auth.noteTotpFail(user.id);
|
|
return 'Incorrect two-factor code';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function issueTicket(userId, purpose, data = {}) {
|
|
const now = Date.now();
|
|
for (const [k, v] of tickets) if (v.expires < now) tickets.delete(k);
|
|
const ticket = crypto.randomBytes(32).toString('hex');
|
|
tickets.set(ticket, { userId, purpose, data, expires: now + TICKET_MS });
|
|
return ticket;
|
|
}
|
|
|
|
// The ticket's entry, used up, or null.
|
|
function takeTicket(ticket, purpose) {
|
|
if (typeof ticket !== 'string' || !/^[0-9a-f]{64}$/.test(ticket)) return null;
|
|
const entry = tickets.get(ticket);
|
|
if (!entry) return null;
|
|
tickets.delete(ticket);
|
|
if (entry.expires < Date.now() || entry.purpose !== purpose) return null;
|
|
return entry;
|
|
}
|
|
|
|
module.exports = { checkAdminReauth, issueTicket, takeTicket };
|