diff --git a/backend/auth.py b/backend/auth.py index 942a3f1..b2736f5 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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() diff --git a/backend/database.py b/backend/database.py index 76c1c2c..5ea9f4a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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: diff --git a/backend/main.py b/backend/main.py index ddb4acc..e8720c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 7661c80..f810217 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -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} diff --git a/backend/routes/forum.py b/backend/routes/forum.py index b6d204f..0cfe1df 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -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: diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6937188..728628b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index a0f3a9a..7c3679d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -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 = `
@@ -1313,6 +1321,13 @@ window.Page_settings = (() => { +

+ +

`; } @@ -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: ` +
+

+ Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts. +

+
+ + +
+
`, + footer: ` + + `, + }); + 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 = ` +
+
+ Ban Yaro +

Neues Passwort

+

+ Wähle ein sicheres Passwort für deinen Account. +

+
+ +
+
+ +
+ + +
+ +
+
+ 🐾 Passwort-Vorschlag + +
+
+ + +
+
+
+ + +
+
+ `; + + _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 // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 9b0bb55..a3a1bc2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -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