469 lines
17 KiB
Python
469 lines
17 KiB
Python
"""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
|