Add username login and expiring reset links so accounts stay recoverable.
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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-10-12 20:00:17 +02:00
parent 0277039b07
commit 89d149684e
24 changed files with 1595 additions and 67 deletions

View File

@ -28,3 +28,10 @@ KANSHO_FRONTEND_PORT=3006
KANSHO_BACKEND_PORT=8005
KANSHO_PROVIDER_KEY=
KANSHO_DETECT_PROVIDER_KEY=
# Betriebsmail. Leer = kein Versand (production/test: Fehler, development: Link nur im Server-Log).
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM=kansho@example.test

View File

@ -33,3 +33,12 @@ KANSHO_DETECT_PROVIDER_KEY=
# DB_USER=kansho
# DB_PASSWORD=
# Betriebsmail für Passwort-Reset und Adressbestätigung. Ohne SMTP schlägt der Versand fehl.
# In development wird der Link nur ins Server-Log geschrieben.
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM=kansho@example.test
# APP_URL=http://localhost:5188

468
backend/account_access.py Normal file
View File

@ -0,0 +1,468 @@
"""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

View File

@ -56,7 +56,35 @@ SQLITE_HISTORY_MIGRATIONS = (
_PROFILE_COLUMNS = {
"status": "TEXT NOT NULL DEFAULT 'active'",
"tier_id": "TEXT NOT NULL DEFAULT 'local'",
"username": "TEXT",
"secondary_email": "TEXT",
"email_verified_at": "TEXT",
"secondary_email_verified_at": "TEXT",
"pending_email": "TEXT",
"pending_secondary_email": "TEXT",
}
_ACCOUNT_INDEXES = (
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_username_lower_idx
ON profiles (lower(username))
WHERE username IS NOT NULL AND username <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_secondary_email_lower_idx
ON profiles (lower(secondary_email))
WHERE secondary_email IS NOT NULL AND secondary_email <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_email_lower_idx
ON profiles (lower(pending_email))
WHERE pending_email IS NOT NULL AND pending_email <> ''
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_secondary_email_lower_idx
ON profiles (lower(pending_secondary_email))
WHERE pending_secondary_email IS NOT NULL AND pending_secondary_email <> ''
""",
)
_PROMPT_COLUMNS = {
"description": "TEXT NOT NULL DEFAULT ''",
"category": "TEXT NOT NULL DEFAULT 'uncategorized'",
@ -227,6 +255,27 @@ def _mark(conn: Any, migration_id: str) -> None:
)
def _ensure_account_identifiers(conn: Any) -> None:
"""Add login identifiers on existing SQLite files and grandfather current emails once."""
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
for statement in _ACCOUNT_INDEXES:
conn.execute(statement)
row = conn.execute(
"SELECT id FROM schema_migrations WHERE id = ?",
("027_account_identifiers",),
).fetchone()
if row:
return
conn.execute(
"""
UPDATE profiles
SET email_verified_at = COALESCE(created, datetime('now'))
WHERE email_verified_at IS NULL OR email_verified_at = ''
"""
)
_mark(conn, "027_account_identifiers")
def _seed_platform(conn: Any) -> None:
seed = json.loads(SEED_PATH.read_text(encoding="utf-8"))
for tier in seed.get("tiers", []):
@ -582,7 +631,7 @@ def init_db() -> None:
_ensure_columns(conn, "provider_settings", {"profile_id": "TEXT"})
seed_provider_settings(conn)
_ensure_columns(conn, "profiles", _PROFILE_COLUMNS)
_ensure_account_identifiers(conn)
_ensure_columns(conn, "conversations", _CONVERSATION_COLUMNS)
_ensure_columns(conn, "journal_days", _JOURNAL_DAY_COLUMNS)
_ensure_columns(conn, "journal_drafts", _JOURNAL_DRAFT_COLUMNS)

64
backend/mailer.py Normal file
View File

@ -0,0 +1,64 @@
"""Operational mail for account links. No dialogue content, no model egress."""
from __future__ import annotations
import logging
import os
import smtplib
from email.message import EmailMessage
from env_loader import env_flag, runtime_env
log = logging.getLogger("kansho.mail")
OUTBOX: list[dict] = []
class MailNotConfigured(RuntimeError):
"""SMTP is required and neither configured nor in the development log path."""
def clear_outbox() -> None:
OUTBOX.clear()
def smtp_configured() -> bool:
return bool((os.environ.get("SMTP_HOST") or "").strip() and (os.environ.get("SMTP_FROM") or "").strip())
def send_mail(recipients: list[str], subject: str, body: str) -> None:
cleaned: list[str] = []
for item in recipients:
address = (item or "").strip().lower()
if address and address not in cleaned:
cleaned.append(address)
if not cleaned:
raise MailNotConfigured("Mailversand ist nicht konfiguriert")
if env_flag("KANSHO_FAKE_MAIL"):
OUTBOX.append({"to": cleaned, "subject": subject, "body": body})
return
host = (os.environ.get("SMTP_HOST") or "").strip()
sender = (os.environ.get("SMTP_FROM") or "").strip()
if not host or not sender:
if runtime_env() == "development":
log.warning("Kansho mail (development, SMTP fehlt): %s", body)
return
raise MailNotConfigured("Mailversand ist nicht konfiguriert")
port = int((os.environ.get("SMTP_PORT") or "587").strip() or "587")
user = (os.environ.get("SMTP_USER") or "").strip()
password = os.environ.get("SMTP_PASSWORD") or ""
message = EmailMessage()
message["Subject"] = subject
message["From"] = sender
message["To"] = ", ".join(cleaned)
message.set_content(body)
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
if user:
smtp.login(user, password)
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=20) as smtp:
smtp.starttls()
if user:
smtp.login(user, password)
smtp.send_message(message)

View File

@ -0,0 +1,49 @@
-- Account identifiers, verified addresses, and one-time mail tokens.
-- Existing primary addresses are grandfathered as verified once.
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS username TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS secondary_email TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified_at TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS secondary_email_verified_at TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS pending_email TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS pending_secondary_email TEXT;
UPDATE profiles
SET email_verified_at = COALESCE(NULLIF(created, ''), CURRENT_TIMESTAMP::text)
WHERE email_verified_at IS NULL OR email_verified_at = '';
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('primary', 'secondary')),
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text
);
CREATE UNIQUE INDEX IF NOT EXISTS profiles_username_lower_idx
ON profiles (lower(username))
WHERE username IS NOT NULL AND username <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_secondary_email_lower_idx
ON profiles (lower(secondary_email))
WHERE secondary_email IS NOT NULL AND secondary_email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_email_lower_idx
ON profiles (lower(pending_email))
WHERE pending_email IS NOT NULL AND pending_email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS profiles_pending_secondary_email_lower_idx
ON profiles (lower(pending_secondary_email))
WHERE pending_secondary_email IS NOT NULL AND pending_secondary_email <> '';

View File

@ -5,8 +5,25 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from account_access import (
FORGOT_MESSAGE,
consume_password_reset,
consume_verification,
deliver_jobs,
find_by_login,
issue_password_reset,
issue_verification,
load_profile,
normalize_email,
profile_view,
resend_verification,
reset_message,
verified_reset_recipients,
verify_message,
)
from auth import create_session, hash_password, require_auth, verify_password
from db import get_db, row_to_dict
from db import get_db
from mailer import MailNotConfigured
from version import APP_VERSION
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -23,6 +40,23 @@ class SetupRequest(BaseModel):
password: str = Field(min_length=4)
class ForgotRequest(BaseModel):
email: str = Field(min_length=1)
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=10)
password: str = Field(min_length=4)
class VerifyEmailRequest(BaseModel):
token: str = Field(min_length=10)
class ResendVerificationRequest(BaseModel):
purpose: str
def _profile_count() -> int:
with get_db() as conn:
row = conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone()
@ -43,15 +77,22 @@ def auth_status():
def setup(req: SetupRequest):
if _profile_count() > 0:
raise HTTPException(409, "Setup ist bereits abgeschlossen")
email = normalize_email(req.email)
profile_id = str(uuid.uuid4())
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
conn.execute(
"""
INSERT INTO profiles (id, email, name, password_hash, role)
VALUES (?, ?, ?, ?, 'admin')
""",
(profile_id, req.email.lower().strip(), req.name.strip(), hash_password(req.password)),
(profile_id, email, req.name.strip(), hash_password(req.password)),
)
raw = issue_verification(conn, profile_id, "primary", email)
if raw:
subject, body = verify_message(raw)
jobs.append(([email], subject, body))
_send_or_log_failure(jobs)
token, expires = create_session(profile_id)
return {
"token": token,
@ -62,15 +103,20 @@ def setup(req: SetupRequest):
}
def _send_or_log_failure(jobs: list[tuple[list[str], str, str]]) -> bool:
if not jobs:
return True
try:
deliver_jobs(jobs)
except MailNotConfigured:
return False
return True
@router.post("/login")
def login(req: LoginRequest):
with get_db() as conn:
prof = row_to_dict(
conn.execute(
"SELECT * FROM profiles WHERE email = ?",
(req.email.lower().strip(),),
).fetchone()
)
prof = find_by_login(conn, req.email)
if not prof or not verify_password(req.password, prof["password_hash"]):
raise HTTPException(401, "Ungültige Zugangsdaten")
if (prof.get("status") or "active") != "active":
@ -95,9 +141,49 @@ def logout(session: dict = Depends(require_auth)):
@router.get("/me")
def me(session: dict = Depends(require_auth)):
with get_db() as conn:
prof = row_to_dict(
conn.execute("SELECT id, email, name, role, status, tier_id FROM profiles WHERE id = ?", (session["profile_id"],)).fetchone()
)
prof = profile_view(load_profile(conn, session["profile_id"]))
if not prof:
raise HTTPException(401, "Profil nicht gefunden")
return prof
@router.post("/forgot-password")
def forgot_password(req: ForgotRequest):
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
prof = find_by_login(conn, req.email)
if prof and (prof.get("status") or "active") == "active":
recipients = verified_reset_recipients(prof)
if recipients:
raw = issue_password_reset(conn, prof["id"])
if raw:
subject, body = reset_message(raw)
jobs.append((recipients, subject, body))
_send_or_log_failure(jobs)
return {"ok": True, "message": FORGOT_MESSAGE}
@router.post("/reset-password")
def reset_password(req: ResetPasswordRequest):
with get_db() as conn:
consume_password_reset(conn, req.token, req.password)
return {"ok": True}
@router.post("/verify-email")
def verify_email(req: VerifyEmailRequest):
with get_db() as conn:
return consume_verification(conn, req.token)
@router.post("/resend-verification")
def resend(req: ResendVerificationRequest, session: dict = Depends(require_auth)):
with get_db() as conn:
profile = load_profile(conn, session["profile_id"])
if not profile:
raise HTTPException(401, "Profil nicht gefunden")
recipients, subject, body = resend_verification(conn, profile, req.purpose)
jobs = [(recipients, subject, body)]
if not _send_or_log_failure(jobs):
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
return {"ok": True}

View File

@ -5,19 +5,34 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from account_access import (
admin_reset_recipients,
apply_identifier_edits,
assert_address_free,
assert_username_free,
deliver_jobs,
issue_password_reset,
issue_verification,
load_profile,
normalize_email,
normalize_username,
profile_view,
reset_message,
verify_message,
)
from auth import hash_password, require_admin_dep, require_auth
from db import get_db, row_to_dict
from db import get_db
from mailer import MailNotConfigured
router = APIRouter(prefix="/api/users", tags=["users"])
PUBLIC_FIELDS = "id, email, name, role, status, tier_id, created"
class CreateUserRequest(BaseModel):
email: str
name: str = Field(min_length=1)
password: str = Field(min_length=4)
role: str = "user"
username: str | None = None
class PatchUserRequest(BaseModel):
@ -25,6 +40,16 @@ class PatchUserRequest(BaseModel):
role: str | None = None
status: str | None = None
password: str | None = None
username: str | None = None
email: str | None = None
secondary_email: str | None = None
class SelfPatchRequest(BaseModel):
username: str | None = None
email: str | None = None
secondary_email: str | None = None
password: str | None = None
def _active_admin_count(conn, exclude_id: str | None = None) -> int:
@ -40,45 +65,104 @@ def _active_admin_count(conn, exclude_id: str | None = None) -> int:
return int(row["n"])
def _send_jobs(jobs: list[tuple[list[str], str, str]]) -> bool:
if not jobs:
return True
try:
deliver_jobs(jobs)
except MailNotConfigured:
return False
return True
@router.get("")
def list_users(session: dict = Depends(require_admin_dep)):
with get_db() as conn:
rows = conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles ORDER BY created").fetchall()
return [row_to_dict(row) for row in rows]
rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall()
return [profile_view(dict(row)) for row in rows]
@router.post("")
def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)):
if req.role not in {"user", "admin"}:
raise HTTPException(400, "Rolle muss user oder admin sein")
email = normalize_email(req.email)
username = normalize_username(req.username) if req.username and req.username.strip() else None
profile_id = str(uuid.uuid4())
jobs: list[tuple[list[str], str, str]] = []
try:
with get_db() as conn:
assert_address_free(conn, email, None)
if username:
assert_username_free(conn, username, None)
conn.execute(
"""
INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id)
VALUES (?, ?, ?, ?, ?, 'active', 'local')
INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id, username)
VALUES (?, ?, ?, ?, ?, 'active', 'local', ?)
""",
(
profile_id,
req.email.lower().strip(),
email,
req.name.strip(),
hash_password(req.password),
req.role,
username,
),
)
prof = row_to_dict(conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?", (profile_id,)).fetchone())
raw = issue_verification(conn, profile_id, "primary", email)
if raw:
subject, body = verify_message(raw)
jobs.append(([email], subject, body))
prof = profile_view(load_profile(conn, profile_id))
except HTTPException:
raise
except Exception as exc:
if "UNIQUE" in str(exc).upper():
raise HTTPException(409, "E-Mail ist bereits vergeben") from exc
raise
if not _send_jobs(jobs):
prof = dict(prof or {})
prof["mail_sent"] = False
return prof
@router.patch("/me")
def patch_me(req: SelfPatchRequest, session: dict = Depends(require_auth)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
current = load_profile(conn, session["profile_id"])
if not current:
raise HTTPException(401, "Profil nicht gefunden")
if req.password:
if len(req.password) < 4:
raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben")
conn.execute(
"UPDATE profiles SET password_hash = ? WHERE id = ?",
(hash_password(req.password), current["id"]),
)
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, current["id"]))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof
@router.patch("/{profile_id}")
def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)):
provided = req.model_fields_set
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
current = row_to_dict(conn.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,)).fetchone())
current = load_profile(conn, profile_id)
if not current:
raise HTTPException(404, "Nutzer nicht gefunden")
name = req.name.strip() if req.name is not None else current["name"]
@ -105,18 +189,49 @@ def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(r
)
if status == "disabled":
conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,))
return row_to_dict(conn.execute(f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?", (profile_id,)).fetchone())
current = load_profile(conn, profile_id) or current
jobs = apply_identifier_edits(
conn,
current,
username=req.username,
email=req.email,
secondary_email=req.secondary_email,
username_set="username" in provided,
email_set="email" in provided,
secondary_set="secondary_email" in provided,
)
prof = profile_view(load_profile(conn, profile_id))
if not _send_jobs(jobs):
raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.")
return prof
@router.post("/{profile_id}/password-reset")
def admin_password_reset(profile_id: str, session: dict = Depends(require_admin_dep)):
if profile_id == session["profile_id"]:
raise HTTPException(400, "Den eigenen Reset startest du über die Anmeldung")
jobs: list[tuple[list[str], str, str]] = []
with get_db() as conn:
profile = load_profile(conn, profile_id)
if not profile:
raise HTTPException(404, "Nutzer nicht gefunden")
recipients = admin_reset_recipients(profile)
if not recipients:
raise HTTPException(400, "Für dieses Konto ist keine E-Mail hinterlegt")
raw = issue_password_reset(conn, profile_id)
if not raw:
raise HTTPException(429, "Zu viele Reset-Links. Bitte später erneut versuchen.")
subject, body = reset_message(raw)
jobs.append((recipients, subject, body))
if not _send_jobs(jobs):
raise HTTPException(503, "Mailversand ist nicht konfiguriert")
return {"ok": True}
@router.get("/me")
def my_account(session: dict = Depends(require_auth)):
with get_db() as conn:
prof = row_to_dict(
conn.execute(
f"SELECT {PUBLIC_FIELDS} FROM profiles WHERE id = ?",
(session["profile_id"],),
).fetchone()
)
prof = profile_view(load_profile(conn, session["profile_id"]))
if not prof:
raise HTTPException(401, "Profil nicht gefunden")
return prof

View File

@ -40,6 +40,12 @@ CREATE TABLE IF NOT EXISTS profiles (
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
tier_id TEXT NOT NULL DEFAULT 'local' REFERENCES tiers(id),
session_days INTEGER NOT NULL DEFAULT 30,
username TEXT,
secondary_email TEXT,
email_verified_at TEXT,
secondary_email_verified_at TEXT,
pending_email TEXT,
pending_secondary_email TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
@ -50,6 +56,26 @@ CREATE TABLE IF NOT EXISTS sessions (
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('primary', 'secondary')),
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_feature_restrictions (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,

View File

@ -189,6 +189,13 @@ def migrate(sqlite_path: Path, media_src: Path, media_dest: Path, replace: bool)
count = pg.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table}: {len(rows)} → {count}")
stats[table] = (len(rows), int(count))
pg.execute(
"""
UPDATE profiles
SET email_verified_at = COALESCE(NULLIF(created, ''), CURRENT_TIMESTAMP::text)
WHERE email_verified_at IS NULL OR email_verified_at = ''
"""
)
pg.execute(
"INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING",
("022_postgres_runtime_baseline",),

View File

@ -0,0 +1,176 @@
"""Account identifiers, reset links, and address verification."""
from __future__ import annotations
import os
import re
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ["KANSHO_PROVIDER_KEY"] = ""
os.environ["KANSHO_DETECT_PROVIDER_KEY"] = ""
os.environ["KANSHO_FAKE_MAIL"] = "1"
os.environ["KANSHO_ENV"] = "test"
os.environ["APP_URL"] = "http://localhost:5188"
if (os.environ.get("KANSHO_DB_BACKEND") or "").strip().lower() not in {"postgres", "postgresql", "pg"}:
os.environ["KANSHO_DB_PATH"] = str(Path(tempfile.mkdtemp(prefix="kansho-account-")) / "account.sqlite")
from fastapi.testclient import TestClient
from main import app
from db import get_db
from mailer import OUTBOX, clear_outbox
def expect(ok: bool, message: str) -> None:
if not ok:
raise SystemExit(f"FAIL: {message}")
print(f"OK {message}")
def auth_header(token: str) -> dict:
return {"X-Auth-Token": token}
def mail_tokens() -> list[str]:
found: list[str] = []
for item in OUTBOX:
found.extend(re.findall(r"token=([A-Za-z0-9_\-]+)", item["body"]))
return found
def main() -> None:
clear_outbox()
with TestClient(app) as client:
setup = client.post(
"/api/auth/setup",
json={"email": "lars@example.test", "name": "Lars", "password": "test-pass"},
)
expect(setup.status_code == 200, f"setup {setup.text}")
token = setup.json()["token"]
headers = auth_header(token)
verify_tokens = mail_tokens()
expect(len(verify_tokens) == 1, "setup sends one verification link")
verified = client.post("/api/auth/verify-email", json={"token": verify_tokens[0]})
expect(verified.status_code == 200 and verified.json()["email_verified"] is True, "admin email verified")
clear_outbox()
me = client.get("/api/auth/me", headers=headers)
expect(me.status_code == 200, "session survives initial verification")
admin_id = me.json()["id"]
blocked = client.patch(
f"/api/users/{admin_id}",
headers=headers,
json={"status": "disabled"},
)
expect(blocked.status_code == 400, f"last admin cannot be disabled {blocked.text}")
created = client.post(
"/api/users",
headers=headers,
json={
"email": "ute@example.test",
"name": "Ute",
"password": "user-pass",
"role": "user",
"username": "ute.user",
},
)
expect(created.status_code == 200 and created.json()["username"] == "ute.user", f"create with username {created.text}")
expect(created.json()["email_verified"] is False, "new primary email starts unverified")
clear_outbox()
by_name = client.post("/api/auth/login", json={"email": "Ute.User", "password": "user-pass"})
expect(by_name.status_code == 200, f"login by username {by_name.text}")
unknown = client.post("/api/auth/forgot-password", json={"email": "missing@example.test"})
known = client.post("/api/auth/forgot-password", json={"email": "lars@example.test"})
expect(unknown.status_code == 200 and known.status_code == 200, "forgot password always 200")
expect(unknown.json() == known.json(), "forgot password does not reveal whether the account exists")
expect(len(mail_tokens()) == 1, "reset mail only for the verified account")
reset_token = mail_tokens()[0]
clear_outbox()
changed = client.post(
"/api/auth/reset-password",
json={"token": reset_token, "password": "new-pass"},
)
expect(changed.status_code == 200, f"reset password {changed.text}")
stale = client.get("/api/auth/me", headers=headers)
expect(stale.status_code == 401, "reset deletes sessions")
old_password = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "test-pass"})
expect(old_password.status_code == 401, "old password rejected")
relogin = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(relogin.status_code == 200, "login with reset password")
headers = auth_header(relogin.json()["token"])
again = client.post("/api/auth/reset-password", json={"token": reset_token, "password": "other-pass"})
expect(again.status_code == 400, "reset token is single use")
client.post("/api/auth/forgot-password", json={"email": "lars@example.test"})
expired_token = mail_tokens()[0]
clear_outbox()
with get_db() as conn:
conn.execute(
"UPDATE password_reset_tokens SET expires_at = ? WHERE token_hash IS NOT NULL",
("2000-01-01 00:00:00",),
)
expired = client.post(
"/api/auth/reset-password",
json={"token": expired_token, "password": "newer-pass"},
)
expect(expired.status_code == 400, "expired reset token is rejected")
clear_outbox()
renamed = client.patch(
"/api/users/me",
headers=headers,
json={"email": "lars.new@example.test"},
)
expect(renamed.status_code == 200, f"email change queued {renamed.text}")
expect(renamed.json()["email"] == "lars@example.test", "login email unchanged until verification")
expect(renamed.json()["pending_email"] == "lars.new@example.test", "pending email stored")
still = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(still.status_code == 200, "old email still logs in")
confirm = client.post("/api/auth/verify-email", json={"token": mail_tokens()[0]})
expect(confirm.status_code == 200 and confirm.json()["email"] == "lars.new@example.test", "verified address becomes login")
gone = client.post("/api/auth/login", json={"email": "lars@example.test", "password": "new-pass"})
expect(gone.status_code == 401, "previous email stops working")
moved = client.post("/api/auth/login", json={"email": "lars.new@example.test", "password": "new-pass"})
expect(moved.status_code == 200, "new email logs in")
headers = auth_header(moved.json()["token"])
clear_outbox()
other = client.post(
f"/api/users/{created.json()['id']}/password-reset",
headers=headers,
)
expect(other.status_code == 200, f"admin reset for another user {other.text}")
expect(len(mail_tokens()) == 1, "admin reset sends one link")
self_reset = client.post(f"/api/users/{moved.json()['profile_id']}/password-reset", headers=headers)
expect(self_reset.status_code == 400, "admin cannot reset the own account via the admin endpoint")
os.environ.pop("KANSHO_FAKE_MAIL", None)
os.environ.pop("SMTP_HOST", None)
os.environ.pop("SMTP_FROM", None)
os.environ["KANSHO_ENV"] = "test"
failed = client.post(
f"/api/users/{created.json()['id']}/password-reset",
headers=headers,
)
expect(failed.status_code == 503, f"admin reset without SMTP fails closed {failed.text}")
secondary_login = client.post(
"/api/auth/login",
json={"email": "ute.second@example.test", "password": "user-pass"},
)
expect(secondary_login.status_code == 401, "unknown address is not a login")
print("Account access tests passed.")
if __name__ == "__main__":
main()

View File

@ -38,6 +38,11 @@ services:
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}"
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-587}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
SMTP_FROM: "${SMTP_FROM:-}"
volumes:
- dev-kansho-media:/app/data/media
ports:

View File

@ -40,6 +40,11 @@ services:
KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}"
KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}"
KANSHO_ALLOW_REMOTE_DETECT: "${KANSHO_ALLOW_REMOTE_DETECT:-}"
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-587}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
SMTP_FROM: "${SMTP_FROM:-}"
volumes:
- kansho-media:/app/data/media
ports:

View File

@ -43,6 +43,31 @@ Mehrere Nutzer pro Instanz: ja. Jede Session ist an genau ein `profile_id` gebun
Klarnamen, E-Mail und Mapping-Tabellen sind Klasse A (Local Only). Sie verlassen die Trusted Zone nicht als Teil eines External Model Context.
**Additiv 2026-10-12.** Der Benutzername ist eine weitere Login-Kennung desselben Profils, keine zweite Identität. Die zweite E-Mail ist Kontaktadresse, kein Login. Beide bleiben Klasse A.
## 2.1 Kontokennungen und Verifikation
**Status: entschieden** (2026-10-12)
- Login mit primärer E-Mail oder Benutzername. Der Benutzername ist optional, eindeutig, 3–32 Zeichen aus Buchstaben, Ziffern, Punkt und Unterstrich.
- Bestehende primäre Adressen gelten mit der Migration als verifiziert.
- Eine neue oder geänderte Adresse wird erst nach dem Bestätigungslink wirksam. Bis dahin bleibt die bisher verifizierte Adresse die Login-Adresse.
- Solange die primäre Adresse nicht verifiziert ist, zeigt die Oberfläche nur den Kontobereich.
- Die zweite E-Mail erhält Reset- und Hinweismails erst nach eigener Verifikation.
- Passwort-Reset-Link: 60 Minuten, einmalig, nur der SHA-256-Hash liegt in der Datenbank. Danach werden die Sessions des Kontos gelöscht.
- Bestätigungslink: 24 Stunden, einmalig, nur der Hash.
- Öffentliches „Passwort vergessen“ antwortet immer gleich und geht nur an bereits verifizierte Adressen. Höchstens drei neue Links pro Konto und Stunde.
- Ein Admin kann für ein anderes Konto denselben Reset auslösen. Der Link geht an die hinterlegte primäre Adresse und an eine bereits verifizierte zweite Adresse. Der letzte aktive Admin kann weder deaktiviert noch auf `user` gesetzt werden.
- SMTP steht in der Server-`.env`. Fehlt sie, gibt es keinen stillen Versand. In `development` wird der Link nur ins Server-Log geschrieben. Der Mailtext enthält nur den Link und den Produktnamen, keinen Dialoginhalt.
## 2.2 Zwei-Faktor
**Status: Hypothese** (2026-10-12). Kein Schema, kein Endpunkt.
- App-TOTP nach dem Passwort und vor der Session. Kein SMS.
- Geheimnis und Wiederherstellungscodes bleiben lokal, Klasse A. Codes werden nur als Hash gespeichert und sind einmalig.
- Sind App und Wiederherstellungscodes verloren, setzt ein Admin den zweiten Faktor zurück. Das läuft nicht über ein Modell.
## 3. Session-Flow (übernommen)
Herkunft: Mitai-Implementierung, als Muster.
@ -104,6 +129,11 @@ Keine Secrets, API-Keys oder Identity-Mappings im Frontend-Bundle.
| Session-Länge | ~30 Tage | bevorzugte Richtung |
| Feineres Rollenmodell | später | offen |
| Token-Ablage Client | localStorage wie Mitai | bevorzugte Richtung |
| Benutzername als zweite Login-Kennung | optional, ein Profil | entschieden (2026-10-12) |
| Zweite E-Mail | Kontakt, kein Login, verifiziert | entschieden (2026-10-12) |
| Reset-Link | 60 Minuten, einmalig, Hash | entschieden (2026-10-12) |
| Letzter aktiver Admin | nicht deaktivieren, nicht herabstufen | entschieden (2026-10-12) |
| Zwei-Faktor (App-TOTP) | vor der Session, lokal | Hypothese |
## 8. Offene Fragen

View File

@ -21,6 +21,8 @@ Kanonisches Home für Zugriffssicherheit des Mitai-Rahmens (Auth, TLS, Secrets,
- Session-Invalidierung beim Logout.
- Kein IDOR über Client-gewähltes Profil (`auth_identity_and_roles.md`).
**Additiv 2026-10-12.** Reset- und Bestätigungstoken werden nur als SHA-256-Hash gespeichert. SMTP ist Betriebsmail und Fail Closed: ohne Konfiguration kein stiller Versand. In `development` erscheint der Link nur im Server-Log, nie in der API-Antwort. Details: `auth_identity_and_roles.md` §2.1.
## 2. Kanshō-spezifisch
- Provider-Keys für LLM nur hinter dem Gateway, mit Policy-Check vor Verwendung.

View File

@ -1,5 +1,5 @@
import { NavLink, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { LogOut, Settings } from 'lucide-react'
import { useAuth } from './context/AuthContext.jsx'
import { getMainNavItems } from './config/appNav.js'
import LoginScreen from './pages/LoginScreen.jsx'
@ -13,6 +13,9 @@ import JournalDayPage from './pages/JournalDayPage.jsx'
import JournalEditorPage from './pages/JournalEditorPage.jsx'
import JournalSourcePage from './pages/JournalSourcePage.jsx'
import SettingsPage from './pages/SettingsPage.jsx'
import ForgotPasswordPage from './pages/ForgotPasswordPage.jsx'
import ResetPasswordPage from './pages/ResetPasswordPage.jsx'
import VerifyEmailPage from './pages/VerifyEmailPage.jsx'
import AdminHomePage from './pages/AdminHomePage.jsx'
import AdminUsersPage from './pages/AdminUsersPage.jsx'
import AdminPromptsPage from './pages/AdminPromptsPage.jsx'
@ -33,8 +36,10 @@ function navItemActive(pathname, item, routerIsActive) {
return routerIsActive
}
function BottomNav({ isAdmin }) {
const items = getMainNavItems(isAdmin)
function BottomNav({ isAdmin, limited }) {
const items = limited
? [{ to: '/settings', label: 'Einstellungen', icon: Settings }]
: getMainNavItems(isAdmin)
const loc = useLocation()
return (
<nav className="bottom-nav">
@ -58,8 +63,10 @@ function BottomNav({ isAdmin }) {
)
}
function DesktopSidebar({ isAdmin, onLogout, name }) {
const items = getMainNavItems(isAdmin)
function DesktopSidebar({ isAdmin, onLogout, name, limited }) {
const items = limited
? [{ to: '/settings', label: 'Einstellungen', icon: Settings }]
: getMainNavItems(isAdmin)
const loc = useLocation()
return (
<aside className="desktop-sidebar">
@ -92,7 +99,7 @@ function DesktopSidebar({ isAdmin, onLogout, name }) {
)
}
function AppShell() {
function AppShell({ limited = false }) {
const { session, isAdmin, logout } = useAuth()
const { confirmLeave } = useUnsavedChanges()
const onLogout = () => {
@ -101,16 +108,19 @@ function AppShell() {
}
return (
<div className="app-shell">
<DesktopSidebar isAdmin={isAdmin} onLogout={onLogout} name={session?.profile?.name} />
<DesktopSidebar isAdmin={isAdmin} onLogout={onLogout} name={session?.profile?.name} limited={limited} />
<div className="app-main">
<header className="mobile-header">
<strong>Kanshō</strong>
<button type="button" className="ghost" onClick={onLogout}>Abmelden</button>
</header>
<main className="page">
{limited && (
<p className="error">Die E-Mail-Adresse ist noch nicht bestätigt. Bis dahin bleibt nur der Kontobereich offen.</p>
)}
<Outlet />
</main>
<BottomNav isAdmin={isAdmin} />
<BottomNav isAdmin={isAdmin} limited={limited} />
</div>
</div>
)
@ -126,13 +136,24 @@ export default function App() {
return <SetupScreen />
}
if (!session) {
return <LoginScreen />
return (
<Routes>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="*" element={<LoginScreen />} />
</Routes>
)
}
const verified = Boolean(session.profile?.email_verified_at)
return (
<UnsavedProvider>
<Routes>
<Route element={<AppShell />}>
<Route element={<AppShell limited={!verified} />}>
{verified ? (
<>
<Route path="/" element={<HomePage />} />
<Route path="/journal" element={<JournalSpacesPage />} />
<Route path="/journal/:spaceId" element={<JournalSpacePage />} />
@ -141,6 +162,9 @@ export default function App() {
<Route path="/journal/:spaceId/:dayId/entry" element={<JournalEditorPage />} />
<Route path="/journal/:spaceId/:dayId/source/:conversationId" element={<JournalSourcePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route element={<RequireAdmin />}>
<Route path="/dialog" element={<DialoguePage />} />
<Route path="/admin" element={<AdminShell />}>
@ -156,8 +180,16 @@ export default function App() {
<Route path="debug" element={<AdminDebugPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</>
) : (
<>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</>
)}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</UnsavedProvider>
)

View File

@ -89,6 +89,20 @@ export function AuthProvider({ children }) {
setSession(null)
}
const refreshProfile = async () => {
const token = session?.token || localStorage.getItem(TOKEN_KEY)
if (!token) return null
const me = await fetch('/api/auth/me', { headers: { 'X-Auth-Token': token } })
if (!me.ok) {
localStorage.removeItem(TOKEN_KEY)
setSession(null)
return null
}
const profile = await me.json()
setSession({ token, profile_id: profile.id, role: profile.role, profile })
return profile
}
return (
<AuthContext.Provider value={{
session,
@ -97,7 +111,8 @@ export function AuthProvider({ children }) {
isAdmin: session?.role === 'admin',
login,
setup,
logout
logout,
refreshProfile
}}>
{children}
</AuthContext.Provider>

View File

@ -0,0 +1,112 @@
import { useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
function statusLabel(verified, pending) {
if (pending) return `Bestätigung offen für ${pending}`
return verified ? 'bestätigt' : 'nicht bestätigt'
}
export default function AccountPanel() {
const { session, refreshProfile } = useAuth()
const profile = session?.profile || {}
const [username, setUsername] = useState(profile.username || '')
const [email, setEmail] = useState('')
const [secondary, setSecondary] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const save = async (body) => {
setBusy(true)
setError('')
setMessage('')
try {
await api('/api/users/me', { token: session.token, method: 'PATCH', body })
await refreshProfile()
setMessage('Gespeichert.')
setPassword('')
setEmail('')
setSecondary('')
} catch (err) {
setError(err.message)
await refreshProfile()
} finally {
setBusy(false)
}
}
const resend = async (purpose) => {
setBusy(true)
setError('')
setMessage('')
try {
await api('/api/auth/resend-verification', { token: session.token, method: 'POST', body: { purpose } })
setMessage('Bestätigungslink wurde gesendet.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="stack">
<h2>Konto</h2>
<p className="muted">
Primäre E-Mail: {profile.email} ({statusLabel(profile.email_verified, profile.pending_email)})
</p>
<p className="muted">
Zweite E-Mail: {profile.secondary_email || 'keine'} ({statusLabel(profile.secondary_email_verified, profile.pending_secondary_email)})
</p>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ username }) }}>
<label>
Benutzername
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<button type="submit" disabled={busy}>Benutzername speichern</button>
</form>
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ email }) }}>
<label>
Neue primäre E-Mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<button type="submit" disabled={busy}>Bestätigung senden</button>
</form>
{(!profile.email_verified || profile.pending_email) && (
<button type="button" className="ghost" disabled={busy} onClick={() => resend('primary')}>
Bestätigung der primären E-Mail erneut senden
</button>
)}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ secondary_email: secondary }) }}>
<label>
Zweite E-Mail
<input type="email" value={secondary} onChange={(e) => setSecondary(e.target.value)} placeholder={profile.secondary_email || ''} />
</label>
<div className="row-actions">
<button type="submit" disabled={busy || !secondary.trim()}>Zweite E-Mail bestätigen lassen</button>
{profile.secondary_email && (
<button type="button" className="ghost" disabled={busy} onClick={() => save({ secondary_email: '' })}>
Zweite E-Mail entfernen
</button>
)}
</div>
</form>
{(profile.pending_secondary_email || (profile.secondary_email && !profile.secondary_email_verified)) && (
<button type="button" className="ghost" disabled={busy} onClick={() => resend('secondary')}>
Bestätigung der zweiten E-Mail erneut senden
</button>
)}
<form className="stack" onSubmit={(event) => { event.preventDefault(); save({ password }) }}>
<label>
Neues Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={4} />
</label>
<button type="submit" disabled={busy}>Passwort ändern</button>
</form>
</div>
)
}

View File

@ -2,24 +2,42 @@ import { useEffect, useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
const emptyForm = { email: '', name: '', password: '', role: 'user' }
const emptyForm = { email: '', name: '', password: '', role: 'user', username: '' }
function verifiedLabel(ok) {
return ok ? 'bestätigt' : 'offen'
}
export default function AdminUsersPage() {
const { session } = useAuth()
const [users, setUsers] = useState([])
const [form, setForm] = useState(emptyForm)
const [editId, setEditId] = useState('')
const [edit, setEdit] = useState(null)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const load = () => api('/api/users', { token: session.token }).then(setUsers).catch((e) => setError(e.message))
useEffect(() => { load() }, [session.token])
const create = async (e) => {
e.preventDefault()
const activeAdmins = users.filter((user) => user.role === 'admin' && user.status === 'active').length
const create = async (event) => {
event.preventDefault()
setError('')
setMessage('')
try {
await api('/api/users', { token: session.token, method: 'POST', body: form })
const body = {
email: form.email,
name: form.name,
password: form.password,
role: form.role
}
if (form.username.trim()) body.username = form.username.trim()
await api('/api/users', { token: session.token, method: 'POST', body })
setForm(emptyForm)
setMessage('Nutzer angelegt. Die primäre E-Mail muss noch bestätigt werden.')
await load()
} catch (err) {
setError(err.message)
@ -28,6 +46,7 @@ export default function AdminUsersPage() {
const toggleStatus = async (user) => {
setError('')
setMessage('')
try {
await api(`/api/users/${user.id}`, {
token: session.token,
@ -40,36 +59,145 @@ export default function AdminUsersPage() {
}
}
const changeRole = async (user, role) => {
setError('')
setMessage('')
try {
await api(`/api/users/${user.id}`, { token: session.token, method: 'PATCH', body: { role } })
await load()
} catch (err) {
setError(err.message)
}
}
const openEdit = (user) => {
setEditId(user.id)
setEdit({
username: user.username || '',
email: '',
secondary_email: ''
})
}
const saveEdit = async (event) => {
event.preventDefault()
setError('')
setMessage('')
const body = {}
if (edit.username !== (users.find((user) => user.id === editId)?.username || '')) {
body.username = edit.username
}
if (edit.email.trim()) body.email = edit.email.trim()
if (edit.secondary_email.trim()) body.secondary_email = edit.secondary_email.trim()
if (!Object.keys(body).length) return
try {
await api(`/api/users/${editId}`, { token: session.token, method: 'PATCH', body })
setMessage('Gespeichert. Geänderte E-Mail-Adressen gelten erst nach der Bestätigung.')
setEditId('')
setEdit(null)
await load()
} catch (err) {
setError(err.message)
}
}
const sendReset = async (user) => {
setError('')
setMessage('')
try {
await api(`/api/users/${user.id}/password-reset`, { token: session.token, method: 'POST' })
setMessage(`Reset-Link für ${user.name} wurde gesendet.`)
} catch (err) {
setError(err.message)
}
}
return (
<section className="card">
<h1>Nutzer</h1>
<p className="muted">Ein Login ist ein Profil. Keine Profilumschaltung, kein Vereinsmodell.</p>
<p className="muted">Ein Login ist ein Profil. Benutzername und zweite E-Mail gehören zu demselben Profil.</p>
{error && <p className="error">{error}</p>}
{message && <p>{message}</p>}
<div className="table-scroll">
<table className="plain">
<thead>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th /></tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td>{user.status}</td>
<td>
<button type="button" className="ghost" onClick={() => toggleStatus(user)}>
{user.status === 'active' ? 'Deaktivieren' : 'Aktivieren'}
</button>
</td>
<table className="plain">
<thead>
<tr>
<th>Name</th>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Zweite E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th />
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{users.map((user) => {
const lastAdmin = user.role === 'admin' && user.status === 'active' && activeAdmins < 2
return (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.username || '—'}</td>
<td>
{user.email}
<div className="muted">{verifiedLabel(user.email_verified)}{user.pending_email ? ` · neu: ${user.pending_email}` : ''}</div>
</td>
<td>
{user.secondary_email || '—'}
<div className="muted">
{user.secondary_email ? verifiedLabel(user.secondary_email_verified) : ''}
{user.pending_secondary_email ? ` · neu: ${user.pending_secondary_email}` : ''}
</div>
</td>
<td>
<select
value={user.role}
disabled={lastAdmin && user.role === 'admin'}
onChange={(event) => changeRole(user, event.target.value)}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</td>
<td>{user.status}</td>
<td>
<div className="row-actions">
<button type="button" className="ghost" onClick={() => openEdit(user)}>Bearbeiten</button>
<button
type="button"
className="ghost"
disabled={user.id === session.profile_id}
onClick={() => sendReset(user)}
>
Reset-Link
</button>
<button type="button" className="ghost" disabled={lastAdmin} onClick={() => toggleStatus(user)}>
{user.status === 'active' ? 'Deaktivieren' : 'Aktivieren'}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{edit && (
<form className="stack" onSubmit={saveEdit}>
<h2>Nutzer bearbeiten</h2>
<label>Benutzername <input value={edit.username} onChange={(e) => setEdit({ ...edit, username: e.target.value })} /></label>
<label>Neue primäre E-Mail <input type="email" value={edit.email} onChange={(e) => setEdit({ ...edit, email: e.target.value })} /></label>
<label>Neue zweite E-Mail <input type="email" value={edit.secondary_email} onChange={(e) => setEdit({ ...edit, secondary_email: e.target.value })} /></label>
<div className="row-actions">
<button type="submit">Speichern</button>
<button type="button" className="ghost" onClick={() => { setEdit(null); setEditId('') }}>Abbrechen</button>
</div>
</form>
)}
<form className="stack" onSubmit={create}>
<h2>Nutzer anlegen</h2>
<label>Name <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></label>
<label>Benutzername <input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></label>
<label>E-Mail <input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></label>
<label>Passwort <input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} /></label>
<label>

View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api.js'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const onSubmit = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
setMessage('')
try {
const result = await api('/api/auth/forgot-password', { method: 'POST', body: { email } })
setMessage(result.message || 'Wenn ein Konto existiert, wurde ein Link gesendet.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={onSubmit}>
<h1>Passwort vergessen</h1>
<p>Gib die E-Mail-Adresse oder den Benutzernamen ein. Der Link gilt 60 Minuten.</p>
<label>
E-Mail oder Benutzername
<input type="text" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="username" />
</label>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy}>{busy ? 'Sende …' : 'Link anfordern'}</button>
<Link to="/">Zur Anmeldung</Link>
</form>
</div>
)
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext.jsx'
export default function LoginScreen() {
@ -27,8 +28,14 @@ export default function LoginScreen() {
<h1>Kanshō</h1>
<p>Anmelden, um den Reflexionsraum fortzusetzen.</p>
<label>
E-Mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
E-Mail oder Benutzername
<input
type="text"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
@ -36,6 +43,7 @@ export default function LoginScreen() {
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy}>{busy ? 'Prüfe …' : 'Anmelden'}</button>
<Link to="/forgot-password">Passwort vergessen</Link>
</form>
</div>
)

View File

@ -0,0 +1,43 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
export default function ResetPasswordPage() {
const [params] = useSearchParams()
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const token = params.get('token') || ''
const onSubmit = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('/api/auth/reset-password', { method: 'POST', body: { token, password } })
setMessage('Das Passwort ist geändert. Du kannst dich neu anmelden.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={onSubmit}>
<h1>Neues Passwort</h1>
{!token && <p className="error">Der Link enthält kein Token.</p>}
<label>
Neues Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={4} />
</label>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy || !token}>{busy ? 'Speichere …' : 'Passwort setzen'}</button>
<Link to="/">Zur Anmeldung</Link>
</form>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useEffect, useId, useState } from 'react'
import { useAuth } from '../context/AuthContext.jsx'
import { api } from '../api.js'
import AccountPanel from './AccountPanel.jsx'
const KIND_LABELS = {
journal_entry: 'Finale Fassung (Stimme)',
@ -456,6 +457,9 @@ export default function SettingsPage() {
return (
<section className="card">
<h1>Einstellungen</h1>
<AccountPanel />
{session?.profile?.email_verified_at ? (
<>
<dl className="meta">
<dt>Name</dt>
<dd>{session?.profile?.name}</dd>
@ -815,6 +819,10 @@ export default function SettingsPage() {
}}
onError={setError}
/>
</>
) : (
<p className="muted">Bitte bestätige zuerst deine E-Mail-Adresse. Danach steht der Reflexionsraum wieder zur Verfügung.</p>
)}
</section>
)
}

View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
export default function VerifyEmailPage() {
const [params] = useSearchParams()
const { session, refreshProfile } = useAuth()
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const token = params.get('token') || ''
const confirm = async (event) => {
event.preventDefault()
setBusy(true)
setError('')
try {
await api('/api/auth/verify-email', { method: 'POST', body: { token } })
if (session?.token) await refreshProfile()
setMessage('Die E-Mail-Adresse ist bestätigt.')
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="auth-screen">
<form className="card" onSubmit={confirm}>
<h1>E-Mail bestätigen</h1>
{!token && <p className="error">Der Link enthält kein Token.</p>}
<p>Der Link gilt 24 Stunden und kann nur einmal verwendet werden.</p>
{message && <p>{message}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy || !token}>{busy ? 'Prüfe …' : 'Adresse bestätigen'}</button>
<Link to={session ? '/settings' : '/'}>{session ? 'Zum Konto' : 'Zur Anmeldung'}</Link>
</form>
</div>
)
}