From 5fe36584ddc3c7acf21dee8700acfffc4f6653a8 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Tue, 18 Aug 2026 14:01:42 +0200 Subject: [PATCH] feat(cms): confirm-before-logout and branded login page - logout asks for confirmation, then invalidates the server-side session and navigates to a public /login page (logo, form, error messages) - POST /login validates credentials (timing-safe) and issues an HttpOnly SameSite=Strict session cookie (8h, Secure behind HTTPS); Basic Auth stays valid in parallel for curl/API use - unauthenticated browser navigations redirect to /login; non-browser requests keep the 401 challenge - failed form logins share the auth rate-limit budget with Basic attempts - save/publish redirect to /login when the session expired - refactor: templates and browser script extracted to scripts/cms-pages.js and scripts/cms-editor-client.js, session logic to scripts/cms-session.js (content-editor.js back under the 400-line limit) - user guide updated (login page, confirmation, 8h session) Closes MITHOME-58 --- content-editor.js | 550 ++++----------------------- docs/felhasznaloi-utmutato.md | 6 +- scripts/cms-editor-client.js | 305 +++++++++++++++ scripts/cms-pages.js | 237 ++++++++++++ scripts/cms-session.js | 62 +++ scripts/test-content-editor-login.js | 153 ++++++++ 6 files changed, 845 insertions(+), 468 deletions(-) create mode 100644 scripts/cms-editor-client.js create mode 100644 scripts/cms-pages.js create mode 100644 scripts/cms-session.js create mode 100644 scripts/test-content-editor-login.js diff --git a/content-editor.js b/content-editor.js index bff15cd..6b8c0c0 100644 --- a/content-editor.js +++ b/content-editor.js @@ -46,468 +46,12 @@ const FILE_LABELS = { hasznalatiFeltetelek: '⚖️ ÁSZF', }; -const HTML = (activeFile, jsonData, message, csrfToken) => ` - - - - - ${CMS_DEPLOY_ENV === 'staging' ? 'STAGING — ' : ''}mozdIT Content Editor - - - - -${CMS_DEPLOY_ENV === 'staging' ? '
⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti
' : ''} -${message ? `
${message.text}
` : ''} - -
-

mozdIT Content Editor

- — JSON fájlok szerkesztése vizuálisan -
- - - -
-

📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (➕). Mentés gomb menti a fájlt.

-
-
- -
- - - - 🔗 Előnézet → - ❓ Súgó - -
- - - - -`; - -// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme. -const GUIDE_PAGE = (contentHtml) => ` - - - - - mozdIT — Felhasználói útmutató - - - - -
-

mozdIT — Felhasználói útmutató

- ← Vissza a szerkesztőhöz -
- -
-${contentHtml} -
- - -`; // ── Server ─────────────────────────────────────────────────────────────────── @@ -545,9 +89,11 @@ function exceedsRateLimit(key, limit) { function hasValidCredentials(req) { const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':'); - if (!CMS_USER || !CMS_PASS || login.length !== CMS_USER.length || password.length !== CMS_PASS.length) return false; - return crypto.timingSafeEqual(Buffer.from(login), Buffer.from(CMS_USER)) - && crypto.timingSafeEqual(Buffer.from(password), Buffer.from(CMS_PASS)); + return validateLogin(login, password, CMS_USER, CMS_PASS); +} + +function isAuthenticated(req) { + return hasValidCredentials(req) || hasValidSession(req); } function hasValidCsrfToken(req) { @@ -589,13 +135,69 @@ const server = http.createServer(async (req, res) => { // no native logout. The client calls /logout with deliberately invalid credentials, // which overwrites the cached pair; the next navigation prompts for login again. // Deliberately exempt from the auth rate limiter so logging out never locks the user out. - if (u.pathname === '/logout') { + if (u.pathname === '/logout' && req.method === 'GET') { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); res.end('Logged out'); return; } - if (!hasValidCredentials(req)) { + // Public: logo asset for the login page. + if (req.method === 'GET' && u.pathname === '/logo.png') { + try { + const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', 'mozdit_logo.png')); + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' }); + res.end(logo); + } catch { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Public: styled login page (shown after logout and for unauthenticated browser visits). + if (req.method === 'GET' && u.pathname === '/login') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(LOGIN_PAGE()); + return; + } + + // Public: login form endpoint. Shares the auth rate-limit budget with failed + // Basic attempts so the form cannot be brute-forced either. + if (req.method === 'POST' && u.pathname === '/login') { + if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) { + writeAudit('login_failed', { clientAddress, result: 'rate_limited' }); + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) }); + res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' })); + return; + } + let body = ''; + let bodyTooLarge = false; + req.on('data', c => { + if (body.length + c.length > 1024) { bodyTooLarge = true; return; } + body += c; + }); + req.on('end', () => { + let user = ''; + let pass = ''; + try { + const parsed = JSON.parse(body); + user = String(parsed.user || ''); + pass = String(parsed.pass || ''); + } catch { /* empty credentials fail validation below */ } + if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) { + const isSecure = req.headers['x-forwarded-proto'] === 'https'; + writeAudit('login_success', { clientAddress }); + res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) }); + res.end(JSON.stringify({ ok: true })); + return; + } + writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' })); + }); + return; + } + + if (!isAuthenticated(req)) { const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS); writeAudit('authentication_failed', { clientAddress, limited }); if (limited) { @@ -603,6 +205,13 @@ const server = http.createServer(async (req, res) => { res.end('Too many authentication attempts'); return; } + // Browser navigations land on the styled login page; API/curl keeps the 401 challenge. + const acceptsHtml = String(req.headers.accept || '').includes('text/html'); + if (acceptsHtml && req.method === 'GET') { + res.writeHead(302, { Location: '/login' }); + res.end(); + return; + } res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); res.end('Access denied'); return; @@ -618,6 +227,15 @@ const server = http.createServer(async (req, res) => { return; } + // POST /logout — invalidate the browser session (Basic Auth stays valid by design). + if (req.method === 'POST' && u.pathname === '/logout') { + deleteSession(req); + writeAudit('logout', { clientAddress, user: CMS_USER }); + res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() }); + res.end(JSON.stringify({ ok: true })); + return; + } + // GET /guide — user guide rendered from the maintained markdown in the repo. if (req.method === 'GET' && u.pathname === '/guide') { let contentHtml; @@ -712,7 +330,7 @@ const server = http.createServer(async (req, res) => { } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN)); + res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs)); }); if (require.main === module) { diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index 2a112ae..8e0baca 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -36,8 +36,10 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció ### Belépés és kilépés - A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`). -- Belépés: a megadott **felhasználónév + jelszó** párossal (ezt az adminisztrátor adja). -- **🚪 Kilépés**: az alsó sáv gombja — anélkül jelentkezel ki, hogy be kellene zárnod a böngészőt. +- **Bejelentkezés**: a logós bejelentkező oldalon add meg a **felhasználónevet és jelszót** (ezt az adminisztrátor adja), majd kattints a Belépés gombra. +- Több **sikertelen próbálkozás** (5) után a belépés kb. 15 percre zárolásra kerül. +- A belépés **8 óráig érvényes** — ezután a CMS visszairányít a bejelentkező oldalra, ahol újra meg kell adni a jelszót. +- **🚪 Kilépés**: az alsó sáv gombja — egy megerősítő kérdés („Biztosan ki szeretnél lépni?") után kijelentkezel, és megjelenik a bejelentkező oldal. ### Felület áttekintés diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js new file mode 100644 index 0000000..8aed686 --- /dev/null +++ b/scripts/cms-editor-client.js @@ -0,0 +1,305 @@ +// Browser-side script of the Content Editor editor page. +// Inlined into the HTML template at render time by content-editor.js. +// Test coverage: scripts/test-content-editor-serializer.js runs this exact code. + +// ── Render ────────────────────────────────────────────────────────────────── + +function render(obj, container) { + container.innerHTML = ''; + renderObject(obj, container, ''); +} + +function renderObject(obj, container, prefix) { + for (const [key, val] of Object.entries(obj)) { + const path = prefix ? prefix + '.' + key : key; + if (Array.isArray(val)) { + renderArray(key, val, container, path); + } else if (typeof val === 'object' && val !== null) { + renderObject(val, container, path); + } else { + renderPrimitive(path, val, container); + } + } +} + +function renderPrimitive(path, val, container) { + const isLong = String(val).length > 80 || String(val).includes('<'); + const div = document.createElement('div'); + div.className = 'field'; + const type = val === null ? 'null' : typeof val; + let control; + if (type === 'boolean') { + control = ``; + } else if (type === 'number') { + control = ``; + } else { + control = isLong + ? `