feat: local registration is closed for good, and its on/off switch is gone
Every account comes through One Sign In; an administrator sends an invitation link from there. The register route answers 410, the registration-status route and the admin toggle are removed, and the setting no longer exists in defaults, lockdown lists or seeds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
parent
f94c2b9f90
commit
4c699b86ef
15 changed files with 21 additions and 201 deletions
|
|
@ -69,9 +69,7 @@ var SETTINGS = {
|
|||
'models.disabled': '[]',
|
||||
'stt.model': 'e2e-stt',
|
||||
'tts.model': 'e2e-tts',
|
||||
'tts.voice': 'e2e-voice',
|
||||
// Registration open, so the auth-screen spec can see the register link.
|
||||
'registration_enabled': 'true'
|
||||
'tts.voice': 'e2e-voice'
|
||||
};
|
||||
|
||||
async function seedSettings() {
|
||||
|
|
|
|||
|
|
@ -41,17 +41,6 @@ test.describe('Unauthenticated auth screen', () => {
|
|||
await expect(page.locator('#btn-local-login')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the register link follows the registration setting', async ({ page }) => {
|
||||
// Hidden by default and shown only when registration is enabled, which the
|
||||
// seed turns on. No invitation field: invitations are the SSO's.
|
||||
await page.goto(E2E_BASE + '/');
|
||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||
await expect(page.locator('#show-register')).toBeVisible();
|
||||
await expect(page.locator('#register-form')).toHaveCount(1);
|
||||
await page.click('#show-register');
|
||||
await expect(page.locator('#reg-invite')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('register form DOM is wired correctly if manually unhidden', async ({ page }) => {
|
||||
await page.goto(E2E_BASE + '/');
|
||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||
|
|
|
|||
|
|
@ -22,16 +22,6 @@
|
|||
<!-- Registration. Accounts are made at the SSO (sso.pedshub.com) from an
|
||||
invitation link; the switch below only governs the local password
|
||||
form, which is refused anyway while sign-in is SSO-only. -->
|
||||
<details class="card" open>
|
||||
<summary class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></summary>
|
||||
<div class="admin-card-body" style="gap:14px;">
|
||||
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
||||
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
|
||||
<button id="btn-toggle-reg" class="btn-sm btn-primary" type="button">Toggle</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Users. The search and refresh sit in a toolbar under the heading
|
||||
rather than inside it: a control inside <summary> toggles the card
|
||||
|
|
|
|||
|
|
@ -55,10 +55,6 @@ function adminTabActive() {
|
|||
if (e.target.closest('#btn-refresh-users')) {
|
||||
loadUsers();
|
||||
}
|
||||
// Toggle registration
|
||||
if (e.target.closest('#btn-toggle-reg')) {
|
||||
toggleRegistration();
|
||||
}
|
||||
});
|
||||
|
||||
function loadAdmin() {
|
||||
|
|
@ -84,39 +80,10 @@ function adminTabActive() {
|
|||
el = document.getElementById('stat-api-today');
|
||||
if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—';
|
||||
|
||||
updateRegStatus(settings.registrationEnabled !== false);
|
||||
})
|
||||
.catch(function(err) { console.error('[Admin] Settings load failed:', err); });
|
||||
}
|
||||
|
||||
function updateRegStatus(enabled) {
|
||||
var text = document.getElementById('reg-status-text');
|
||||
var btn = document.getElementById('btn-toggle-reg');
|
||||
if (text) {
|
||||
text.innerHTML = 'Registration is currently <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
|
||||
}
|
||||
if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration';
|
||||
window._adminRegEnabled = enabled;
|
||||
}
|
||||
|
||||
function toggleRegistration() {
|
||||
var newVal = !window._adminRegEnabled;
|
||||
fetch('/api/admin/settings/registration', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ enabled: newVal })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
updateRegStatus(newVal);
|
||||
showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- USERS ----
|
||||
|
||||
|
|
@ -1919,7 +1886,7 @@ initImageSettings();
|
|||
// the buttons that only read.
|
||||
function lockdownFields(panel, state) {
|
||||
var editable = ['cms-ann', 'announcement',
|
||||
'admin-users-search', 'registration'];
|
||||
'admin-users-search'];
|
||||
panel.querySelectorAll('input, select, textarea, button').forEach(function(el) {
|
||||
var id = el.id || '';
|
||||
if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return;
|
||||
|
|
|
|||
|
|
@ -1231,16 +1231,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Check registration status — link is hidden by default, shown only when enabled
|
||||
fetch('/api/auth/registration-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.registrationEnabled) {
|
||||
var link = document.getElementById('show-register');
|
||||
if (link) link.style.display = '';
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
// The register link stays hidden: accounts are created through One Sign In.
|
||||
|
||||
console.log('[Auth] ✅ Module ready');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ if (!window.__fetchAuthIntercepted) {
|
|||
'/api/auth/login', '/api/auth/register', '/api/auth/logout',
|
||||
'/api/auth/forgot-password', '/api/auth/reset-password',
|
||||
'/api/auth/verify-email', '/api/auth/resend-verification',
|
||||
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
|
||||
'/api/auth/oidc', '/api/auth/oidc-status'
|
||||
]);
|
||||
boundary.logoutRequest = function(headers) {
|
||||
return rawFetch('/api/auth/logout', {
|
||||
|
|
|
|||
|
|
@ -325,8 +325,6 @@ async function initDatabase() {
|
|||
|
||||
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
||||
var defaults = [
|
||||
// Core
|
||||
['registration_enabled', 'true'],
|
||||
// Announcements
|
||||
['announcement.enabled', 'false'],
|
||||
['announcement.text', ''],
|
||||
|
|
|
|||
|
|
@ -174,37 +174,18 @@ router.post('/users/:id/reset-password', require('../utils/policy').requireLocal
|
|||
} catch (err) { res.status(500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// TOGGLE REGISTRATION
|
||||
// ============================================================
|
||||
router.post('/settings/registration', async function(req, res) {
|
||||
try {
|
||||
var { enabled } = req.body;
|
||||
var value = enabled ? 'true' : 'false';
|
||||
|
||||
await db.setSetting('registration_enabled', value);
|
||||
|
||||
logger.audit(req.user.id, 'admin_toggle_registration', 'Registration ' + (enabled ? 'enabled' : 'disabled'), req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, registrationEnabled: enabled, message: 'Registration ' + (enabled ? 'enabled' : 'disabled') });
|
||||
} catch (err) { res.status(500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET APP SETTINGS
|
||||
// ============================================================
|
||||
router.get('/settings', async function(req, res) {
|
||||
try {
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
|
||||
var todayApiCount = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings: {
|
||||
registrationEnabled: regEnabled !== 'false'
|
||||
},
|
||||
settings: {},
|
||||
stats: {
|
||||
totalUsers: userCount ? parseInt(userCount.count) : 0,
|
||||
totalApiCalls: apiCount ? parseInt(apiCount.count) : 0,
|
||||
|
|
|
|||
|
|
@ -246,7 +246,6 @@ router.post('/config/prompts/:key/restore', function(req, res) {
|
|||
router.post('/config/reset-defaults', async function(req, res) {
|
||||
try {
|
||||
var defaults = [
|
||||
['registration_enabled', 'true'],
|
||||
['announcement.enabled', 'false'],
|
||||
['announcement.text', ''],
|
||||
['announcement.type', 'info'],
|
||||
|
|
@ -973,7 +972,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.', 'clinical_assistant.', 'my_resources.', 'nextcloud.'];
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.', 'nextcloud.'];
|
||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||
if (!isAllowed) {
|
||||
return res.status(400).json({ error: 'Unknown config key' });
|
||||
|
|
|
|||
|
|
@ -173,89 +173,11 @@ async function sendEmail(to, subject, html) {
|
|||
}
|
||||
|
||||
// ============================================================
|
||||
// REGISTER (checks if registration is enabled)
|
||||
// REGISTER — closed. Every account is created through One Sign In
|
||||
// (authentik); an administrator sends an invitation link from there.
|
||||
// ============================================================
|
||||
router.post('/register', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
if (regEnabled === 'false') {
|
||||
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
|
||||
}
|
||||
|
||||
var { email, password, name, turnstileToken } = req.body;
|
||||
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
|
||||
|
||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
|
||||
|
||||
// Cloudflare Turnstile verification
|
||||
if (process.env.TURNSTILE_SECRET_KEY) {
|
||||
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification challenge' });
|
||||
var turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
|
||||
});
|
||||
var turnstileData = await turnstileRes.json();
|
||||
if (!turnstileData.success) {
|
||||
console.error('[Auth] Turnstile verification failed:', turnstileData['error-codes']);
|
||||
return res.status(400).json({ error: 'Bot verification failed. Please try again.' });
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) return res.status(400).json({ error: 'Email already registered' });
|
||||
|
||||
var hash = await passwords.hash(password);
|
||||
var verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
|
||||
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
|
||||
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
|
||||
);
|
||||
|
||||
var userId = result.lastInsertRowid;
|
||||
|
||||
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.';
|
||||
|
||||
await sendEmail(email, verifySubject, emailWrapper(
|
||||
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Welcome aboard, ${escHtml(name)}!</p>
|
||||
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(verifyBody).replace(/\n/g, '<br>')}</p>
|
||||
${btnHtml(verifyUrl, 'Verify My Email')}
|
||||
${linkFallback(verifyUrl)}
|
||||
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
|
||||
));
|
||||
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
|
||||
logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' });
|
||||
notifyNewRegistration(email, name);
|
||||
|
||||
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
|
||||
if (!smtpHost) {
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
|
||||
var token = signAuthToken(userId, req);
|
||||
var regSessionId = generateSessionId();
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
setAuthCookie(res, token);
|
||||
return res.json({
|
||||
success: true, token: token, sessionId: regSessionId,
|
||||
user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
|
||||
message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
|
||||
} catch (err) {
|
||||
console.error('[Auth] Register error:', err.message);
|
||||
res.status(500).json({ error: 'Registration failed. Please try again.' });
|
||||
}
|
||||
router.post('/register', function(req, res) {
|
||||
res.status(410).json({ error: 'Accounts are created through One Sign In. Ask an administrator for an invitation link.' });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -701,14 +623,6 @@ router.get('/me', authMiddleware, async (req, res) => {
|
|||
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
|
||||
});
|
||||
|
||||
// Check if registration is enabled (public endpoint)
|
||||
router.get('/registration-status', async (req, res) => {
|
||||
try {
|
||||
var enabled = await db.getSetting('registration_enabled');
|
||||
res.json({ registrationEnabled: enabled !== 'false' && !await isSSOOnly() });
|
||||
} catch (err) { res.json({ registrationEnabled: false }); }
|
||||
});
|
||||
|
||||
// Expose helpers for adminConfig test-email and email template loading
|
||||
module.exports = router;
|
||||
module.exports.__sendEmail = sendEmail;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ var LOCKED_PREFIXES = Object.freeze([
|
|||
// Day-to-day operation stays with ordinary admins.
|
||||
var EDITABLE_WHEN_LOCKED = Object.freeze([
|
||||
'announcement.',
|
||||
'registration_enabled',
|
||||
'feature.',
|
||||
'site.'
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -56,13 +56,8 @@ var operations = {
|
|||
public: true
|
||||
},
|
||||
'POST /api/auth/register': {
|
||||
summary: 'Create a local password account',
|
||||
description: 'Refused while sign-in is SSO-only; accounts are then created at the SSO from an invitation link.',
|
||||
public: true
|
||||
},
|
||||
'GET /api/auth/registration-status': {
|
||||
summary: 'Whether local registration is open',
|
||||
description: 'Read by the sign-in screen to decide whether to offer the register link.',
|
||||
summary: 'Closed',
|
||||
description: 'Always 410. Accounts are created through One Sign In from an invitation link an administrator sends.',
|
||||
public: true
|
||||
},
|
||||
'POST /api/auth/logout': { summary: 'End this session' },
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ test('admin lockdown refuses configuration writes at the server', () => {
|
|||
'tts.voice', 'stt.model', 'smtp.host', 'email.verify.subject']) {
|
||||
assert.equal(lockdown.isLocked(key, on), true, key + ' is locked');
|
||||
}
|
||||
for (const key of ['announcement.text', 'registration_enabled', 'feature.memories', 'site.name']) {
|
||||
for (const key of ['announcement.text', 'feature.memories', 'site.name']) {
|
||||
assert.equal(lockdown.isLocked(key, on), false, key + ' stays editable');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,10 +106,10 @@ test('metadata is keyed exactly as routes are mounted', () => {
|
|||
|
||||
test('a public operation says so, and is the only kind that does', () => {
|
||||
// Guessing this from middleware gets it wrong in the direction that hides a
|
||||
// hole, so it is stated per route. Registration status must be public — the
|
||||
// sign-in screen reads it before anyone has a session.
|
||||
// hole, so it is stated per route. Sign-in must be public — the sign-in
|
||||
// screen calls it before anyone has a session.
|
||||
const meta = require('../src/utils/openapiRoutes');
|
||||
assert.equal(meta.operations['GET /api/auth/registration-status'].public, true);
|
||||
assert.equal(meta.operations['POST /api/auth/login'].public, true);
|
||||
assert.equal(meta.operations['GET /api/health'].public, true);
|
||||
assert.equal(meta.operations['POST /api/auth/logout'].public, undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -244,13 +244,12 @@ test('OIDC refuses unsafe linking, disabled/mismatched identities, and session f
|
|||
test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
f.state.settings['oidc.disable_local_auth'] = 'true';
|
||||
for (const route of ['/api/auth/login', '/api/auth/register', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
||||
for (const route of ['/api/auth/login', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
||||
const response = await request(route, { method: 'POST', body: {}, authenticated: true });
|
||||
assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0);
|
||||
}
|
||||
assert.equal(f.state.writes.length, 0);
|
||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
|
||||
assert.equal((await request('/api/auth/registration-status')).data.registrationEnabled, false);
|
||||
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
|
||||
f.state.settings['oidc.enabled'] = 'false';
|
||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false);
|
||||
|
|
@ -261,14 +260,14 @@ test('active SSO-only policy denies local login/registration/credential creation
|
|||
assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
|
||||
});
|
||||
|
||||
test('local login and auto-verified registration never issue success/cookie on session insert failure', async t => {
|
||||
test('local login never issues success/cookie on session insert failure, and registration is closed', async t => {
|
||||
const f = fixture(); const request = await f.serve(t); f.state.sessionError = true;
|
||||
const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
||||
assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined);
|
||||
f.state.user = null;
|
||||
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 1);
|
||||
// Local registration is closed for good: it answers 410 before touching anything.
|
||||
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
|
||||
assert.equal(registration.status, 500); assert.equal(authCookies(registration).length, 0); assert.equal(registration.data.success, undefined);
|
||||
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 2);
|
||||
assert.equal(registration.status, 410); assert.equal(authCookies(registration).length, 0);
|
||||
});
|
||||
|
||||
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue