Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
import db from './db.js';
|
||||
import config from './config.js';
|
||||
import { hashPassword, randomPassword } from './auth.js';
|
||||
import { nowIso } from './util.js';
|
||||
|
||||
const LOCKOUT_FAILS = 6;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
|
||||
export function domainAllowed(email) {
|
||||
const allowed = config.admin.allowedDomains;
|
||||
if (!allowed.length) return true;
|
||||
const domain = String(email).split('@')[1]?.toLowerCase() || '';
|
||||
return allowed.some((d) => domain === d || domain.endsWith(`.${d}`));
|
||||
}
|
||||
|
||||
export function domainRuleText() {
|
||||
const allowed = config.admin.allowedDomains;
|
||||
if (!allowed.length) return null;
|
||||
return allowed.map((d) => `@${d}`).join(' or ');
|
||||
}
|
||||
|
||||
export function findByEmail(email) {
|
||||
return db
|
||||
.prepare('SELECT * FROM admin_users WHERE email = ?')
|
||||
.get(String(email).trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function findById(id) {
|
||||
return db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function countActive() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM admin_users WHERE active = 1').get().n;
|
||||
}
|
||||
|
||||
export function shape(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
siteId: user.site_id,
|
||||
twoFactorOn: Boolean(user.totp_enabled),
|
||||
mustChangePassword: Boolean(user.must_change_password),
|
||||
active: Boolean(user.active),
|
||||
lastLoginAt: user.last_login_at,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createUser({ email, name, password, role = 'admin', siteId = null, mustChange = true }) {
|
||||
const clean = String(email || '').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error('Enter a valid email address.');
|
||||
if (!domainAllowed(clean)) {
|
||||
throw new Error(`Admin accounts must use an ${domainRuleText()} address.`);
|
||||
}
|
||||
if (findByEmail(clean)) throw new Error('An account already uses that email address.');
|
||||
|
||||
const temp = password || randomPassword();
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO admin_users (email, name, password_hash, role, site_id, must_change_password)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(clean, String(name || '').trim() || null, hashPassword(temp), role, siteId, mustChange ? 1 : 0);
|
||||
|
||||
return { user: findById(info.lastInsertRowid), temporaryPassword: password ? null : temp };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- lockout */
|
||||
|
||||
export function lockState(email) {
|
||||
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
|
||||
if (row?.locked_until && row.locked_until > nowIso()) return row;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function noteFailure(email) {
|
||||
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
|
||||
const fails = (row?.fails || 0) + 1;
|
||||
const lockedUntil =
|
||||
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
|
||||
db.prepare(
|
||||
`INSERT INTO login_attempts (email, fails, locked_until) VALUES (?, ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
|
||||
).run(email, fails, lockedUntil);
|
||||
return { fails, lockedUntil };
|
||||
}
|
||||
|
||||
export function clearFailures(email) {
|
||||
db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- bootstrap */
|
||||
|
||||
/** Creates the very first admin account from the environment, once. */
|
||||
export function bootstrap() {
|
||||
if (countActive() > 0) return;
|
||||
|
||||
const { bootstrapEmail, bootstrapPassword } = config.admin;
|
||||
if (!bootstrapEmail || !bootstrapPassword) {
|
||||
console.warn(
|
||||
'[users] No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD\n' +
|
||||
' in .env and restart to create the first one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
createUser({
|
||||
email: bootstrapEmail,
|
||||
name: 'First admin',
|
||||
password: bootstrapPassword,
|
||||
role: 'owner',
|
||||
mustChange: true,
|
||||
});
|
||||
console.log(`[users] created the first admin account: ${bootstrapEmail}`);
|
||||
console.log('[users] you will be asked to set a new password at first sign in');
|
||||
} catch (err) {
|
||||
console.error('[users] could not create the first admin account:', err.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user