Kansho/backend/account_access.py
Lars 89d149684e
Some checks failed
Deploy Development / deploy (push) Successful in 58s
Test Suite / pytest-backend (push) Failing after 3m11s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add username login and expiring reset links so accounts stay recoverable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:00:17 +02:00

469 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Login identifiers, address verification, and one-time reset links."""
from __future__ import annotations
import hashlib
import os
import re
import secrets
import uuid
from datetime import datetime, timedelta
from fastapi import HTTPException
from auth import hash_password
from db import row_to_dict
USERNAME_RE = re.compile(r"^[a-z0-9._]{3,32}$")
PUBLIC_COLUMNS = (
"id",
"email",
"name",
"role",
"status",
"tier_id",
"created",
"username",
"secondary_email",
"email_verified_at",
"secondary_email_verified_at",
"pending_email",
"pending_secondary_email",
)
FORGOT_MESSAGE = (
"Wenn ein Konto zu dieser Angabe existiert, wurde ein Link an die verifizierten Adressen gesendet."
)
RESET_MINUTES = 60
VERIFY_HOURS = 24
LINK_LIMIT = 3
def utc_stamp(moment: datetime | None = None) -> str:
current = moment or datetime.utcnow()
return current.strftime("%Y-%m-%d %H:%M:%S")
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def app_base_url() -> str:
return (os.environ.get("APP_URL") or "http://localhost:5188").rstrip("/")
def profile_view(row: dict | None) -> dict | None:
if not row:
return None
data = {key: row.get(key) for key in PUBLIC_COLUMNS}
data["email_verified"] = bool(row.get("email_verified_at"))
data["secondary_email_verified"] = bool(row.get("secondary_email_verified_at"))
return data
def normalize_email(value: str) -> str:
email = (value or "").strip().lower()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email or email.count("@") != 1:
raise HTTPException(400, "E-Mail-Adresse ist ungültig")
local, domain = email.split("@", 1)
if not local or "." not in domain:
raise HTTPException(400, "E-Mail-Adresse ist ungültig")
return email
def normalize_username(value: str) -> str:
username = (value or "").strip().lower()
if not USERNAME_RE.match(username):
raise HTTPException(400, "Benutzername: 3–32 Zeichen, Buchstaben, Ziffern, Punkt oder Unterstrich")
return username
def find_by_login(conn, identifier: str) -> dict | None:
key = (identifier or "").strip().lower()
if not key:
return None
return row_to_dict(
conn.execute(
"""
SELECT * FROM profiles
WHERE lower(email) = ? OR lower(COALESCE(username, '')) = ?
""",
(key, key),
).fetchone()
)
def _owner_of_address(conn, email: str, exclude_id: str | None) -> dict | None:
return row_to_dict(
conn.execute(
"""
SELECT id FROM profiles
WHERE (
lower(email) = ?
OR lower(COALESCE(secondary_email, '')) = ?
OR lower(COALESCE(pending_email, '')) = ?
OR lower(COALESCE(pending_secondary_email, '')) = ?
)
AND id != COALESCE(?, '')
""",
(email, email, email, email, exclude_id or ""),
).fetchone()
)
def assert_address_free(conn, email: str, exclude_id: str | None) -> None:
if _owner_of_address(conn, email, exclude_id):
raise HTTPException(409, "E-Mail ist bereits vergeben")
def assert_username_free(conn, username: str, exclude_id: str | None) -> None:
row = conn.execute(
"""
SELECT id FROM profiles
WHERE lower(COALESCE(username, '')) = ? AND id != COALESCE(?, '')
""",
(username, exclude_id or ""),
).fetchone()
if row:
raise HTTPException(409, "Benutzername ist bereits vergeben")
def _recent_count(conn, table: str, profile_id: str) -> int:
cutoff = utc_stamp(datetime.utcnow() - timedelta(hours=1))
if table not in {"password_reset_tokens", "email_verification_tokens"}:
raise HTTPException(500, "Unbekannte Token-Tabelle")
row = conn.execute(
f"SELECT COUNT(*) AS n FROM {table} WHERE profile_id = ? AND created > ?",
(profile_id, cutoff),
).fetchone()
return int(row["n"])
def issue_password_reset(conn, profile_id: str) -> str | None:
if _recent_count(conn, "password_reset_tokens", profile_id) >= LINK_LIMIT:
return None
raw = secrets.token_urlsafe(32)
now = datetime.utcnow()
conn.execute(
"""
INSERT INTO password_reset_tokens (id, profile_id, token_hash, expires_at, created)
VALUES (?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
hash_token(raw),
utc_stamp(now + timedelta(minutes=RESET_MINUTES)),
utc_stamp(now),
),
)
return raw
def issue_verification(conn, profile_id: str, purpose: str, email: str) -> str | None:
if purpose not in {"primary", "secondary"}:
raise HTTPException(400, "Unbekannter Verifikationszweck")
if _recent_count(conn, "email_verification_tokens", profile_id) >= LINK_LIMIT:
return None
raw = secrets.token_urlsafe(32)
now = datetime.utcnow()
conn.execute(
"""
INSERT INTO email_verification_tokens
(id, profile_id, purpose, email, token_hash, expires_at, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
profile_id,
purpose,
email,
hash_token(raw),
utc_stamp(now + timedelta(hours=VERIFY_HOURS)),
utc_stamp(now),
),
)
return raw
def reset_link(token: str) -> str:
return f"{app_base_url()}/reset-password?token={token}"
def verify_link(token: str) -> str:
return f"{app_base_url()}/verify-email?token={token}"
def reset_message(token: str) -> tuple[str, str]:
body = (
"Über diesen Link setzt du das Passwort für Kanshō zurück.\n\n"
f"{reset_link(token)}\n\n"
"Der Link gilt 60 Minuten und kann nur einmal verwendet werden. "
"Wenn du das nicht angefordert hast, ignoriere diese Nachricht."
)
return "Kanshō – Passwort zurücksetzen", body
def verify_message(token: str) -> tuple[str, str]:
body = (
"Über diesen Link bestätigst du eine E-Mail-Adresse für Kanshō.\n\n"
f"{verify_link(token)}\n\n"
"Der Link gilt 24 Stunden und kann nur einmal verwendet werden."
)
return "Kanshō – E-Mail-Adresse bestätigen", body
def change_notice() -> tuple[str, str]:
body = (
"Für dieses Kanshō-Konto wurde eine Änderung der E-Mail-Adresse angefordert. "
"Wenn du das nicht warst, setze das Passwort zurück."
)
return "Kanshō – Änderung der E-Mail-Adresse", body
def verified_reset_recipients(profile: dict) -> list[str]:
recipients: list[str] = []
if profile.get("email_verified_at") and profile.get("email"):
recipients.append(profile["email"])
if profile.get("secondary_email_verified_at") and profile.get("secondary_email"):
recipients.append(profile["secondary_email"])
return recipients
def admin_reset_recipients(profile: dict) -> list[str]:
recipients: list[str] = []
if profile.get("email"):
recipients.append(profile["email"])
if profile.get("secondary_email_verified_at") and profile.get("secondary_email"):
recipients.append(profile["secondary_email"])
return recipients
def notice_recipients(profile: dict) -> list[str]:
return verified_reset_recipients(profile)
def load_profile(conn, profile_id: str) -> dict | None:
return row_to_dict(conn.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,)).fetchone())
def consume_password_reset(conn, token: str, password: str) -> None:
if len(password or "") < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
now = utc_stamp()
row = row_to_dict(
conn.execute(
"""
SELECT * FROM password_reset_tokens
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
""",
(hash_token(token.strip()), now),
).fetchone()
)
if not row:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
conn.execute(
"UPDATE profiles SET password_hash = ? WHERE id = ?",
(hash_password(password), row["profile_id"]),
)
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (row["profile_id"],))
def consume_verification(conn, token: str) -> dict:
now = utc_stamp()
row = row_to_dict(
conn.execute(
"""
SELECT * FROM email_verification_tokens
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
""",
(hash_token(token.strip()), now),
).fetchone()
)
if not row:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
profile = load_profile(conn, row["profile_id"])
if not profile:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
purpose = row["purpose"]
address = row["email"]
if purpose == "primary":
pending = (profile.get("pending_email") or "").strip().lower()
current = (profile.get("email") or "").strip().lower()
if pending:
if address != pending:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
assert_address_free(conn, address, profile["id"])
conn.execute(
"""
UPDATE profiles
SET email = ?, email_verified_at = ?, pending_email = NULL
WHERE id = ?
""",
(address, now, profile["id"]),
)
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile["id"],))
elif address == current:
conn.execute(
"UPDATE profiles SET email_verified_at = ? WHERE id = ?",
(now, profile["id"]),
)
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
elif purpose == "secondary":
pending = (profile.get("pending_secondary_email") or "").strip().lower()
current = (profile.get("secondary_email") or "").strip().lower()
if pending:
if address != pending:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
assert_address_free(conn, address, profile["id"])
conn.execute(
"""
UPDATE profiles
SET secondary_email = ?, secondary_email_verified_at = ?, pending_secondary_email = NULL
WHERE id = ?
""",
(address, now, profile["id"]),
)
elif current and address == current:
conn.execute(
"UPDATE profiles SET secondary_email_verified_at = ? WHERE id = ?",
(now, profile["id"]),
)
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
else:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
conn.execute(
"UPDATE email_verification_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
updated = load_profile(conn, profile["id"])
viewed = profile_view(updated)
if viewed is None:
raise HTTPException(400, "Ungültiger oder abgelaufener Link")
return viewed
def _queue_verification(conn, profile: dict, purpose: str, address: str) -> tuple[str, str] | None:
raw = issue_verification(conn, profile["id"], purpose, address)
if not raw:
return None
return verify_message(raw)
def apply_identifier_edits(
conn,
profile: dict,
*,
username: str | None = None,
email: str | None = None,
secondary_email: str | None = None,
username_set: bool = False,
email_set: bool = False,
secondary_set: bool = False,
) -> list[tuple[list[str], str, str]]:
"""Apply username and address edits. Returns mails as (recipients, subject, body)."""
jobs: list[tuple[list[str], str, str]] = []
if username_set:
if username is None or username.strip() == "":
conn.execute("UPDATE profiles SET username = NULL WHERE id = ?", (profile["id"],))
else:
cleaned = normalize_username(username)
assert_username_free(conn, cleaned, profile["id"])
conn.execute("UPDATE profiles SET username = ? WHERE id = ?", (cleaned, profile["id"]))
profile = load_profile(conn, profile["id"]) or profile
if email_set and email is not None:
cleaned = normalize_email(email)
current = (profile.get("email") or "").strip().lower()
secondary = (profile.get("secondary_email") or "").strip().lower()
pending_secondary = (profile.get("pending_secondary_email") or "").strip().lower()
if cleaned == current:
pass
else:
if cleaned == secondary or cleaned == pending_secondary:
raise HTTPException(409, "E-Mail ist bereits vergeben")
assert_address_free(conn, cleaned, profile["id"])
conn.execute("UPDATE profiles SET pending_email = ? WHERE id = ?", (cleaned, profile["id"]))
queued = _queue_verification(conn, profile, "primary", cleaned)
if queued:
subject, body = queued
jobs.append(([cleaned], subject, body))
notice = notice_recipients(profile)
notice = [item for item in notice if item != cleaned]
if notice:
jobs.append((notice, *change_notice()))
else:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
profile = load_profile(conn, profile["id"]) or profile
if secondary_set:
if secondary_email is None or secondary_email.strip() == "":
conn.execute(
"""
UPDATE profiles
SET secondary_email = NULL,
secondary_email_verified_at = NULL,
pending_secondary_email = NULL
WHERE id = ?
""",
(profile["id"],),
)
else:
cleaned = normalize_email(secondary_email)
current_primary = (profile.get("email") or "").strip().lower()
pending_primary = (profile.get("pending_email") or "").strip().lower()
current_secondary = (profile.get("secondary_email") or "").strip().lower()
if cleaned == current_primary or cleaned == pending_primary:
raise HTTPException(409, "E-Mail ist bereits vergeben")
if cleaned == current_secondary and profile.get("secondary_email_verified_at") and not profile.get("pending_secondary_email"):
pass
else:
assert_address_free(conn, cleaned, profile["id"])
conn.execute(
"UPDATE profiles SET pending_secondary_email = ? WHERE id = ?",
(cleaned, profile["id"]),
)
queued = _queue_verification(conn, profile, "secondary", cleaned)
if queued:
subject, body = queued
jobs.append(([cleaned], subject, body))
notice = [item for item in notice_recipients(profile) if item != cleaned]
if notice:
jobs.append((notice, *change_notice()))
else:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
return jobs
def deliver_jobs(jobs: list[tuple[list[str], str, str]]) -> None:
from mailer import send_mail
for recipients, subject, body in jobs:
send_mail(recipients, subject, body)
def resend_verification(conn, profile: dict, purpose: str) -> tuple[list[str], str, str]:
if purpose == "primary":
target = (profile.get("pending_email") or "").strip().lower()
if not target and not profile.get("email_verified_at"):
target = (profile.get("email") or "").strip().lower()
if not target or (profile.get("email_verified_at") and not profile.get("pending_email")):
raise HTTPException(400, "Keine offene Bestätigung für die primäre E-Mail")
elif purpose == "secondary":
target = (profile.get("pending_secondary_email") or "").strip().lower()
if not target and profile.get("secondary_email") and not profile.get("secondary_email_verified_at"):
target = profile["secondary_email"].strip().lower()
if not target or (
profile.get("secondary_email_verified_at") and not profile.get("pending_secondary_email")
):
raise HTTPException(400, "Keine offene Bestätigung für die zweite E-Mail")
else:
raise HTTPException(400, "Unbekannter Verifikationszweck")
queued = _queue_verification(conn, profile, purpose, target)
if not queued:
raise HTTPException(429, "Zu viele Bestätigungslinks. Bitte später erneut versuchen.")
subject, body = queued
return [target], subject, body