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
|