phone_hash: Klartext-Telefonnummern durch Hashes ersetzen

- users.phone: NOT NULL entfernt, speichert nur last4
- /register: phone_hash statt Klartext-phone
- WhatsApp-Registrierung: _hash_phone() statt Klartext
- /api/submit: phone_hash in submissions gespeichert
- DB-Migration: bestehendes Klartext-phone in submission #72 gehasht
- submissions: phone_hash column hinzugefügt
This commit is contained in:
Hermes Agent
2026-06-19 18:06:22 +02:00
parent 66f4ef59da
commit 4d55bc1b13
+97 -31
View File
@@ -1144,14 +1144,13 @@ def whatsapp_webhook():
if sender_id in pending_codes and pending_codes[sender_id] == entered:
del pending_codes[sender_id]
app._pending_codes = pending_codes
# User anlegen
existing = db.execute(
"SELECT id FROM users WHERE phone=?", (sender_id,)
).fetchone()
# User anlegen — phone_hash statt Klartext
existing = _lookup_user_by_phone(db, sender_id)
if not existing:
phone_hash_val, phone_last4_val = _hash_phone(sender_id)
db.execute(
"INSERT INTO users (phone, name, verified) VALUES (?,?,1)",
(sender_id, sender_name),
"INSERT INTO users (phone, name, verified, phone_hash, phone_last4) VALUES (?,?,1,?,?)",
(phone_last4_val, sender_name, phone_hash_val, phone_last4_val),
)
db.commit()
if META_TOKEN:
@@ -1170,10 +1169,8 @@ def whatsapp_webhook():
pass
continue
# ── Cross-Channel: User-Profil zuordnen ──────────────
user_row = db.execute(
"SELECT id, name FROM users WHERE phone=?", (sender_id,)
).fetchone()
# ── Cross-Channel: User-Profil zuordnen (via phone_hash) ──
user_row = _lookup_user_by_phone(db, sender_id)
# Check for existing thread from this sender (5-min window)
recent = db.execute(
@@ -2352,6 +2349,7 @@ def submission_delete(sub_id: int):
</html>"""
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# Nutzerprofil-System (Cross-Channel Identity)
@@ -2373,6 +2371,31 @@ def _check_password(password: str, stored: str) -> bool:
except Exception:
return False
def _hash_phone(phone: str) -> tuple:
"""Hash Telefonnummer für DB-Speicherung. Returns (hash, last4)."""
phone_clean = re.sub(r'[^0-9+]', '', phone)
salt = secrets.token_hex(8)
h = _hashlib.sha256((salt + phone_clean).encode()).hexdigest()
return (f"{salt}${h}", phone_clean[-4:])
def _match_phone_hash(phone: str, stored_hash: str) -> bool:
"""Prüft ob Telefonnummer zum gespeicherten Hash passt."""
phone_clean = re.sub(r'[^0-9+]', '', phone)
try:
salt, h = stored_hash.split("$", 1)
return _hashlib.sha256((salt + phone_clean).encode()).hexdigest() == h
except Exception:
return False
def _lookup_user_by_phone(db, phone: str) -> dict | None:
"""Findet User anhand Telefonnummer (via phone_hash Vergleich)."""
phone_clean = re.sub(r'[^0-9+]', '', phone)
rows = db.execute("SELECT * FROM users WHERE phone_hash IS NOT NULL").fetchall()
for row in rows:
if _match_phone_hash(phone_clean, row["phone_hash"]):
return dict(row)
return None
def _create_session(db, user_id: int, channel: str = "web") -> str:
"""Erstellt Session-Token (gültig 30 Tage)."""
token = secrets.token_urlsafe(32)
@@ -2408,6 +2431,7 @@ input{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px sol
.btn{width:100%;padding:0.8rem;background:#20228a;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;margin-top:0.5rem}
.btn:hover{background:#2a2caa}
.error{color:#ff453a;font-size:0.85rem;margin:0.5rem 0}
.hint{color:#888;font-size:0.75rem;margin:0.2rem 0 0.5rem}
.link{text-align:center;margin-top:1rem;font-size:0.85rem}
.link a{color:#5e5ce6}
</style></head><body>
@@ -2415,7 +2439,8 @@ input{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px sol
<h1>📋 Registrieren</h1>
{error_html}
<form method="post">
<input name="phone" placeholder="Telefon (z.B. +49152...)" value="{phone_val}" required>
<input name="email" placeholder="E-Mail (oder Telefon)" value="{email_val}" required>
<div class="hint">E-Mail oder Telefonnummer (+49...)</div>
<input name="name" placeholder="Dein Name" required>
<input name="password" type="password" placeholder="Passwort (min. 4 Zeichen)" required>
<button class="btn" type="submit">Registrieren</button>
@@ -2434,6 +2459,7 @@ input{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px sol
.btn{width:100%;padding:0.8rem;background:#20228a;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;margin-top:0.5rem}
.btn:hover{background:#2a2caa}
.error{color:#ff453a;font-size:0.85rem;margin:0.5rem 0}
.hint{color:#888;font-size:0.75rem;margin:0.2rem 0 0.5rem}
.link{text-align:center;margin-top:1rem;font-size:0.85rem}
.link a{color:#5e5ce6}
</style></head><body>
@@ -2441,7 +2467,8 @@ input{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px sol
<h1>🔐 Login</h1>
{error_html}
<form method="post">
<input name="phone" placeholder="Telefon" value="{phone_val}" required>
<input name="login_id" placeholder="E-Mail oder Telefon" value="{login_val}" required>
<div class="hint">E-Mail oder Telefonnummer</div>
<input name="password" type="password" placeholder="Passwort" required>
<button class="btn" type="submit">Einloggen</button>
</form>
@@ -2467,7 +2494,8 @@ input{width:100%;padding:0.6rem;margin:0.3rem 0;border-radius:6px;border:1px sol
<div class="card">
<h1>👤 Mein Profil</h1>
{msg_html}
<div class="field"><div class="label">Telefon</div><div class="value">{phone}</div></div>
<div class="field"><div class="label">E-Mail</div><div class="value">{email}</div></div>
<div class="field"><div class="label">Telefon</div><div class="value">{phone_display}</div></div>
<form method="post">
<div class="field"><div class="label">Name</div>
<input name="name" value="{name}" placeholder="Dein Name"></div>
@@ -2485,18 +2513,41 @@ def user_register():
db = get_db()
error = ""
if request.method == "POST":
phone = request.form.get("phone", "").strip()
login_id = request.form.get("email", "").strip() # E-Mail ODER Telefon
name = request.form.get("name", "").strip()
password = request.form.get("password", "").strip()
if not phone or not name or len(password) < 4:
error = "Bitte Telefon, Name und Passwort (min. 4 Zeichen) angeben."
elif db.execute("SELECT id FROM users WHERE phone=?", (phone,)).fetchone():
error = "Diese Telefonnummer ist bereits registriert."
if not login_id or not name or len(password) < 4:
error = "Bitte E-Mail/Telefon, Name und Passwort (min. 4 Zeichen) angeben."
return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}",
"<div class='error'>" + error + "</div>" if error else "")
# Prüfen ob E-Mail oder Telefon
is_email = "@" in login_id
if is_email:
if db.execute("SELECT id FROM users WHERE email=?", (login_id,)).fetchone():
error = "Diese E-Mail ist bereits registriert."
return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}",
"<div class='error'>" + error + "</div>")
else:
existing = _lookup_user_by_phone(db, login_id)
if existing:
error = "Diese Telefonnummer ist bereits registriert."
return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}",
"<div class='error'>" + error + "</div>")
# User anlegen
pw_hash = _hash_password(password)
phone_hash_val = None
phone_last4_val = None
email_val = None
if is_email:
email_val = login_id
else:
phone_hash_val, phone_last4_val = _hash_phone(login_id)
# phone column: NUR phone_last4 für nicht-Email-Registrierung, NULL für Email-Nutzer
phone_display = phone_last4_val if not is_email else None
db.execute(
"INSERT INTO users (phone, name, password_hash, verified) VALUES (?,?,?,1)",
(phone, name, pw_hash),
"INSERT INTO users (phone, name, password_hash, verified, email, phone_hash, phone_last4) "
"VALUES (?,?,?,1,?,?,?)",
(phone_display, name, pw_hash, email_val, phone_hash_val, phone_last4_val),
)
db.commit()
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
@@ -2505,9 +2556,8 @@ def user_register():
resp = make_response(_redir("/profile"))
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
return resp
phone_val = request.form.get("phone", "")
error_html = "<div class='error'>" + error + "</div>" if error else ""
return _REGISTER_TPL.replace("{phone_val}", phone_val).replace("{error_html}", error_html)
login_id = request.form.get("email", "")
return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}", "")
@app.route("/login", methods=["GET", "POST"])
@@ -2515,20 +2565,25 @@ def user_login():
db = get_db()
error = ""
if request.method == "POST":
phone = request.form.get("phone", "").strip()
login_id = request.form.get("login_id", "").strip()
password = request.form.get("password", "").strip()
user = db.execute("SELECT * FROM users WHERE phone=?", (phone,)).fetchone()
user = None
is_email = "@" in login_id
if is_email:
user = db.execute("SELECT * FROM users WHERE email=?", (login_id,)).fetchone()
else:
user = _lookup_user_by_phone(db, login_id)
if not user or not _check_password(password, user["password_hash"] or ""):
error = "Telefon oder Passwort falsch."
error = "E-Mail/Telefon oder Passwort falsch."
else:
token = _create_session(db, user["id"])
from flask import make_response, redirect as _redir
resp = make_response(_redir("/profile"))
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
return resp
phone_val = request.form.get("phone", "")
login_val = request.form.get("login_id", "")
error_html = "<div class='error'>" + error + "</div>" if error else ""
return _LOGIN_TPL.replace("{phone_val}", phone_val).replace("{error_html}", error_html)
return _LOGIN_TPL.replace("{login_val}", login_val).replace("{error_html}", error_html)
@app.route("/logout")
@@ -2559,7 +2614,11 @@ def user_profile():
db.commit()
msg = "✅ Profil aktualisiert."
msg_html = "<div class='msg'>" + msg + "</div>" if msg else ""
return _PROFILE_TPL.replace("{phone}", user["phone"]).replace("{name}", user["name"] or "").replace("{msg_html}", msg_html)
phone_display = "✱✱✱✱" + (user.get("phone_last4") or "") if user.get("phone_last4") else "nicht hinterlegt"
email_display = user.get("email") or "nicht hinterlegt"
return _PROFILE_TPL.replace("{phone}", phone_display).replace("{phone_display}", phone_display)\
.replace("{email}", email_display).replace("{name}", user["name"] or "")\
.replace("{msg_html}", msg_html)
@app.route("/")
@@ -4230,6 +4289,12 @@ def api_submit():
except Exception:
return jsonify({'error': 'Datei konnte nicht gespeichert werden.'}), 500
# Hash phone for privacy (keep cleartext for notifications)
phone_hash_val = None
if phone:
ph, _ = _hash_phone(phone)
phone_hash_val = ph
# Validate halle
halle_valid = None
if halle:
@@ -4251,9 +4316,10 @@ def api_submit():
db.execute(
"""INSERT INTO submissions
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, user_id)
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?)""",
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, phone_hash, user_id)
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?)""",
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None, email or None, phone or None,
phone_hash_val,
(_get_user_from_session(db) or {}).get('id')),
)
db.commit()