diff --git a/.env.example b/.env.example index 0aea4bc..cf94cd9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 0ef42ef..cfec006 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 + diff --git a/backend/account_access.py b/backend/account_access.py new file mode 100644 index 0000000..b120864 --- /dev/null +++ b/backend/account_access.py @@ -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 diff --git a/backend/db.py b/backend/db.py index a96822f..37d8733 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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) diff --git a/backend/mailer.py b/backend/mailer.py new file mode 100644 index 0000000..03a233d --- /dev/null +++ b/backend/mailer.py @@ -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) diff --git a/backend/migrations/027_account_identifiers.sql b/backend/migrations/027_account_identifiers.sql new file mode 100644 index 0000000..b092c25 --- /dev/null +++ b/backend/migrations/027_account_identifiers.sql @@ -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 <> ''; diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 113b70d..a3e963e 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -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} diff --git a/backend/routers/users.py b/backend/routers/users.py index d9e1dc5..e52a4ec 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -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 diff --git a/backend/schema.sql b/backend/schema.sql index 6e63346..07e3541 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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, diff --git a/backend/sqlite_to_postgres.py b/backend/sqlite_to_postgres.py index acf9349..7be38a0 100644 --- a/backend/sqlite_to_postgres.py +++ b/backend/sqlite_to_postgres.py @@ -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",), diff --git a/backend/tests/test_account_access.py b/backend/tests/test_account_access.py new file mode 100644 index 0000000..19cd4e3 --- /dev/null +++ b/backend/tests/test_account_access.py @@ -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() diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 447e050..76ea812 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 72f5f11..f884e0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/architecture/technical/auth_identity_and_roles.md b/docs/architecture/technical/auth_identity_and_roles.md index 951080e..48c98e9 100644 --- a/docs/architecture/technical/auth_identity_and_roles.md +++ b/docs/architecture/technical/auth_identity_and_roles.md @@ -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 diff --git a/docs/architecture/technical/security.md b/docs/architecture/technical/security.md index 29b6531..f749653 100644 --- a/docs/architecture/technical/security.md +++ b/docs/architecture/technical/security.md @@ -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. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8d41030..6505ca9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (