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: if sender_id in pending_codes and pending_codes[sender_id] == entered:
del pending_codes[sender_id] del pending_codes[sender_id]
app._pending_codes = pending_codes app._pending_codes = pending_codes
# User anlegen # User anlegen — phone_hash statt Klartext
existing = db.execute( existing = _lookup_user_by_phone(db, sender_id)
"SELECT id FROM users WHERE phone=?", (sender_id,)
).fetchone()
if not existing: if not existing:
phone_hash_val, phone_last4_val = _hash_phone(sender_id)
db.execute( db.execute(
"INSERT INTO users (phone, name, verified) VALUES (?,?,1)", "INSERT INTO users (phone, name, verified, phone_hash, phone_last4) VALUES (?,?,1,?,?)",
(sender_id, sender_name), (phone_last4_val, sender_name, phone_hash_val, phone_last4_val),
) )
db.commit() db.commit()
if META_TOKEN: if META_TOKEN:
@@ -1170,10 +1169,8 @@ def whatsapp_webhook():
pass pass
continue continue
# ── Cross-Channel: User-Profil zuordnen ────────────── # ── Cross-Channel: User-Profil zuordnen (via phone_hash) ──
user_row = db.execute( user_row = _lookup_user_by_phone(db, sender_id)
"SELECT id, name FROM users WHERE phone=?", (sender_id,)
).fetchone()
# Check for existing thread from this sender (5-min window) # Check for existing thread from this sender (5-min window)
recent = db.execute( recent = db.execute(
@@ -2352,6 +2349,7 @@ def submission_delete(sub_id: int):
</html>""" </html>"""
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Nutzerprofil-System (Cross-Channel Identity) # Nutzerprofil-System (Cross-Channel Identity)
@@ -2373,6 +2371,31 @@ def _check_password(password: str, stored: str) -> bool:
except Exception: except Exception:
return False 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: def _create_session(db, user_id: int, channel: str = "web") -> str:
"""Erstellt Session-Token (gültig 30 Tage).""" """Erstellt Session-Token (gültig 30 Tage)."""
token = secrets.token_urlsafe(32) 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{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} .btn:hover{background:#2a2caa}
.error{color:#ff453a;font-size:0.85rem;margin:0.5rem 0} .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{text-align:center;margin-top:1rem;font-size:0.85rem}
.link a{color:#5e5ce6} .link a{color:#5e5ce6}
</style></head><body> </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> <h1>📋 Registrieren</h1>
{error_html} {error_html}
<form method="post"> <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="name" placeholder="Dein Name" required>
<input name="password" type="password" placeholder="Passwort (min. 4 Zeichen)" required> <input name="password" type="password" placeholder="Passwort (min. 4 Zeichen)" required>
<button class="btn" type="submit">Registrieren</button> <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{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} .btn:hover{background:#2a2caa}
.error{color:#ff453a;font-size:0.85rem;margin:0.5rem 0} .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{text-align:center;margin-top:1rem;font-size:0.85rem}
.link a{color:#5e5ce6} .link a{color:#5e5ce6}
</style></head><body> </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> <h1>🔐 Login</h1>
{error_html} {error_html}
<form method="post"> <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> <input name="password" type="password" placeholder="Passwort" required>
<button class="btn" type="submit">Einloggen</button> <button class="btn" type="submit">Einloggen</button>
</form> </form>
@@ -2467,7 +2494,8 @@ input{width:100%;padding:0.6rem;margin:0.3rem 0;border-radius:6px;border:1px sol
<div class="card"> <div class="card">
<h1>👤 Mein Profil</h1> <h1>👤 Mein Profil</h1>
{msg_html} {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"> <form method="post">
<div class="field"><div class="label">Name</div> <div class="field"><div class="label">Name</div>
<input name="name" value="{name}" placeholder="Dein Name"></div> <input name="name" value="{name}" placeholder="Dein Name"></div>
@@ -2485,18 +2513,41 @@ def user_register():
db = get_db() db = get_db()
error = "" error = ""
if request.method == "POST": 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() name = request.form.get("name", "").strip()
password = request.form.get("password", "").strip() password = request.form.get("password", "").strip()
if not phone or not name or len(password) < 4: if not login_id or not name or len(password) < 4:
error = "Bitte Telefon, Name und Passwort (min. 4 Zeichen) angeben." error = "Bitte E-Mail/Telefon, Name und Passwort (min. 4 Zeichen) angeben."
elif db.execute("SELECT id FROM users WHERE phone=?", (phone,)).fetchone(): return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}",
error = "Diese Telefonnummer ist bereits registriert." "<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: 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) 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( db.execute(
"INSERT INTO users (phone, name, password_hash, verified) VALUES (?,?,?,1)", "INSERT INTO users (phone, name, password_hash, verified, email, phone_hash, phone_last4) "
(phone, name, pw_hash), "VALUES (?,?,?,1,?,?,?)",
(phone_display, name, pw_hash, email_val, phone_hash_val, phone_last4_val),
) )
db.commit() db.commit()
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0] user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
@@ -2505,9 +2556,8 @@ def user_register():
resp = make_response(_redir("/profile")) resp = make_response(_redir("/profile"))
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True) resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
return resp return resp
phone_val = request.form.get("phone", "") login_id = request.form.get("email", "")
error_html = "<div class='error'>" + error + "</div>" if error else "" return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}", "")
return _REGISTER_TPL.replace("{phone_val}", phone_val).replace("{error_html}", error_html)
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
@@ -2515,20 +2565,25 @@ def user_login():
db = get_db() db = get_db()
error = "" error = ""
if request.method == "POST": if request.method == "POST":
phone = request.form.get("phone", "").strip() login_id = request.form.get("login_id", "").strip()
password = request.form.get("password", "").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 ""): 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: else:
token = _create_session(db, user["id"]) token = _create_session(db, user["id"])
from flask import make_response, redirect as _redir from flask import make_response, redirect as _redir
resp = make_response(_redir("/profile")) resp = make_response(_redir("/profile"))
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True) resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
return resp 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 "" 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") @app.route("/logout")
@@ -2559,7 +2614,11 @@ def user_profile():
db.commit() db.commit()
msg = "✅ Profil aktualisiert." msg = "✅ Profil aktualisiert."
msg_html = "<div class='msg'>" + msg + "</div>" if msg else "" 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("/") @app.route("/")
@@ -4230,6 +4289,12 @@ def api_submit():
except Exception: except Exception:
return jsonify({'error': 'Datei konnte nicht gespeichert werden.'}), 500 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 # Validate halle
halle_valid = None halle_valid = None
if halle: if halle:
@@ -4251,9 +4316,10 @@ def api_submit():
db.execute( db.execute(
"""INSERT INTO submissions """INSERT INTO submissions
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, user_id) (channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, phone_hash, user_id)
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?)""", 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, ('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')), (_get_user_from_session(db) or {}).get('id')),
) )
db.commit() db.commit()