feat: invite-only registration
registration_enabled was a single switch: open to anyone, or closed to everyone. This adds the setting an operator actually wants in between — open to people you invited. A code is single-use, expires (7 days by default, 90 maximum), and can be revoked or deleted. It is stored hashed with only its last four characters kept, because an invite grants account creation and a database dump should not hand someone a working one. The code is readable exactly once, in the response that creates it. The claim is a single conditional UPDATE carrying every condition, so two registrations racing the same code cannot both succeed. It happens after the account exists, so a code is never spent on a failed registration — and if the race is lost, the just-created account is removed rather than left behind as a free registration. The rejection never says which of the four reasons applied; distinguishing them would tell someone probing codes which guesses were closer. Codes avoid I, L, O and U so they survive being read aloud or copied off a screen, and matching ignores case and separators. The sign-up field appears only when the server says a code is required. The admin card creates, lists, revokes and deletes, and carries the toggle. Verified against the live database: create, claim, second claim refused, unknown code refused, revoking a used code refused, delete. 684 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
cd27293a52
commit
39c1663334
10 changed files with 460 additions and 9 deletions
38
migrations/1780100000000_registration-invites.js
Normal file
38
migrations/1780100000000_registration-invites.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Invite-only registration.
|
||||
//
|
||||
// registration_enabled is a single on/off switch: open to anyone, or closed to
|
||||
// everyone. This adds the middle setting an operator actually wants — open to
|
||||
// people you invited. A code is single-use, expires, and can be revoked or
|
||||
// deleted without touching the account it created.
|
||||
//
|
||||
// The code is stored hashed. An invite grants account creation, so a leaked
|
||||
// settings dump or database backup should not hand someone a working code, the
|
||||
// same reason password reset tokens are not stored in the clear.
|
||||
|
||||
exports.up = pgm => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS registration_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code_hash TEXT NOT NULL UNIQUE,
|
||||
-- The last few characters, so the list can show which code a row is
|
||||
-- without being able to reconstruct it.
|
||||
code_hint TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
-- Set when used. The row is kept so an admin can see who used which code.
|
||||
used_at TIMESTAMPTZ,
|
||||
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Set when revoked. Separate from deletion: a revoked code stays visible.
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_registration_invites_hash ON registration_invites(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_registration_invites_expires ON registration_invites(expires_at);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
pgm.sql('DROP TABLE IF EXISTS registration_invites;');
|
||||
};
|
||||
|
|
@ -373,6 +373,40 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Registration invites ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-envelope-open-text"></i> Registration Invitations</h3>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Invite only</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="admin-invite-only">
|
||||
Require an invitation code to register
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Create an invitation</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-invite-note" placeholder="Who is this for? (optional)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<label style="font-size:12px;color:var(--g600);display:flex;align-items:center;gap:6px;">Valid for
|
||||
<input type="number" id="admin-invite-days" value="7" min="1" max="90" style="width:70px;font-size:13px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;"> days
|
||||
</label>
|
||||
<button id="btn-create-invite" class="btn-sm btn-primary" type="button"><i class="fas fa-plus"></i> Create</button>
|
||||
</div>
|
||||
<div id="admin-invite-new" style="margin-top:10px;"></div>
|
||||
<p style="font-size:12px;color:var(--g500);margin:6px 0 0;">The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.</p>
|
||||
</div>
|
||||
|
||||
<div id="admin-invites-list" style="display:flex;flex-direction:column;gap:4px;max-height:340px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TTS Model Management ───────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
<label>Password (8+ characters)</label>
|
||||
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
|
||||
</div>
|
||||
<!-- Shown only when the server says registration is invite-only. -->
|
||||
<div class="form-group hidden" id="reg-invite-group">
|
||||
<label>Invitation code</label>
|
||||
<input type="text" id="reg-invite" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
|
||||
<button type="submit" class="btn-auth">Create Account</button>
|
||||
<div class="auth-links">
|
||||
|
|
|
|||
|
|
@ -1462,6 +1462,122 @@ initImageSettings();
|
|||
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN REGISTRATION INVITES
|
||||
// The code exists in readable form exactly once: in the response to creating
|
||||
// it. Everything after works from the id and the last four characters.
|
||||
// ============================================================
|
||||
{
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'admin') loadInvites();
|
||||
});
|
||||
if (adminTabActive()) loadInvites();
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-create-invite')) createInvite();
|
||||
var revoke = e.target.closest('.admin-invite-revoke');
|
||||
if (revoke) inviteAction(revoke.dataset.id, 'revoke');
|
||||
var del = e.target.closest('.admin-invite-delete');
|
||||
if (del) inviteAction(del.dataset.id, 'delete');
|
||||
var copy = e.target.closest('.admin-invite-copy');
|
||||
if (copy && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(copy.dataset.code)
|
||||
.then(function() { showToast('Invitation code copied', 'success'); })
|
||||
.catch(function() { showToast('Could not copy; select it by hand', 'error'); });
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id === 'admin-invite-only') setInviteOnly(e.target.checked);
|
||||
});
|
||||
|
||||
const esc = adminEscapeHtml;
|
||||
|
||||
function loadInvites() {
|
||||
fetch('/api/admin/invites', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var toggle = document.getElementById('admin-invite-only');
|
||||
if (toggle) toggle.checked = !!data.inviteOnly;
|
||||
renderInvites(data.invites || []);
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function setInviteOnly(on) {
|
||||
fetch('/api/admin/config/' + encodeURIComponent('registration_invite_only'), {
|
||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: on ? 'true' : 'false' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not save');
|
||||
showToast(on ? 'Registration now requires an invitation' : 'Registration no longer requires an invitation', 'success');
|
||||
})
|
||||
.catch(function(err) { showToast(err.message, 'error'); loadInvites(); });
|
||||
}
|
||||
|
||||
function createInvite() {
|
||||
var note = (document.getElementById('admin-invite-note') || {}).value || '';
|
||||
var days = (document.getElementById('admin-invite-days') || {}).value || '7';
|
||||
var out = document.getElementById('admin-invite-new');
|
||||
fetch('/api/admin/invites', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ note: note, days: days })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not create');
|
||||
if (out) {
|
||||
out.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--green);background:var(--green-light);border-radius:8px;flex-wrap:wrap;">' +
|
||||
'<code style="font-size:15px;font-weight:700;letter-spacing:.06em;">' + esc(data.code) + '</code>' +
|
||||
'<button type="button" class="btn-sm btn-ghost admin-invite-copy" data-code="' + esc(data.code) + '"><i class="fas fa-copy"></i> Copy</button>' +
|
||||
'<span style="font-size:12px;color:var(--g600);">Valid ' + esc(String(data.days)) + ' days. This is the only time it is shown.</span>' +
|
||||
'</div>';
|
||||
}
|
||||
var noteEl = document.getElementById('admin-invite-note');
|
||||
if (noteEl) noteEl.value = '';
|
||||
loadInvites();
|
||||
})
|
||||
.catch(function(err) { showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function inviteAction(id, action) {
|
||||
var request = action === 'delete'
|
||||
? fetch('/api/admin/invites/' + encodeURIComponent(id), { method: 'DELETE', headers: getAuthHeaders() })
|
||||
: fetch('/api/admin/invites/' + encodeURIComponent(id) + '/revoke', { method: 'POST', headers: getAuthHeaders() });
|
||||
request.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
showToast(action === 'delete' ? 'Invitation deleted' : 'Invitation revoked', 'info');
|
||||
loadInvites();
|
||||
})
|
||||
.catch(function(err) { showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function renderInvites(rows) {
|
||||
var container = document.getElementById('admin-invites-list');
|
||||
if (!container) return;
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No invitations yet.</p>';
|
||||
return;
|
||||
}
|
||||
var colours = { active: 'var(--green)', used: 'var(--g400)', expired: 'var(--amber)', revoked: 'var(--red)' };
|
||||
container.innerHTML = rows.map(function(row) {
|
||||
var when = row.status === 'used' ? 'used ' + new Date(row.used_at).toLocaleDateString()
|
||||
: row.status === 'revoked' ? 'revoked'
|
||||
: 'expires ' + new Date(row.expires_at).toLocaleDateString();
|
||||
var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : '';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
|
||||
'<span style="font-size:10px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:10px;color:white;background:' + (colours[row.status] || 'var(--g400)') + ';">' + esc(row.status) + '</span>' +
|
||||
'<code style="font-size:12px;">****-' + esc(row.code_hint) + '</code>' +
|
||||
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(row.note || '') + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--g500);">' + esc(when) + who + '</span>' +
|
||||
(row.status === 'active' ? '<button type="button" class="btn-sm btn-ghost admin-invite-revoke" data-id="' + esc(String(row.id)) + '">Revoke</button>' : '') +
|
||||
'<button type="button" class="btn-sm btn-ghost admin-invite-delete" data-id="' + esc(String(row.id)) + '" style="color:var(--red);" title="Remove from this list"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN IMAGE MODEL MANAGEMENT
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -881,7 +881,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var request = fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
|
||||
body: JSON.stringify({
|
||||
name: name, email: email, password: password, turnstileToken: regToken,
|
||||
// Sent whether or not the field is showing; the server decides
|
||||
// whether it is required.
|
||||
inviteCode: (document.getElementById('reg-invite') || {}).value || ''
|
||||
})
|
||||
});
|
||||
var attempt = authState();
|
||||
return request
|
||||
|
|
@ -1192,6 +1197,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var link = document.getElementById('show-register');
|
||||
if (link) link.style.display = '';
|
||||
}
|
||||
// Only ask for a code when one is actually required, so ordinary
|
||||
// registration is not cluttered by a field nobody can fill in.
|
||||
var inviteGroup = document.getElementById('reg-invite-group');
|
||||
var inviteInput = document.getElementById('reg-invite');
|
||||
if (inviteGroup) inviteGroup.classList.toggle('hidden', !data.inviteOnly);
|
||||
if (inviteInput) inviteInput.required = !!data.inviteOnly;
|
||||
})
|
||||
.catch(function() {});
|
||||
|
||||
|
|
|
|||
|
|
@ -2353,20 +2353,24 @@ import {
|
|||
});
|
||||
}
|
||||
|
||||
// Opening a saved chat is not the assistant working, so it does not flip the
|
||||
// send button into Stop and does not announce itself: the transcript
|
||||
// appearing is the confirmation. It still takes the busy lock, so two rapid
|
||||
// clicks cannot interleave two transcripts — but silently.
|
||||
var loadingSavedChat = false;
|
||||
function loadSavedChat(id) {
|
||||
if (assistantBusy) return;
|
||||
if (assistantBusy || loadingSavedChat) return;
|
||||
var layout = document.getElementById('assistant-layout');
|
||||
if (layout) layout.classList.remove('mobile-chats-open');
|
||||
setBusy(true, 'Loading chat...');
|
||||
loadingSavedChat = true;
|
||||
return fetchSavedAssistantChat(id)
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Load failed');
|
||||
currentChatId = data.chat ? data.chat.id : null;
|
||||
restoreSavedChat(data.chat && data.chat.payload || {});
|
||||
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
|
||||
})
|
||||
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })
|
||||
.finally(function() { setBusy(false, 'Ready'); });
|
||||
.finally(function() { loadingSavedChat = false; });
|
||||
}
|
||||
|
||||
function restoreSavedChat(payload) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ var promptCatalog = require('../utils/promptCatalog');
|
|||
var promptRevisions = require('../utils/promptRevisions');
|
||||
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
|
||||
var logger = require('../utils/logger');
|
||||
var { gatewayUrl } = require('../utils/errors');
|
||||
var { gatewayUrl, serverError } = require('../utils/errors');
|
||||
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
|
||||
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
|
||||
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
|
||||
|
|
@ -673,6 +673,42 @@ router.get('/config/stt', async function(req, res) {
|
|||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
// ── Registration invites ─────────────────────────────────────────────────
|
||||
// A code is shown once, at creation. Only its hash and last four characters
|
||||
// are stored, so this endpoint is the only time it can be read.
|
||||
var invites = require('../utils/registrationInvites');
|
||||
|
||||
router.get('/invites', async function(req, res) {
|
||||
try {
|
||||
res.json({ success: true, invites: await invites.list(), inviteOnly: await invites.inviteOnly() });
|
||||
} catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); }
|
||||
});
|
||||
|
||||
router.post('/invites', async function(req, res) {
|
||||
try {
|
||||
var created = await invites.create(req.user.id, { days: req.body.days, note: req.body.note });
|
||||
logger.audit(req.user.id, 'invite_create', 'Created a registration invite valid ' + created.days + ' days', req, { category: 'admin' });
|
||||
res.json({ success: true, code: created.code, days: created.days });
|
||||
} catch (e) { return serverError(res, 'Invite create', e, 'Could not create an invitation'); }
|
||||
});
|
||||
|
||||
router.post('/invites/:id/revoke', async function(req, res) {
|
||||
try {
|
||||
var done = await invites.revoke(req.params.id, req.user.id);
|
||||
if (!done) return res.status(400).json({ error: 'Only an unused, unrevoked invitation can be revoked' });
|
||||
logger.audit(req.user.id, 'invite_revoke', 'Revoked invitation ' + req.params.id, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { return serverError(res, 'Invite revoke', e, 'Could not revoke the invitation'); }
|
||||
});
|
||||
|
||||
router.delete('/invites/:id', async function(req, res) {
|
||||
try {
|
||||
if (!await invites.remove(req.params.id)) return res.status(404).json({ error: 'Not found' });
|
||||
logger.audit(req.user.id, 'invite_delete', 'Deleted invitation ' + req.params.id, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { return serverError(res, 'Invite delete', e, 'Could not delete the invitation'); }
|
||||
});
|
||||
|
||||
// ── GET discover STT models from provider ────────────────────────────────
|
||||
router.get('/config/stt/discover', async function(req, res) {
|
||||
try {
|
||||
|
|
@ -842,7 +878,7 @@ router.put('/config/:key(*)', async function(req, res) {
|
|||
}
|
||||
|
||||
// Security: only allow known key prefixes
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
|
||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||
if (!isAllowed) {
|
||||
return res.status(400).json({ error: 'Unknown config key' });
|
||||
|
|
@ -866,6 +902,9 @@ router.put('/config/:key(*)', async function(req, res) {
|
|||
if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
|
||||
return res.status(400).json({ error: 'Show sources must be true or false' });
|
||||
}
|
||||
if (key === 'registration_invite_only' && !['true', 'false'].includes(String(value))) {
|
||||
return res.status(400).json({ error: 'Invite-only must be true or false' });
|
||||
}
|
||||
// The admin's image-model roster: comma-separated gateway model ids.
|
||||
if (key === 'clinical_assistant.image_model_roster') {
|
||||
var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const QRCode = require('qrcode');
|
|||
const crypto = require('crypto');
|
||||
const db = require('../db/database');
|
||||
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
|
||||
const invites = require('../utils/registrationInvites');
|
||||
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
||||
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
|
||||
var logger = require('../utils/logger');
|
||||
|
|
@ -182,8 +183,16 @@ router.post('/register', requireLocalAuth, async (req, res) => {
|
|||
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
|
||||
}
|
||||
|
||||
var { email, password, name, turnstileToken } = req.body;
|
||||
var { email, password, name, turnstileToken, inviteCode } = req.body;
|
||||
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
|
||||
|
||||
// Invite-only sits between "open" and "closed": anyone with a code, nobody
|
||||
// without one. Checked before the work of hashing a password, and claimed
|
||||
// atomically once the account exists.
|
||||
var inviteRequired = await invites.inviteOnly();
|
||||
if (inviteRequired && !String(inviteCode || '').trim()) {
|
||||
return res.status(400).json({ error: 'An invitation code is required to register.' });
|
||||
}
|
||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
|
||||
|
||||
// Cloudflare Turnstile verification
|
||||
|
|
@ -217,6 +226,18 @@ router.post('/register', requireLocalAuth, async (req, res) => {
|
|||
);
|
||||
|
||||
var userId = result.lastInsertRowid;
|
||||
|
||||
// Claimed only now, so a code is never spent on a registration that failed.
|
||||
// The claim is a single conditional UPDATE, so two people racing the same
|
||||
// code cannot both win — the loser's account is removed again rather than
|
||||
// left behind as a free registration.
|
||||
if (inviteRequired) {
|
||||
var claimedInvite = await invites.claim(inviteCode, userId);
|
||||
if (!claimedInvite) {
|
||||
await db.run('DELETE FROM users WHERE id = ?', [userId]);
|
||||
return res.status(400).json({ error: 'That invitation code is not valid. It may have expired, been revoked, or already been used.' });
|
||||
}
|
||||
}
|
||||
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
|
||||
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
|
||||
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
|
||||
|
|
@ -679,7 +700,10 @@ router.get('/me', authMiddleware, async (req, res) => {
|
|||
router.get('/registration-status', async (req, res) => {
|
||||
try {
|
||||
var enabled = await db.getSetting('registration_enabled');
|
||||
res.json({ registrationEnabled: enabled !== 'false' && !await isSSOOnly() });
|
||||
res.json({
|
||||
registrationEnabled: enabled !== 'false' && !await isSSOOnly(),
|
||||
inviteOnly: await invites.inviteOnly()
|
||||
});
|
||||
} catch (err) { res.json({ registrationEnabled: false }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
138
src/utils/registrationInvites.js
Normal file
138
src/utils/registrationInvites.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// ============================================================
|
||||
// REGISTRATION INVITES
|
||||
// registration_enabled is open-or-closed. This is the middle setting: open to
|
||||
// people you invited. One module owns the rules so the register route and the
|
||||
// admin routes cannot disagree about what "valid" means.
|
||||
//
|
||||
// A code is single-use, expires, and can be revoked. It is stored hashed,
|
||||
// because an invite grants account creation and a database dump should not
|
||||
// hand someone a working one.
|
||||
// ============================================================
|
||||
|
||||
var crypto = require('crypto');
|
||||
|
||||
// Required lazily. The code-generation and hashing helpers are pure, and
|
||||
// requiring the database at module load made simply importing this file open a
|
||||
// connection — which is why a test that only checked those helpers hung.
|
||||
function db() {
|
||||
return require('../db/database');
|
||||
}
|
||||
|
||||
var DEFAULT_TTL_DAYS = 7;
|
||||
var MAX_TTL_DAYS = 90;
|
||||
|
||||
// Crockford-style: no I, L, O or U, so a code read aloud or copied off a screen
|
||||
// is hard to mistype and cannot spell anything unfortunate.
|
||||
var ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
function generateCode() {
|
||||
var bytes = crypto.randomBytes(16);
|
||||
var out = '';
|
||||
for (var i = 0; i < 16; i++) {
|
||||
out += ALPHABET[bytes[i] % ALPHABET.length];
|
||||
if (i % 4 === 3 && i !== 15) out += '-';
|
||||
}
|
||||
return out; // XXXX-XXXX-XXXX-XXXX
|
||||
}
|
||||
|
||||
function normalize(code) {
|
||||
return String(code == null ? '' : code).toUpperCase().replace(/[^0-9A-Z]/g, '');
|
||||
}
|
||||
|
||||
function hash(code) {
|
||||
return crypto.createHash('sha256').update(normalize(code)).digest('hex');
|
||||
}
|
||||
|
||||
function ttlDays(requested) {
|
||||
var days = Number(requested);
|
||||
if (!Number.isFinite(days) || days < 1) return DEFAULT_TTL_DAYS;
|
||||
return Math.min(Math.floor(days), MAX_TTL_DAYS);
|
||||
}
|
||||
|
||||
// Returns the code once. It is never recoverable afterwards — only its hash and
|
||||
// the last four characters are kept.
|
||||
async function create(adminUserId, options) {
|
||||
var opts = options || {};
|
||||
var code = generateCode();
|
||||
var days = ttlDays(opts.days);
|
||||
var note = String(opts.note || '').trim().slice(0, 200);
|
||||
await db().run(
|
||||
"INSERT INTO registration_invites (code_hash, code_hint, note, created_by, expires_at) " +
|
||||
"VALUES ($1, $2, $3, $4, NOW() + ($5 || ' days')::interval)",
|
||||
[hash(code), normalize(code).slice(-4), note, adminUserId, String(days)]
|
||||
);
|
||||
return { code: code, days: days, note: note };
|
||||
}
|
||||
|
||||
async function list() {
|
||||
return db().all(
|
||||
"SELECT i.id, i.code_hint, i.note, i.created_at, i.expires_at, i.used_at, i.revoked_at, " +
|
||||
" c.email AS created_by_email, u.email AS used_by_email, " +
|
||||
" CASE WHEN i.revoked_at IS NOT NULL THEN 'revoked' " +
|
||||
" WHEN i.used_at IS NOT NULL THEN 'used' " +
|
||||
" WHEN i.expires_at <= NOW() THEN 'expired' " +
|
||||
" ELSE 'active' END AS status " +
|
||||
"FROM registration_invites i " +
|
||||
"LEFT JOIN users c ON c.id = i.created_by " +
|
||||
"LEFT JOIN users u ON u.id = i.used_by " +
|
||||
"ORDER BY i.created_at DESC LIMIT 200", []
|
||||
);
|
||||
}
|
||||
|
||||
async function revoke(id, adminUserId) {
|
||||
// Revoking an already-used code would rewrite history, so only a live one.
|
||||
var result = await db().run(
|
||||
'UPDATE registration_invites SET revoked_at = NOW(), revoked_by = $1 ' +
|
||||
'WHERE id = $2 AND revoked_at IS NULL AND used_at IS NULL',
|
||||
[adminUserId, id]
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
var result = await db().run('DELETE FROM registration_invites WHERE id = $1', [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a code for a registration, atomically.
|
||||
*
|
||||
* The UPDATE carries every condition, so two registrations racing the same code
|
||||
* cannot both succeed: the second matches no row. Checking first and updating
|
||||
* after would leave exactly that gap.
|
||||
*
|
||||
* Returns the invite id, or null if the code is unusable for any reason —
|
||||
* unknown, expired, revoked or already used. The caller must not say which:
|
||||
* distinguishing them tells someone probing codes which guesses were closer.
|
||||
*/
|
||||
async function claim(code, userId) {
|
||||
var normalized = normalize(code);
|
||||
if (!normalized) return null;
|
||||
var rows = await db().all(
|
||||
'UPDATE registration_invites SET used_at = NOW(), used_by = $1 ' +
|
||||
'WHERE code_hash = $2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() ' +
|
||||
'RETURNING id',
|
||||
[userId, hash(normalized)]
|
||||
);
|
||||
return rows && rows.length ? rows[0].id : null;
|
||||
}
|
||||
|
||||
// True when a code must be supplied to register at all.
|
||||
async function inviteOnly() {
|
||||
return String(await db().getSetting('registration_invite_only') || 'false') === 'true';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TTL_DAYS,
|
||||
MAX_TTL_DAYS,
|
||||
generateCode,
|
||||
normalize,
|
||||
hash,
|
||||
ttlDays,
|
||||
create,
|
||||
list,
|
||||
revoke,
|
||||
remove,
|
||||
claim,
|
||||
inviteOnly
|
||||
};
|
||||
|
|
@ -150,3 +150,45 @@ test('.env.example documents every variable the app reads', () => {
|
|||
const missing = [...used].filter(name => !documented.has(name)).sort();
|
||||
assert.deepEqual(missing, [], 'undocumented variables: ' + missing.join(', '));
|
||||
});
|
||||
|
||||
// registration_enabled is open-or-closed. Invite-only is the middle setting,
|
||||
// and it has to hold up against someone probing codes.
|
||||
test('registration invites are single-use, expiring, and safe to store', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = f => fs.readFileSync(path.join(root, f), 'utf8');
|
||||
const invites = require('../src/utils/registrationInvites');
|
||||
const migration = read('migrations/1780100000000_registration-invites.js');
|
||||
const auth = read('src/routes/auth.js');
|
||||
|
||||
// A code must not be recoverable from a database dump.
|
||||
assert.match(migration, /code_hash TEXT NOT NULL UNIQUE/);
|
||||
assert.doesNotMatch(migration, /\bcode TEXT\b/, 'the code itself is never stored');
|
||||
assert.equal(invites.hash('abcd-efgh'), invites.hash('ABCDEFGH'), 'hashing normalises case and separators');
|
||||
assert.notEqual(invites.hash('a'), 'a');
|
||||
|
||||
// Generated codes avoid characters that are misread when typed from a screen.
|
||||
const code = invites.generateCode();
|
||||
assert.match(code, /^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);
|
||||
assert.doesNotMatch(code, /[ILOU]/, 'no I, L, O or U');
|
||||
assert.notEqual(invites.generateCode(), invites.generateCode());
|
||||
|
||||
// Expiry is bounded, and a nonsense value falls back rather than throwing.
|
||||
assert.equal(invites.ttlDays(undefined), invites.DEFAULT_TTL_DAYS);
|
||||
assert.equal(invites.ttlDays(0), invites.DEFAULT_TTL_DAYS);
|
||||
assert.equal(invites.ttlDays('nonsense'), invites.DEFAULT_TTL_DAYS);
|
||||
assert.equal(invites.ttlDays(10000), invites.MAX_TTL_DAYS);
|
||||
|
||||
// The claim is one conditional UPDATE, so two registrations racing the same
|
||||
// code cannot both succeed.
|
||||
const claim = read('src/utils/registrationInvites.js');
|
||||
assert.match(claim, /UPDATE registration_invites SET used_at = NOW\(\), used_by = \$1 /);
|
||||
assert.match(claim, /WHERE code_hash = \$2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW\(\)/);
|
||||
assert.match(claim, /RETURNING id/);
|
||||
|
||||
// Losing that race must not leave a free account behind.
|
||||
assert.match(auth, /if \(!claimedInvite\) \{[\s\S]{0,120}DELETE FROM users WHERE id = \?/);
|
||||
// And the rejection must not say which of the four reasons applied.
|
||||
assert.match(auth, /may have expired, been revoked, or already been used/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue