Kansho/backend/account_access.py

469 lines
17 KiB
Python
Raw Normal View History

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