"""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