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:
parent
82d6417d09
commit
526ff42215
8 changed files with 232 additions and 4 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue