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

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number FROM users WHERE id=?",
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?",
(user_id,)
).fetchone()

View file

@ -565,6 +565,9 @@ def _migrate(conn_factory):
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
("users", "founder_number", "INTEGER"),
("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"),
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:

View file

@ -64,6 +64,19 @@ app = FastAPI(
redoc_url = None,
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["X-XSS-Protection"] = "1; mode=block"
return response
app.add_middleware(SecurityHeadersMiddleware)
# Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024

View file

@ -3,6 +3,7 @@
import os
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response, Depends
@ -77,6 +78,8 @@ async def register(data: RegisterRequest, response: Response, request: Request):
raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
if is_username_blocked(name):
raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.")
if len(data.password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.")
with db() as conn:
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
@ -247,7 +250,8 @@ async def verify_email(token: str):
@router.post("/resend-verification")
async def resend_verification(user=Depends(get_current_user)):
async def resend_verification(request: Request, user=Depends(get_current_user)):
rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
with db() as conn:
row = conn.execute(
"SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],)
@ -263,3 +267,65 @@ async def resend_verification(user=Depends(get_current_user)):
)
_send_verification_email(row["email"], row["name"], token)
return {"ok": True}
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
password: str
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw")
with db() as conn:
user = conn.execute(
"SELECT id, name FROM users WHERE email=?", (data.email,)
).fetchone()
# Immer OK zurückgeben — kein User-Enumeration
if user:
token = secrets.token_urlsafe(32)
expires = (datetime.utcnow() + timedelta(hours=2)).isoformat()
with db() as conn:
conn.execute(
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
app_url = os.getenv("APP_URL", "https://banyaro.app")
subject = "Ban Yaro — Passwort zurücksetzen"
body = (
f"Hallo {user['name']},\n\n"
"du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n"
f"Klicke hier um ein neues Passwort zu setzen:\n"
f"{app_url}/#reset-password?token={token}\n\n"
"Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n"
"Viele Grüße,\nDas Ban Yaro Team"
)
from routes.outreach import _send_smtp
try:
_send_smtp(data.email, subject, body, "support")
except Exception:
pass
return {"ok": True}
@router.post("/reset-password")
async def reset_password(data: ResetPasswordRequest, request: Request):
rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw")
if len(data.password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.")
with db() as conn:
user = conn.execute(
"SELECT id, password_reset_expires FROM users WHERE password_reset_token=?",
(data.token,)
).fetchone()
if not user:
raise HTTPException(400, "Ungültiger oder abgelaufener Link.")
if user["password_reset_expires"] < datetime.utcnow().isoformat():
raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.")
conn.execute(
"UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?",
(hash_password(data.password), user["id"])
)
return {"ok": True}

View file

@ -166,6 +166,8 @@ async def list_threads(
# ------------------------------------------------------------------
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.")
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
@ -304,6 +306,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre
# ------------------------------------------------------------------
@router.post("/threads/{thread_id}/posts", status_code=201)
async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:

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