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,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}