Harden security, polish admin UI and document Windows WebDAV

- helmet, express-rate-limit (login/setup/customer-auth/me-password)
- Constant-time login (bcrypt always runs against a dummy hash on miss)
- Cookie secure flag follows req.protocol; trust proxy is env-gated to
  prevent X-Forwarded-For spoofing on direct exposure
- Drop SVG from accepted logo types (same-origin XSS) and resolve the
  served logo path against LOGO_DIR as defense in depth
- Self-service /me/password endpoint plus header button; bumps minimum
  password length to 8 across backend, prompts and edit modal
- Multer 1.x → 2.x for current security backports
- Customer edit modal replaces stacked prompts; user role is now an
  inline dropdown with a confirm-and-revert flow
- Windows .reg helper plus README section for Basic-Auth-over-HTTP and
  the http:// vs \\HOST@PORT\DavWWWRoot\ mapping syntax

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 12:53:13 +02:00
parent 3f86fca578
commit 182ef04cc5
7 changed files with 263 additions and 43 deletions
+114 -21
View File
@@ -160,6 +160,7 @@
.pill.archived { color: var(--text-dim); border-color: var(--border-strong);
background: color-mix(in srgb, var(--text-dim) 10%, transparent); }
tr.archived td:not(.cell-actions) { opacity: .55; }
select.role-select { padding: .25rem .5rem; font-size: .85rem; min-width: 9rem; }
.small { font-size: .8rem; color: var(--text-muted); }
.muted { color: var(--text-muted); }
@@ -220,8 +221,8 @@
<form id="setupForm">
<div class="field"><label>Benutzername</label>
<input name="username" required pattern="[A-Za-z0-9._-]+" autocomplete="username" /></div>
<div class="field"><label>Passwort (min. 6 Zeichen)</label>
<input name="password" type="password" required minlength="6" autocomplete="new-password" /></div>
<div class="field"><label>Passwort (min. 8 Zeichen)</label>
<input name="password" type="password" required minlength="8" autocomplete="new-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Admin anlegen & einloggen</button></div>
<div class="err" id="setupErr"></div>
</form>
@@ -253,6 +254,7 @@
</div>
<div class="nav-right">
<span id="whoami"></span>
<button id="changePwBtn" class="btn sm">Passwort ändern</button>
<button id="logoutBtn" class="btn sm">Logout</button>
</div>
</nav>
@@ -299,7 +301,7 @@
<h3 class="card-title">Benutzer anlegen</h3>
<form id="createUserForm" class="form-row">
<div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div>
<div><label>Passwort</label><input name="password" required minlength="6" type="text" /></div>
<div><label>Passwort</label><input name="password" required minlength="8" type="text" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div>
<div><label>Rolle</label>
<select name="role">
@@ -332,10 +334,10 @@
<div class="placeholder">kein Logo</div>
</div>
<div style="flex:1">
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif" style="display:none" />
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none" />
<button class="btn" id="logoUploadBtn" type="button">Logo hochladen</button>
<button class="btn danger" id="logoDeleteBtn" type="button" style="margin-left:.25rem">Entfernen</button>
<div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, SVG, WEBP, GIF.</div>
<div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, WEBP, GIF.</div>
</div>
</div>
<div class="field"><label>Breite (px) — 0 = automatisch</label>
@@ -413,6 +415,36 @@
</div>
</div>
<!-- EDIT CUSTOMER MODAL -->
<div class="modal" id="editCustomerModal">
<div class="modal-inner">
<h3 id="editCustomerTitle">Kunde bearbeiten</h3>
<form id="editCustomerForm">
<div class="field"><label>E-Mail</label>
<input name="email" type="email" placeholder="leer = entfernen" /></div>
<div class="field"><label>Passwort (leer = unverändert, min. 8 Zeichen)</label>
<input name="password" type="text" minlength="8" autocomplete="new-password" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_password" style="width:auto" />
Vorhandenes Passwort entfernen
</label>
<div class="field" style="margin-top:1rem"><label>Ablaufdatum</label>
<input name="expires_at" type="datetime-local" /></div>
<label style="display:flex; gap:.4rem; align-items:center; font-weight:normal; color:var(--text); margin-top:.4rem">
<input type="checkbox" name="clear_expires" style="width:auto" />
Ablauf entfernen (unbegrenzt gültig)
</label>
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
<button type="button" class="btn" id="editCustomerCancel">Abbrechen</button>
<button type="submit" class="btn primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- ACCESS MODAL -->
<div class="modal" id="accessModal">
<div class="modal-inner">
@@ -527,6 +559,19 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
});
document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); };
document.getElementById('changePwBtn').onclick = async () => {
const oldPw = prompt('Aktuelles Passwort:');
if (!oldPw) return;
const newPw = prompt('Neues Passwort (min. 8 Zeichen):');
if (!newPw) return;
if (newPw.length < 8) return alert('Passwort zu kurz.');
const repeat = prompt('Neues Passwort wiederholen:');
if (newPw !== repeat) return alert('Eingaben stimmen nicht überein.');
try {
await api.send('POST', '/me/password', { old_password: oldPw, new_password: newPw });
alert('Passwort geändert. Andere Sitzungen wurden abgemeldet.');
} catch (ex) { alert('Fehler: ' + ex.message); }
};
// Tabs
document.querySelectorAll('.tab').forEach(btn => {
@@ -543,8 +588,10 @@ document.querySelectorAll('.tab').forEach(btn => {
});
// Customers
let customersCache = [];
async function loadCustomers() {
const data = await api.get('/customers');
customersCache = data;
const rows = document.getElementById('customerRows');
const adminOnly = me.role === 'admin';
rows.innerHTML = '';
@@ -610,14 +657,8 @@ document.getElementById('customerRows').addEventListener('click', async (e) => {
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers();
}
if (t.dataset.edit) {
const em = prompt('E-Mail (leer = unverändert, "-" = entfernen):', ''); if (em === null) return;
const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', ''); if (pw === null) return;
const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return;
const body = {};
if (em === '-') body.email = ''; else if (em) body.email = em;
if (pw === '-') body.clear_password = true; else if (pw) body.password = pw;
if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp);
await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers();
const c = customersCache.find(x => String(x.id) === t.dataset.edit);
if (c) openEditCustomer(c);
}
if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name);
});
@@ -642,15 +683,19 @@ async function loadUsers() {
rows.innerHTML = '';
for (const u of data) {
const tr = document.createElement('tr');
const roleSel = `
<select data-rolesel="${u.id}" data-prev="${u.role}" class="role-select">
<option value="staff" ${u.role === 'staff' ? 'selected' : ''}>Sachbearbeiter</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>`;
tr.innerHTML = `
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td class="small">${u.email ? esc(u.email) : ''}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></td>
<td>${roleSel}</td>
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
<td class="cell-actions">
<button class="btn sm" data-email="${u.id}">E-Mail</button>
<button class="btn sm" data-pw="${u.id}">Passwort</button>
<button class="btn sm" data-role="${u.id}" data-current="${u.role}">Rolle</button>
<button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button>
</td>`;
rows.appendChild(tr);
@@ -665,19 +710,32 @@ document.getElementById('userRows').addEventListener('click', async (e) => {
await api.send('PATCH', `/users/${t.dataset.email}`, { email: em || '' }); loadUsers();
}
if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return;
const pw = prompt('Neues Passwort (min. 8 Zeichen):', ''); if (!pw) return;
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers();
}
if (t.dataset.role) {
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); loadUsers();
}
if (t.dataset.deluser && confirm(`Benutzer "${t.dataset.name}" löschen?`)) {
await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers();
}
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('userRows').addEventListener('change', async (e) => {
const sel = e.target;
if (!sel.dataset || !sel.dataset.rolesel) return;
const newRole = sel.value;
const prev = sel.dataset.prev;
if (newRole === prev) return;
if (!confirm(`Rolle wirklich auf "${newRole}" ändern?`)) {
sel.value = prev;
return;
}
try {
await api.send('PATCH', `/users/${sel.dataset.rolesel}`, { role: newRole });
sel.dataset.prev = newRole;
} catch (ex) {
alert('Fehler: ' + ex.message);
sel.value = prev;
}
});
document.getElementById('createUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
try { await api.send('POST', '/users', Object.fromEntries(new FormData(e.target))); e.target.reset(); loadUsers(); }
@@ -829,6 +887,41 @@ document.getElementById('logoDeleteBtn').onclick = async () => {
updateLogoPreview('');
};
// Edit Customer modal
let editingCustomerId = null;
function dtLocal(ms) {
const d = new Date(ms);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function openEditCustomer(c) {
editingCustomerId = c.id;
document.getElementById('editCustomerTitle').textContent = `Bearbeiten ${c.name}`;
const f = document.getElementById('editCustomerForm');
f.email.value = c.email || '';
f.password.value = '';
f.clear_password.checked = false;
f.clear_expires.checked = false;
f.expires_at.value = c.expires_at ? dtLocal(c.expires_at) : '';
document.getElementById('editCustomerModal').classList.add('open');
}
document.getElementById('editCustomerCancel').onclick = () =>
document.getElementById('editCustomerModal').classList.remove('open');
document.getElementById('editCustomerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = { email: (fd.get('email') || '').trim() };
if (fd.get('clear_password')) body.clear_password = true;
else if (fd.get('password')) body.password = fd.get('password');
if (fd.get('clear_expires')) body.expires_at = null;
else if (fd.get('expires_at')) body.expires_at = new Date(fd.get('expires_at')).getTime();
try {
await api.send('PATCH', `/customers/${editingCustomerId}`, body);
document.getElementById('editCustomerModal').classList.remove('open');
loadCustomers();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
// Access modal
let accessCustomerId = null;
async function openAccessModal(id, name) {