TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c130c85b17
commit
f7805a2180
211 files changed
+31099
-14759
No files matched your search
@@ -0,0 +1,278 @@
|
||||
"""Kundenportal -- zeitlich begrenzter Zugang ohne Konto.
|
||||
|
||||
Zweistufig, bewusst:
|
||||
|
||||
1. ``/portal`` nimmt eine E-Mail-Adresse an und verschickt einen **einmal**
|
||||
verwendbaren Zugangslink mit kurzer Gueltigkeit.
|
||||
2. Der Link erzeugt eine Sitzung mit eigener, unabhaengiger Gueltigkeit.
|
||||
|
||||
Der Vorgaenger hatte zuerst einen permanenten, nie ablaufenden Link pro Kunde
|
||||
(``/portal/<token>``). Das wurde als Schwachstelle erkannt und ersetzt -- hier
|
||||
existiert die permanente Variante gar nicht.
|
||||
|
||||
Anti-Enumeration: die Antwort ist immer dieselbe, unabhaengig davon, ob die
|
||||
Adresse bekannt ist. Ein Fehler beim Mailversand wird dem Anfragenden gegenueber
|
||||
verschwiegen, aber serverseitig protokolliert -- sonst waere eine kaputte
|
||||
Mailkonfiguration unsichtbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.security import ratelimit
|
||||
|
||||
from ..services import issuance, mail
|
||||
from .common import customer_facing_url, vendor_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("portal", __name__, url_prefix="/portal")
|
||||
|
||||
SESSION_CUSTOMER = "portal_customer_id"
|
||||
SESSION_EXPIRES = "portal_expires_at"
|
||||
RATE_BUCKET = "portal:request"
|
||||
RATE_LIMIT = 5
|
||||
RATE_WINDOW_MINUTES = 15
|
||||
|
||||
GENERIC_ANSWER = (
|
||||
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
||||
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
|
||||
)
|
||||
|
||||
EDITABLE_FIELDS = (
|
||||
"contact_person",
|
||||
"contact_phone",
|
||||
"contact_email",
|
||||
"street",
|
||||
"house_number",
|
||||
"postal_code",
|
||||
"city",
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _parse(value: Any) -> datetime | None:
|
||||
"""Liest einen Zeitstempel aus der Datenbank.
|
||||
|
||||
Bewusst geparst statt als Zeichenkette verglichen: ein Wert, der ueber
|
||||
``datetime('now')`` von SQLite selbst geschrieben wurde, hat ein Leerzeichen
|
||||
statt ``T`` und keine Zeitzone -- ein lexikografischer Vergleich mit unserem
|
||||
ISO-Format waere dann falsch, und zwar zugunsten von "abgelaufen".
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip().replace(" ", "T")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(core().settings.get(db(), "portal_enabled"))
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
def request_link():
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
minutes = int(extension.settings.get(conn, "portal_link_minutes") or 30)
|
||||
if request.method == "POST":
|
||||
email = (request.form.get("email") or "").strip()
|
||||
verdict = ratelimit.check(
|
||||
conn,
|
||||
RATE_BUCKET,
|
||||
client_ip() or "unbekannt",
|
||||
limit=RATE_LIMIT,
|
||||
window_minutes=RATE_WINDOW_MINUTES,
|
||||
)
|
||||
with transaction():
|
||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||
if verdict.blocked:
|
||||
# Auch hier keine abweichende Aussage -- nur eine Bremse.
|
||||
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
customer = issuance.find_customer_by_email(conn, email)
|
||||
if customer is not None:
|
||||
token = secrets.token_urlsafe(32)
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO portal_access_tokens (token, customer_id, created_at, "
|
||||
"expires_at, created_ip) VALUES (?,?,?,?,?)",
|
||||
(
|
||||
token,
|
||||
int(customer["id"]),
|
||||
_iso(_now()),
|
||||
_iso(_now() + timedelta(minutes=minutes)),
|
||||
client_ip() or "",
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.link_requested",
|
||||
target=str(customer["name"]),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
access_url = customer_facing_url("portal.access", token=token)
|
||||
vendor = vendor_info()
|
||||
try:
|
||||
mail.send(
|
||||
conn,
|
||||
to=str(customer["contact_email"]),
|
||||
subject="Ihr Zugang zum Kundenportal",
|
||||
body="\n".join(
|
||||
[
|
||||
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
||||
"",
|
||||
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
||||
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
|
||||
"",
|
||||
access_url,
|
||||
"",
|
||||
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
||||
"",
|
||||
"Freundliche Gruesse",
|
||||
vendor.get("name") or "Ihr Lizenzgeber",
|
||||
]
|
||||
),
|
||||
)
|
||||
except mail.MailError as exc:
|
||||
# Bewusst nicht nach aussen tragen (Anti-Enumeration), aber
|
||||
# serverseitig sichtbar machen.
|
||||
logger.error("Portal-Mail an %s fehlgeschlagen: %s", email, exc)
|
||||
|
||||
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
return render_template("license/portal_request.html", minutes=minutes)
|
||||
|
||||
|
||||
@bp.get("/zugang/<token>")
|
||||
def access(token: str):
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM portal_access_tokens WHERE token=?", (token,)
|
||||
)
|
||||
expires = _parse(row["expires_at"]) if row is not None else None
|
||||
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
||||
if not valid:
|
||||
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
||||
with transaction():
|
||||
# Einmalverwendung erzwingen: auch innerhalb der Gueltigkeit scheitert
|
||||
# ein zweiter Aufruf.
|
||||
conn.execute(
|
||||
"UPDATE portal_access_tokens SET used_at=? WHERE token=? AND used_at IS NULL",
|
||||
(_iso(_now()), token),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.session_started",
|
||||
target=str(row["customer_id"]),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
session[SESSION_CUSTOMER] = int(row["customer_id"])
|
||||
session[SESSION_EXPIRES] = _iso(_now() + timedelta(minutes=minutes))
|
||||
return redirect(url_for("portal.me"))
|
||||
|
||||
|
||||
def _current_customer() -> dict[str, Any] | None:
|
||||
customer_id = session.get(SESSION_CUSTOMER)
|
||||
expires = _parse(session.get(SESSION_EXPIRES))
|
||||
if not customer_id or expires is None or _now() >= expires:
|
||||
session.pop(SESSION_CUSTOMER, None)
|
||||
session.pop(SESSION_EXPIRES, None)
|
||||
return None
|
||||
return issuance.get_customer(db(), int(customer_id))
|
||||
|
||||
|
||||
@bp.route("/meine-daten", methods=["GET", "POST"])
|
||||
def me():
|
||||
conn = db()
|
||||
if not _enabled():
|
||||
return render_template("license/portal_disabled.html"), 404
|
||||
|
||||
customer = _current_customer()
|
||||
if customer is None:
|
||||
flash("Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Zugangslink an.", "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
|
||||
if request.method == "POST":
|
||||
fields = {key: request.form.get(key, "") for key in EDITABLE_FIELDS}
|
||||
try:
|
||||
with transaction():
|
||||
changes = issuance.update_customer(
|
||||
conn, int(customer["id"]), name=str(customer["name"]), **fields
|
||||
)
|
||||
if changes:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="portal",
|
||||
action="portal.customer_updated",
|
||||
target=str(customer["name"]),
|
||||
detail=", ".join(changes),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
|
||||
except issuance.IssuanceError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("portal.me"))
|
||||
|
||||
tickets = issuance.list_tickets(conn, customer_id=int(customer["id"]))
|
||||
licenses = {
|
||||
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
|
||||
for row in issuance.list_licenses(conn, customer_id=int(customer["id"]))
|
||||
if row["status"] in {"issued", "active"}
|
||||
}
|
||||
self_service_enabled = bool(core().settings.get(conn, "self_service_enabled"))
|
||||
return render_template(
|
||||
"license/portal_me.html",
|
||||
customer=customer,
|
||||
tickets=tickets,
|
||||
licenses=licenses,
|
||||
expires_at=session.get(SESSION_EXPIRES),
|
||||
self_service_enabled=self_service_enabled,
|
||||
vendor=vendor_info(),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/abmelden")
|
||||
def logout():
|
||||
session.pop(SESSION_CUSTOMER, None)
|
||||
session.pop(SESSION_EXPIRES, None)
|
||||
flash("Sie wurden abgemeldet.", "info")
|
||||
return redirect(url_for("portal.request_link"))
|
||||
Reference in new issue
Block a user