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/routes/outreach.py b/backend/routes/outreach.py
index 11f4152..6ec066c 100644
--- a/backend/routes/outreach.py
+++ b/backend/routes/outreach.py
@@ -1,5 +1,6 @@
"""BAN YARO — Mailing (Admin)"""
+import imaplib
import os
import smtplib
import ssl
@@ -9,16 +10,21 @@ from email.utils import formataddr
from datetime import datetime
from typing import List, Optional
+import logging
+
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_admin
from database import db
-router = APIRouter()
+router = APIRouter()
+_log = logging.getLogger(__name__)
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
+_IMAP_HOST = os.getenv("IMAP_HOST", "mail.your-server.de")
+_IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
_ACCOUNTS = {
"partner": {
@@ -35,23 +41,73 @@ _ACCOUNTS = {
},
}
+# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot)
+_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
+
+
+def _imap_save_sent(msg_bytes: bytes, account: str):
+ acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
+ if not acc["user"] or not acc["pass"]:
+ _log.warning("IMAP: Account '%s' nicht konfiguriert, überspringe.", account)
+ return
+ try:
+ ctx = ssl.create_default_context()
+ with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap:
+ imap.login(acc["user"], acc["pass"])
+ _, raw_folders = imap.list()
+ available = [f.decode(errors="replace") for f in (raw_folders or [])]
+ _log.info("IMAP Ordner (%s): %s", account, available)
+
+ # Echten Ordnernamen aus LIST-Antwort extrahieren
+ # Format: '(\Flags) "." INBOX.Sent' → letztes Token
+ folder = None
+ for line in available:
+ name = line.rsplit('"." ', 1)[-1].strip().strip('"')
+ for candidate in _SENT_CANDIDATES:
+ if candidate.lower() in name.lower():
+ folder = name
+ break
+ if folder:
+ break
+ if not folder:
+ folder = "INBOX.Sent"
+ _log.info("IMAP: speichere in Ordner '%s' (%s)", folder, account)
+
+ typ, data = imap.append(
+ folder,
+ r"\Seen",
+ imaplib.Time2Internaldate(datetime.now().timestamp()),
+ msg_bytes,
+ )
+ _log.info("IMAP append: %s %s", typ, data)
+ except Exception as e:
+ _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
+
+
+def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart:
+ acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = formataddr((acc["name"], acc["from"]))
+ msg["To"] = to
+ msg["Reply-To"] = acc["from"]
+ msg.attach(MIMEText(body, "plain", "utf-8"))
+ return msg
+
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
- msg = MIMEMultipart("alternative")
- msg["Subject"] = subject
- msg["From"] = formataddr((acc["name"], acc["from"]))
- msg["To"] = to
- msg["Reply-To"] = acc["from"]
- msg.attach(MIMEText(body, "plain", "utf-8"))
+ msg = _build_message(to, subject, body, account)
+ msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
s.ehlo()
s.starttls(context=ctx)
s.login(acc["user"], acc["pass"])
- s.sendmail(acc["from"], [to], msg.as_bytes())
+ s.sendmail(acc["from"], [to], msg_bytes)
+ _imap_save_sent(msg_bytes, account)
# ------------------------------------------------------------------
diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg
index 2fcc061..3fcf69f 100644
--- a/backend/static/icons/phosphor.svg
+++ b/backend/static/icons/phosphor.svg
@@ -189,4 +189,5 @@
+ +
`; } @@ -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: ` + `, + 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 = ` ++ Wähle ein sicheres Passwort für deinen Account. +
+