Security: Passwort-Minimum, Rate Limits, Headers, Passwort-vergessen, email_verified

- Passwort-Minimum 8 Zeichen bei Register + Reset
- Rate Limit auf /resend-verification (3/h) und /forgot-password (3/h)
- Security-Headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy etc.
- email_verified in get_current_user SELECT ergänzt
- Forum: create_thread + create_post erfordern email_verified
- POST /auth/forgot-password + /auth/reset-password (2h-Token, via support@)
- DB-Migration: password_reset_token + password_reset_expires
- Frontend: Passwort-vergessen-Modal im Login, Reset-Formular mit Passphrase-Generator
- SW by-v576, APP_VER 553
This commit is contained in:
rene 2026-04-30 20:23:43 +02:00
parent 82d6417d09
commit 526ff42215
8 changed files with 232 additions and 4 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '553'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -824,6 +824,14 @@ const App = (() => {
});
}
// Passwort-Reset: #reset-password?token=xxx
if (hashPage === 'reset-password' && hashParams.token) {
sessionStorage.setItem('by_reset_token', hashParams.token);
history.replaceState(null, '', '/');
navigate('settings', false);
return;
}
// E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token}
if (hashParams.verified === '1' || hashParams.verified === 1) {
if (state.user) state.user.email_verified = 1;

View file

@ -1239,6 +1239,14 @@ window.Page_settings = (() => {
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
if (resetToken) {
sessionStorage.removeItem('by_reset_token');
_renderResetPassword(resetToken);
return;
}
_mode = mode;
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
@ -1313,6 +1321,13 @@ window.Page_settings = (() => {
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Anmelden
</button>
<p style="text-align:center;margin-top:var(--space-3);font-size:var(--text-xs)">
<button type="button" id="forgot-pw-link"
class="btn btn-ghost"
style="font-size:var(--text-xs);color:var(--c-text-muted);padding:0">
Passwort vergessen?
</button>
</p>
</form>
`;
}
@ -1414,6 +1429,38 @@ window.Page_settings = (() => {
function _bindLoginForm() {
_bindPwToggle('login-pw', 'login-pw-toggle');
document.getElementById('forgot-pw-link')?.addEventListener('click', () => {
const id = 'forgot-pw-modal';
UI.modal.open({
title: 'Passwort zurücksetzen',
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
</p>
<div>
<label class="form-label">E-Mail</label>
<input class="form-control" id="forgot-pw-email" type="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector(`[form="${id}"]`);
const email = document.getElementById('forgot-pw-email').value.trim();
await UI.asyncButton(btn, async () => {
await API.post('/auth/forgot-password', { email });
UI.modal.close();
UI.toast.success('Falls ein Account existiert, haben wir dir einen Link geschickt.');
});
});
});
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
@ -1610,6 +1657,93 @@ window.Page_settings = (() => {
setTimeout(remove, 12000);
}
// ----------------------------------------------------------
// PASSWORT ZURÜCKSETZEN
// ----------------------------------------------------------
function _renderResetPassword(token) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Neues Passwort</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
Wähle ein sicheres Passwort für deinen Account.
</p>
</div>
<form id="reset-pw-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label class="form-label">Neues Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" id="reset-pw-input"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required style="padding-right:var(--space-10)">
<button type="button" id="reset-pw-toggle"
class="btn btn-ghost btn-icon"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="reset-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="reset-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="reset-gen-use"
class="btn btn-sm btn-secondary" style="flex-shrink:0">
Übernehmen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-full">
Passwort speichern
</button>
</form>
</div>
`;
_bindPwToggle('reset-pw-input', 'reset-pw-toggle');
const phraseEl = document.getElementById('reset-gen-phrase');
const pwInput = document.getElementById('reset-pw-input');
const _refresh = () => { phraseEl.textContent = _genPassphrase(); };
_refresh();
document.getElementById('reset-gen-new')?.addEventListener('click', _refresh);
document.getElementById('reset-gen-use')?.addEventListener('click', () => {
pwInput.value = phraseEl.textContent;
pwInput.type = 'text';
});
document.getElementById('reset-pw-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const password = document.getElementById('reset-pw-input').value;
await UI.asyncButton(btn, async () => {
const res = await API.post('/auth/reset-password', { token, password });
if (res?.ok) {
UI.toast.success('Passwort geändert! Du kannst dich jetzt anmelden.');
_renderAuth('login');
}
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v575';
const CACHE_VERSION = 'by-v576';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache