diff --git a/docs/authentication.md b/docs/authentication.md index 631029ad..c18616da 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -171,6 +171,10 @@ no request ever leaving the browser. ## Registration invitations +`registration_invite_only` sits between open and closed registration: people may +register, but only with a code. It is subordinate to `registration_enabled` — with +registration disabled entirely, nobody can register, code or not. + `registration_invites` holds codes that let someone register while `registration_invite_only` is on. Only the hash is stored; the code is shown once, at creation. diff --git a/docs/features-explained.md b/docs/features-explained.md index aa4c2de7..a9dde22b 100644 --- a/docs/features-explained.md +++ b/docs/features-explained.md @@ -28,7 +28,20 @@ The voice preview button calls LiteLLM TTS and plays the returned audio in the b ## Nextcloud WebDAV -Users can connect a Nextcloud account with an app password and export generated notes to it. +Two ways to connect. **Sign in with Nextcloud** uses Nextcloud's own Login Flow +v2: the person enters their server address, signs in on Nextcloud itself the way +they normally do — SSO, 2FA, a password manager — and Nextcloud generates an app +password for this app. We never see their real password and they never have to +find the app-password screen. An app password can still be pasted in directly, +under *Use an app password instead*. + +The poll token Nextcloud issues is a credential, so the polling happens on the +server; the browser holds only an opaque handle bound to its own account. Both +URLs the server hands back are checked and must be on the host the person typed. + +Once connected, generated notes and rendered resources can be saved to it. A +resource is sent as the **rendered file** — a PowerPoint or Word document, +exactly what the download would have produced — not as text. ## Documents And S3 diff --git a/migrations/1780900000000_drop-webdav-learning-path.js b/migrations/1780900000000_drop-webdav-learning-path.js new file mode 100644 index 00000000..5cf6f55f --- /dev/null +++ b/migrations/1780900000000_drop-webdav-learning-path.js @@ -0,0 +1,13 @@ +// users.webdav_learning_path was the folder the Learning Hub file browser opened +// first. The browser went with Learning Hub, the Settings field that set it has +// gone, and nothing reads the column. Nextcloud itself stays — connect, +// disconnect, and exporting a note or a rendered resource all use +// nextcloud_folder, which is a different column and still in use. + +exports.up = async function (pgm) { + pgm.sql('ALTER TABLE users DROP COLUMN IF EXISTS webdav_learning_path'); +}; + +exports.down = async function (pgm) { + pgm.sql('ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL'); +}; diff --git a/public/components/admin.html b/public/components/admin.html index 1c663b16..806b3d8e 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -30,7 +30,6 @@ Require an invitation code to register -

Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.

diff --git a/public/components/settings.html b/public/components/settings.html index b466cda9..de22f6b0 100644 --- a/public/components/settings.html +++ b/public/components/settings.html @@ -121,31 +121,36 @@

Nextcloud Integration

-

Export generated documents to your Nextcloud.

+

Save generated notes and teaching material to your own Nextcloud.

Not connected
+
- +
-
- - -
-
- - - Go to Nextcloud → Settings → Security → Create new app password -
- - - diff --git a/public/js/nextcloud.js b/public/js/nextcloud.js index 4c5ef659..6bdff768 100644 --- a/public/js/nextcloud.js +++ b/public/js/nextcloud.js @@ -16,21 +16,14 @@ fetch('/api/auth/me', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { - var pathSection = document.getElementById('nc-webdav-path-section'); if (data.user && data.user.nextcloud_url) { setNextcloudConnectedStatus(data.user.nextcloud_url, data.user.nextcloud_user); document.getElementById('nc-url').value = data.user.nextcloud_url; document.getElementById('nc-user').value = data.user.nextcloud_user; document.getElementById('btn-nc-disconnect').classList.remove('hidden'); - if (pathSection) { - pathSection.classList.remove('hidden'); - var pathEl = document.getElementById('nc-webdav-path'); - if (pathEl) pathEl.value = data.user.webdav_learning_path || ''; - } } else { document.getElementById('nc-status').textContent = 'Not connected'; document.getElementById('btn-nc-disconnect').classList.add('hidden'); - if (pathSection) pathSection.classList.add('hidden'); } }); } @@ -70,13 +63,60 @@ .then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); }); }); - document.getElementById('btn-nc-save-path').addEventListener('click', function() { - var p = (document.getElementById('nc-webdav-path').value || '').trim(); - fetch('/api/user/webdav-path', { - method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p }) - }).then(function(r) { return r.json(); }) - .then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); }) - .catch(function() { showToast('Failed to save', 'error'); }); + // ── Sign in with Nextcloud ──────────────────────────────────────────── + // Nextcloud's own login flow. The tab is opened from the click itself, before + // any await, or a popup blocker eats it — the request that fetches the URL is + // allowed to finish afterwards and point the already-open tab at it. + var pollTimer = null; + + function stopPolling(message, tone) { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + var status = document.getElementById('nc-login-flow-status'); + if (status) { + status.textContent = message || ''; + status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)'; + } + } + + document.getElementById('btn-nc-login-flow').addEventListener('click', function () { + var url = (document.getElementById('nc-url').value || '').trim(); + if (!url) { showToast('Enter your Nextcloud address first', 'error'); return; } + + var tab = window.open('', '_blank'); // claimed while the click is still trusted + stopPolling('Opening Nextcloud…'); + + fetch('/api/nextcloud/login-flow/start', { + method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ nextcloudUrl: url }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) throw new Error(data.error || 'Could not start sign-in'); + if (tab) tab.location.href = data.loginUrl; else window.open(data.loginUrl, '_blank'); + stopPolling('Waiting for you to finish signing in…'); + + var until = Date.now() + 10 * 60 * 1000; + pollTimer = setInterval(function () { + if (Date.now() > until) return stopPolling('Sign-in timed out. Try again.', 'bad'); + fetch('/api/nextcloud/login-flow/poll', { + method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ handle: data.handle }) + }) + .then(function (r) { return r.json(); }) + .then(function (result) { + if (result.connected) { + stopPolling('Connected as ' + result.username + '.', 'good'); + loadNextcloudStatus(); + showToast('Nextcloud connected', 'success'); + } else if (!result.success) { + stopPolling(result.error || 'Sign-in failed.', 'bad'); + } + }) + .catch(function () { /* a dropped poll is not a failed sign-in */ }); + }, 3000); + }) + .catch(function (err) { + if (tab) tab.close(); + stopPolling(err.message, 'bad'); + }); }); console.log('✅ Nextcloud module loaded'); diff --git a/server.js b/server.js index 5e1a0903..f4faaeb1 100644 --- a/server.js +++ b/server.js @@ -348,18 +348,6 @@ app.use('/api', require('./src/routes/dontMiss')); app.use('/api', require('./src/routes/patientEducation')); app.use('/api/user', require('./src/routes/userPreferences')); -// User-level preference: the Nextcloud folder this account browses from. -(function() { - var { authMiddleware } = require('./src/middleware/auth'); - var db = require('./src/db/database'); - app.post('/api/user/webdav-path', authMiddleware, require('./src/utils/policy').requireFeature('nextcloud'), async function(req, res) { - try { - await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]); - res.json({ success: true }); - } catch(e) { res.status(500).json({ error: e.message }); } - }); -})(); - app.get('/', (req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); diff --git a/src/db/database.js b/src/db/database.js index eaf69947..261d2e09 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -226,9 +226,6 @@ async function initDatabase() { // Add updated_by column to app_settings if upgrading try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {} - // The Nextcloud folder this account browses from. Named for the Learning - // Hub it was added for; kept because Settings still uses it to pick a folder. - try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {} // Add user preferences for STT model and TTS voice try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {} try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {} diff --git a/src/routes/nextcloud.js b/src/routes/nextcloud.js index db389be2..b71b4508 100644 --- a/src/routes/nextcloud.js +++ b/src/routes/nextcloud.js @@ -14,6 +14,129 @@ function davRoot(baseUrl, username) { return baseUrl + '/remote.php/dav/files/' + encodeURIComponent(username); } +// ── Login Flow v2 ─────────────────────────────────────────── +// Connecting without asking anyone to find the app-password screen. +// +// Nextcloud's own flow: we ask the server to start one, it gives back a URL and +// a poll token. The person opens that URL, signs in the way they normally do — +// SSO, 2FA, a password manager — and Nextcloud generates an app password for +// us. We never see their real password, and they never have to know what an app +// password is. +// +// The poll token is a credential, so the polling happens here rather than in the +// browser. The browser holds only an opaque handle bound to its own account. +// +// In memory, not in the database: a flow lives for minutes, and a restart +// mid-login is a retry, not a loss. One flow per account at a time — starting a +// second replaces the first, which is what "I clicked it again" means. +var loginFlows = new Map(); +var LOGIN_FLOW_TTL_MS = 20 * 60 * 1000; // Nextcloud expires its side at ~20 minutes + +function sweepLoginFlows() { + var now = Date.now(); + for (var [key, flow] of loginFlows) if (flow.expires < now) loginFlows.delete(key); +} + +router.post('/nextcloud/login-flow/start', authMiddleware, async function (req, res) { + try { + var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, ''); + if (!cleanUrl) return res.status(400).json({ error: 'Enter your Nextcloud address' }); + await assertSafeHttpsUrl(cleanUrl, 'Nextcloud URL'); + + var started = await axios({ + method: 'POST', url: cleanUrl + '/index.php/login/v2', + headers: { 'User-Agent': 'Ped-AI' }, timeout: 15000, maxRedirects: 0 + }); + var poll = started.data && started.data.poll; + var loginUrl = started.data && started.data.login; + if (!poll || typeof poll.token !== 'string' || typeof poll.endpoint !== 'string' || typeof loginUrl !== 'string') { + return res.status(502).json({ error: 'That address did not answer like a Nextcloud server.' }); + } + + // The server chooses both URLs, so both are checked. An endpoint pointing + // somewhere else would turn this into a request-forgery gadget aimed at + // whatever it named, with our credentials attached. + await assertSafeHttpsUrl(poll.endpoint, 'Nextcloud poll endpoint'); + await assertSafeHttpsUrl(loginUrl, 'Nextcloud login URL'); + var origin = new URL(cleanUrl).host; + if (new URL(poll.endpoint).host !== origin || new URL(loginUrl).host !== origin) { + return res.status(502).json({ error: 'That server pointed the login somewhere else. Not continuing.' }); + } + + sweepLoginFlows(); + var handle = require('crypto').randomUUID(); + loginFlows.set(handle, { + owner: req.user.id, url: cleanUrl, token: poll.token, endpoint: poll.endpoint, + folder: String(req.body.folder || '/PediatricScribe').replace(/\/+$/, ''), + expires: Date.now() + LOGIN_FLOW_TTL_MS + }); + // One at a time per account. + for (var [key, flow] of loginFlows) if (flow.owner === req.user.id && key !== handle) loginFlows.delete(key); + + res.json({ success: true, handle: handle, loginUrl: loginUrl }); + } catch (err) { + if (err.statusCode) return res.status(err.statusCode).json({ error: err.message }); + return res.status(502).json({ error: 'Could not reach that Nextcloud server.' }); + } +}); + +router.post('/nextcloud/login-flow/poll', authMiddleware, async function (req, res) { + try { + sweepLoginFlows(); + var flow = loginFlows.get(String(req.body.handle || '')); + // Bound to the account that started it: a handle is not a bearer token. + if (!flow || flow.owner !== req.user.id) { + return res.status(410).json({ error: 'That sign-in attempt has expired. Start again.' }); + } + + var answer; + try { + answer = await axios({ + method: 'POST', url: flow.endpoint, + data: 'token=' + encodeURIComponent(flow.token), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 15000, maxRedirects: 0 + }); + } catch (e) { + // 404 is Nextcloud's "not finished yet", which is the normal case while + // someone is still typing their password. + var status = e.response && e.response.status; + if (status === 404) return res.json({ success: true, pending: true }); + throw e; + } + + var appPassword = answer.data && answer.data.appPassword; + var loginName = answer.data && answer.data.loginName; + if (!appPassword || !loginName) return res.json({ success: true, pending: true }); + + // Nextcloud returns the server it actually signed them into; trust that over + // what was typed, but only once it has passed the same checks. + var serverUrl = String(answer.data.server || flow.url).replace(/\/+$/, ''); + await assertSafeHttpsUrl(serverUrl, 'Nextcloud URL'); + + var folder = flow.folder || '/PediatricScribe'; + var parts = folder.split('/').filter(Boolean); + var walked = ''; + for (var part of parts) { + walked += '/' + part; + try { + await axios({ method: 'MKCOL', url: davRoot(serverUrl, loginName) + walked + '/', + auth: { username: loginName, password: appPassword }, timeout: 15000, maxRedirects: 0 }); + } catch (e) {} + } + + await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?', + [serverUrl, loginName, cryptoUtil.encryptString(appPassword), folder, req.user.id]); + loginFlows.delete(String(req.body.handle)); + + logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud via login flow', req, { category: 'integration' }); + res.json({ success: true, connected: true, username: loginName, folder: folder }); + } catch (err) { + if (err.statusCode) return res.status(err.statusCode).json({ error: err.message }); + return res.status(502).json({ error: 'Could not finish connecting to Nextcloud.' }); + } +}); + router.post('/nextcloud/connect', authMiddleware, async function(req, res) { try { var { nextcloudUrl, username, appPassword, folder } = req.body; diff --git a/test/nextcloud-login-flow.test.js b/test/nextcloud-login-flow.test.js new file mode 100644 index 00000000..e834471f --- /dev/null +++ b/test/nextcloud-login-flow.test.js @@ -0,0 +1,90 @@ +// Nextcloud's own Login Flow v2: the person signs in on Nextcloud however they +// normally do — SSO, 2FA, a password manager — and Nextcloud hands back an app +// password it generated. We never see their real password and they never have +// to find the app-password screen. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); +const route = read('src/routes/nextcloud.js'); +const ui = read('public/js/nextcloud.js'); +const start = route.slice(route.indexOf("router.post('/nextcloud/login-flow/start'"), + route.indexOf("router.post('/nextcloud/login-flow/poll'")); +const poll = route.slice(route.indexOf("router.post('/nextcloud/login-flow/poll'"), + route.indexOf("router.post('/nextcloud/connect'")); + +test('the URLs the server hands back are checked, not followed on trust', () => { + // The remote server chooses both the login URL and the poll endpoint. An + // endpoint pointing elsewhere would make this a request-forgery gadget aimed + // at whatever it named, with our credentials attached. + assert.match(start, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/); + assert.match(start, /assertSafeHttpsUrl\(poll\.endpoint, 'Nextcloud poll endpoint'\)/); + assert.match(start, /assertSafeHttpsUrl\(loginUrl, 'Nextcloud login URL'\)/); + // And both must be on the host the person actually typed. + assert.match(start, /new URL\(poll\.endpoint\)\.host !== origin \|\| new URL\(loginUrl\)\.host !== origin/); + assert.match(start, /pointed the login somewhere else/); +}); + +test('the poll token never reaches the browser', () => { + // It is a credential. The browser gets an opaque handle instead. + assert.match(start, /res\.json\(\{ success: true, handle: handle, loginUrl: loginUrl \}\)/); + assert.doesNotMatch(start, /res\.json\([^)]*token/); + const flowHandler = ui.slice(ui.indexOf('btn-nc-login-flow')); + assert.match(flowHandler, /handle: data\.handle/); + // The manual fallback above it does send an app password — that is its whole + // point. This path must not. + assert.doesNotMatch(flowHandler, /poll\.token|appPassword/); +}); + +test('a handle belongs to the account that started the flow', () => { + assert.match(poll, /!flow \|\| flow\.owner !== req\.user\.id/); + assert.match(poll, /a handle is not a bearer token/); +}); + +test('"not finished yet" is a normal answer, not a failure', () => { + // Nextcloud answers 404 while the person is still typing their password. + assert.match(poll, /if \(status === 404\) return res\.json\(\{ success: true, pending: true \}\)/); +}); + +test('the server Nextcloud reports is re-checked before it is stored', () => { + assert.match(poll, /answer\.data\.server \|\| flow\.url/); + assert.match(poll, /assertSafeHttpsUrl\(serverUrl, 'Nextcloud URL'\)/); +}); + +test('the app password is encrypted at rest, like every other credential here', () => { + assert.match(poll, /cryptoUtil\.encryptString\(appPassword\)/); +}); + +test('a flow expires, and starting again replaces the old one', () => { + assert.match(route, /LOGIN_FLOW_TTL_MS = 20 \* 60 \* 1000/); + assert.match(route, /function sweepLoginFlows\(\)/); + assert.match(start, /flow\.owner === req\.user\.id && key !== handle\) loginFlows\.delete\(key\)/); +}); + +test('the tab is opened while the click is still trusted', () => { + // Opening after an await is what a popup blocker stops. + const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); + const open = handler.indexOf("window.open('', '_blank')"); + const fetchAt = handler.indexOf("fetch('/api/nextcloud/login-flow/start'"); + assert.ok(open > -1 && open < fetchAt, 'the tab must be claimed before the request'); + assert.match(handler, /a popup blocker eats it|still trusted/); +}); + +test('polling stops: on success, on failure, and on a deadline', () => { + const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); + assert.match(handler, /Date\.now\(\) > until\) return stopPolling/); + assert.match(handler, /if \(result\.connected\)/); + // stopPolling owns the interval, and is what every exit calls. + assert.match(ui, /function stopPolling\(message, tone\)[\s\S]{0,160}clearInterval\(pollTimer\)/); + // A dropped poll is not a failed sign-in. + assert.match(handler, /a dropped poll is not a failed sign-in/); +}); + +test('the app-password path is still there, as the fallback', () => { + assert.match(route, /router\.post\('\/nextcloud\/connect'/); + const settings = read('public/components/settings.html'); + assert.match(settings, /Use an app password instead/); + assert.match(settings, /id="btn-nc-login-flow"/); +}); diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index 21356b64..e3878707 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -355,8 +355,7 @@ test('feature routes deny before data/provider access; user status is nonsensiti for (const name of ['read_aloud', 'nextcloud', 'memories']) f.state.settings['feature.' + name] = 'false'; const endpoints = [ ['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'], - ['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'], - ['POST', '/api/user/webdav-path'] + ['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'] ]; for (const [method, route] of endpoints) { const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } }); @@ -457,7 +456,6 @@ test('enabled personal Nextcloud and memory CRUD remain usable through actual ha for (const [route, body] of [ ['/api/nextcloud/connect', { nextcloudUrl: 'https://cloud.example', username: 'synthetic', appPassword: 'synthetic-token' }], ['/api/nextcloud/export', { content: 'Synthetic document', type: 'note' }], - ['/api/user/webdav-path', { path: '/Synthetic' }], ['/api/nextcloud/disconnect', {}] ]) { const response = await request(route, { method: 'POST', role: 'user', body });