Der Hinweis aus dem letzten Commit zeigte auf die Startseite von PraxisKI.
Richtig ist https://ki-praxis.praxis.local/anfragen/.
Neue Spalte anfragen.anrufernummer VARCHAR(255) NULL wird jetzt bei
Telefonanfragen als anklickbarer tel:-Link ausgegeben. GetTelefonLink()
baut das Ziel aus Ziffern und einem fuehrenden Plus, zeigt die Nummer aber
unveraendert an - Leerzeichen, Klammern, Slashes und Durchwahl-Bindestriche
stoeren den Link damit nicht. Nummern ganz ohne Ziffer, etwa bei
unterdrueckter Rufnummer, bleiben Text ohne Link. Der Rohwert wird in der
Funktion maskiert.
GetAnruferZeile() liefert die beschriftete Zeile nur fuer source='telefon'
und nur, wenn eine Nummer da ist. In der Liste steht sie in der
Kontaktspalte, wo Telefonnummern hingehoeren; dort ersetzt sie bei Anfragen
ohne zugeordneten Patienten den Platzhalter "Kontaktdaten siehe Nachricht".
In Antwortformular, Hinweisseite, Antwort einsehen und Patientenhistorie
steht sie bei den uebrigen Anfrageinformationen. Die Abfragen der letzten
beiden lasen die Spalte nicht mit, sie ist dort ergaenzt.
Die Spalte ist derzeit in allen 309 Telefonanfragen NULL - die Anlage
schreibt sie noch nicht. Bis dahin aendert sich die Anzeige nicht, der
bisherige Rueckfall greift.
Geprueft: GetTelefonLink gegen internationale, geklammerte, mit Leerzeichen
und Durchwahl geschriebene Nummern sowie gegen Leerwert, NULL, reinen Text
und einen Injektionsversuch - das Script kommt maskiert heraus, nicht als
Markup. Listenlogik ueber alle 12789 Anfragen mit simulierter Nummer
durchgerechnet: der Link erscheint genau dann, wenn source='telefon' ist
und eine Nummer vorliegt, und Nicht-Telefonzeilen bekommen kein
zusaetzliches <br> in die Kontaktspalte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anfragen.nachricht enthaelt bei source='telefon' nur das automatisch
erzeugte Transkript der Sprachnachricht, das Erkennungsfehler enthalten
kann. Bisher war aus der Oberflaeche nicht ersichtlich, dass es die
Aufnahme selbst noch gibt und wo sie liegt.
Neue Funktion GetSprachnachrichtHinweis() gibt fuer source='telefon' einen
Hinweis mit Link auf https://ki-praxis.praxis.local/ aus, fuer jede andere
Herkunft einen leeren String. Der Link ist nur aus dem Praxisnetz
erreichbar, das steht im Hinweis dabei.
Eingesetzt an allen fuenf Stellen, an denen eine Telefonanfrage sichtbar
wird: Anfrageliste, Antwortformular, Hinweisseite fuer Anfragen ohne
zugeordneten Patienten, Antwort einsehen und Patientenhistorie. Die
Abfragen fuer die letzten beiden lasen source bisher nicht mit, die Spalte
ist dort ergaenzt.
Nachgeprueft gegen die Datenbank: alle vier Abfragen liefern source, und
der Hinweis erscheint ueber alle 12785 Anfragen hinweg genau dann, wenn
source='telefon' ist - je Zeile geprueft, keine Ausnahme. Beide Dateien
bleiben UTF-8 ohne BOM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seit dem 02.09.2026 legt die Telefonanlage Anfragen mit source='telefon'
an. Von 309 solchen Zeilen haben 207 keine requester_person_id, weil der
Anrufer nicht zugeordnet werden konnte - darunter alle 33 offenen. Saemtliche
Anzeigepfade haengen per INNER JOIN an persons, wodurch genau diese Zeilen
kommentarlos aus dem Ergebnis fielen. Die Ansicht "Unbeantwortete Anfragen"
zeigte dadurch 2 statt 35 Eintraege.
Bei mail- und portal-Anfragen konnte das nicht auffallen: die Formulare
legen die Person vorher in persons an und brechen sonst ab (rezepte.php),
entsprechend hat dort keine einzige Zeile eine fehlende Person. personid
und userid stehen zwar auch bei mail auf 0, sind aber tote Legacy-Spalten
und tragen den Personenbezug nicht.
Die Anzeigepfade verwenden jetzt LEFT JOIN und zeigen ohne Treffer
"Kein Patient zugeordnet" statt einer leeren Zeile: Liste, Antworten,
Loesch-Dialog, Loeschen ohne Mail und Antwort einsehen. Die beiden Knoepfe,
die zwingend eine Mailadresse brauchen - Antworten und Loeschen mit Mail -
erscheinen nur noch mit zugeordneter Person; wer das Antwortformular direkt
aufruft, bekommt einen Hinweis samt Transkript statt eines Abbruchs.
"Telefonisch beantwortet" brauchte keine Aenderung, dieser Weg fragt nur
die anfrageid ab.
Der Mailversand in functions.inc.php und die patientenbezogene Historie
behalten ihren INNER JOIN - dort ist eine Person Voraussetzung.
Neu ist GetAnfrageHerkunft(). Die Liste leitete die Herkunft bisher aus
sicherenachricht ab, was Telefonanfragen mangels Wert als "Mailanfrage"
etikettierte. Nur dieser dritte Fall kommt aus source. Die Unterscheidung
intern/Mail bleibt bewusst an sicherenachricht haengen, weil insertAnfrage()
kein source setzt und 304 interne Anfragen deshalb als source='mail' in der
Tabelle stehen; ueber source vergeben haette das Label 9871 Bestandszeilen
umetikettiert, darunter die datenschutzrelevante Unterscheidung.
Nachgeprueft gegen die Datenbank: die Ansicht liefert statt 2 nun 35 Zeilen
mit allen 33 offenen Telefon-Anfragen. Der Renderblock ueber alle 12784
Zeilen simuliert, error_reporting=E_ALL als Exception, ohne Warnung; das
Herkunfts-Label bleibt fuer 12475 Bestandszeilen unveraendert und aendert
sich nur fuer die 309 Telefon-Zeilen. Alle fuenf von der Liste erreichbaren
Aktionen laden fuer eine Zeile ohne Person.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
intern/ enthaelt dieselben Patientendaten wie der Adminbereich und war
bisher genauso ungeschuetzt. config.inc.php aktiviert den Token-Filter
jetzt fuer beide Verzeichnisse. Eigenen AJAX-Code oder Nicht-HTML-Ausgaben
gibt es dort nicht, die Umstellung betrifft nur Formulare.
intern/login.php rief nach dem Include ein zweites session_start() auf und
setzte danach session.gc_maxlifetime und session.cookie_lifetime per
ini_set(). Beides lief ins Leere, weil die Sitzung zu dem Zeitpunkt schon
laeuft, und erzeugte Warnungen. Die Lebensdauer kommt aus
session_set_cookie_params() in config.inc.php.
13 Dateien begannen mit einem UTF-8-BOM. Die drei Bytes gehen vor dem
Include raus, womit die Header gesendet sind und
session_set_cookie_params() sowie session_start() in config.inc.php
scheitern - genau die Flags, um die es hier geht. Sichtbar wird das nur
ohne output_buffering, aber darauf sollte sich die Sitzungssicherheit
nicht verlassen.
Nachgeprueft mit output_buffering=0 und error_reporting=E_ALL: admin/ und
intern/ melden keine Header- oder Sessionwarnungen mehr, POSTs ohne Token
liefern 403, mit Token laufen sie durch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stored XSS
Die Anfrageuebersicht und das Antwortformular haben Nachricht, Medikamente,
Dateiname und Adressdaten des Patienten roh in HTML-Strings gesetzt.
Gespeichert wird der Text nur mit trim(), ein Patient konnte darueber
Skriptcode in den Browser der Mitarbeiterin einschleusen - mit deren
Sitzung. Die Werte laufen jetzt beim Auslesen durch e(). Der dritte Zweig
(Antwort einsehen) maskierte bereits bei der Ausgabe und bleibt, wie er
ist.
SQL-Injection
togoadmin.php interpolierte $_GET["id"] an drei Stellen ungecastet in
UPDATE-Statements, waehrend die Nachbarzeilen bereits (int) verwenden.
Dazu drei reflektierte Ausgaben desselben Wertes in Formularfelder.
Beides auf (int) umgestellt.
Formularziele
$_SERVER['PHP_SELF'] enthaelt bei Aufrufen wie /admin/anfragen.php/"><script>
auch den angehaengten Pfad und landete an 72 Stellen ungeprueft im HTML.
Ersetzt durch self_action() aus inc/security.inc.php, das den Basisnamen
des Skripts maskiert zurueckgibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neu: inc/security.inc.php, eingebunden von inc/config.inc.php.
CSRF
Der Adminbereich hatte keinerlei Schutz gegen fremde POSTs. Jetzt haengt
csrf_inject_output() als Ausgabefilter das Token an jedes POST-Formular und
csrf_require() weist POSTs ohne gueltiges Token ab. Beides wird nur fuer
Skripte unterhalb von admin/ aktiviert, der oeffentliche Bereich bleibt
unveraendert. Der Umweg ueber den Ausgabefilter erspart es, die rund 60
bestehenden Formulare einzeln anzufassen; der AJAX-Aufruf auf
mailtemplate.php schickt das Token als Feld mit.
Session-Cookie-Flags
config.inc.php setzt secure, httponly und samesite - aber 25 Dateien in
admin/ und intern/ riefen session_start() vor dem Include auf, womit die
Parameter wirkungslos waren. Die vorgezogenen Aufrufe sind entfernt,
config.inc.php startet die Sitzung nur noch, wenn keine laeuft.
admin/logout.php musste umgestellt werden, weil dort session_destroy()
vor dem Include stand; die Cookies werden jetzt mit denselben Parametern
geloescht, mit denen sie gesetzt wurden.
Brute-Force
Nach 5 Fehlversuchen je Konto oder 20 je IP ist die Anmeldung 15 Minuten
gesperrt, gezaehlt in der neuen Tabelle login_attempts. Fehlt die Tabelle,
laeuft der Login wie bisher - gleiche Vorgehensweise wie bei
securitytokensHatAblaufspalte(). Eine erfolgreiche Anmeldung raeumt die
Fehlversuche des Kontos ab.
Passwort vergessen
admin/passwortvergessen.php uebergab $mail und $body an SendMailMessage();
beide Variablen gibt es dort nicht, sie heissen $empfaenger und $text. Die
Mail ging deshalb nie raus, obwohl der Reset-Code gesetzt wurde.
Getestet gegen einen lokalen PHP-Server: Token wird eingesetzt, POST ohne
Token liefert 403, mit Token laeuft der Login normal, der sechste
Fehlversuch wird gesperrt, das Sitzungscookie traegt secure/HttpOnly/
SameSite. Oeffentliche Seiten sind unveraendert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Abfrage in aktion=10 stammte noch aus der Zeit der user-Tabelle und
selektierte p.mail, p.jahrgang sowie a.timeid. Keine dieser Spalten
existiert: persons fuehrt email und geburtstag, anfragen hat kein timeid.
Die Abfrage warf damit eine PDOException, und da der Block kein try/catch
hat, brach das Skript vor dem UPDATE ab - "Anforderung loeschen (ohne
Mail)" war komplett wirkungslos.
Spaltennamen korrigiert und die uebrig gebliebene Debug-Ausgabe von
timeid entfernt, die als einzige Stelle den Wert genutzt hat. Ein Scan
aller persons-Aliasse gegen das Schema zeigt keine weiteren Fundstellen.
Der Fehlertext beim fehlgeschlagenen Mailversand in aktion=3 behauptete,
es sei nichts gespeichert worden. Tatsaechlich laeuft das UPDATE vorher.
Die Meldung benennt jetzt den echten Zustand und bittet darum, den
Vorgang erneut auszufuehren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Button "Telefonisch beantwortet" (aktion=20) hat bisher die Mailvorlage
42 an die private Mailadresse des Patienten geschickt. Telefonisch erledigte
Anfragen duerfen die Praxis nie per Mail verlassen, der Versand entfaellt
daher ersatzlos.
Die Bestaetigungstexte benennen jetzt den tatsaechlichen Vorgang statt
faelschlich von einer Loeschung mit schriftlicher Mailbestaetigung zu
sprechen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Kommentar des vorigen Commits enthielt die Zeichenfolge des
schliessenden PHP-Tags, um das urspruengliche Problem zu erklaeren.
Genau die beendet den PHP-Block aber auch innerhalb eines
//-Kommentars - der gesamte nachfolgende Code wurde dadurch als Text
ins HTML geschrieben und das Laufband blieb leer.
Kommentar umformuliert, ohne die Zeichenfolge zu nennen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Lauftext stand in Short-Open-Tags:
+++<? echo $Laufband1 ?>+++
short_open_tag ist auf dem Server abgeschaltet, deshalb landete der
PHP-Quelltext woertlich im ausgelieferten HTML. Der Browser hat
"<? echo $Laufband1 ?>" als unbekanntes Tag verworfen - sichtbar
blieben nur die Pluszeichen. Ueberprueft am ausgelieferten HTML der
Startseite, dort stand der Quelltext unveraendert drin.
Beide Vorkommen nutzen jetzt <?php und eine gemeinsam aufbereitete
Ausgabe:
- Leere Eintraege werden uebersprungen; sind alle sechs leer, entfaellt
das Laufband ganz, statt leere +++ durchs Bild zu schicken.
- Der Inhalt kommt aus dem Editor in webseitenadmin.php und ist in der
Datenbank HTML. config.inc.php entfernt zwar die Tags, die Entities
bleiben aber stehen - daher erst html_entity_decode(), dann
htmlspecialchars(). Sonst waere aus & ein sichtbares "&" und
aus Ö ein "Ö" geworden.
- Beide Bloecke trugen dieselben id-Attribute (marquee-cont, scroll).
Doppelte ids sind ungueltiges HTML; sie sind durch eine Klasse
ersetzt, css/ticker.css spricht jetzt beide Schreibweisen an.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ergebnis der Durchsicht beider Bereiche (47 Dateien).
Patientendaten ohne Anmeldung abrufbar
--------------------------------------
admin/mailtemplate.php hatte keine Zugriffspruefung. Der Endpunkt
liefert gerenderte Mailvorlagen und damit Vorname, Nachname,
Geburtstag, Adresse, Medikamente, Anfragetext und den Anfragen-Link
mit dem hash. Ein anonymer POST wurde bis in die Datenbank verarbeitet
(geprueft mit ungueltiger templetid, es sind keine Daten geflossen).
Jetzt check_admin_user() mit HTTP 401. Ausserdem ging die
Exception-Meldung an den Aufrufer zurueck - sie geht jetzt ins Log.
intern/meineanfragen.php filterte beim Detailaufruf ausschliesslich
auf die anfrageid aus $_POST, ohne Bezug zum angemeldeten Benutzer -
und setzte sie unmaskiert ins SQL. Jeder registrierte Patient konnte
damit fremde Anfragen samt Geburtstag, Adresse und Telefonnummer
lesen. Jetzt Prepared Statement und zusaetzlich an die E-Mail des
angemeldeten Benutzers gebunden, wie in der Listenansicht derselben
Datei.
SQL-Injection
-------------
admin/togoadmin.php: 14 Abfragen bauten $_GET/$_POST direkt in das
SQL. Ganzzahlige Spalten bekommen einen (int)-Cast, damit die
umgebenden mysqli-Schleifen unveraendert bleiben; alle INSERT- und
UPDATE-Anweisungen mit Textwerten sind auf Prepared Statements
umgestellt. create_time dort jetzt per NOW() statt PHP-date().
Ein abschliessender Scan ueber intern/, admin/ und zeiterfassung/
findet keine verkettete Nutzereingabe in SQL mehr.
Zugriffspruefung ohne Wirkung
-----------------------------
admin/anrufbeantworter.php und admin/kalender.php riefen
check_admin_user() auf, werteten den Rueckgabewert aber nie aus. Die
Funktion liefert bei fehlender Anmeldung nur null, sie bricht nicht
ab - beide Seiten rendered fuer anonyme Besucher weiter. Daten flossen
nicht ab, aber die Oberflaeche war sichtbar. Jetzt gleiches Muster wie
admin/index.php.
Tokens ohne Ablauf
------------------
Die Tabelle securitytokens hatte keine Ablaufspalte: ein erbeutetes
Admin-Cookie galt unbegrenzt. Der Patientenbereich setzt 30 Tage.
securitytokensHatAblaufspalte() prueft die Spalte zur Laufzeit, damit
der Code vor und nach der Migration laeuft; admin/login.php setzt den
Ablauf beim Anlegen, check_admin_user() beruecksichtigt ihn beim
Lesen. Migration in admin/sql/. Die Cookie-Laufzeit war auf 365 Tage
gesetzt und ist jetzt deckungsgleich mit dem Token.
Entfernt
--------
admin/phpinfo.php lieferte ohne Anmeldung 102 KB Serverkonfiguration.
intern/admin.php, admin/admin.php sowie mailtemplatebody.php und
mailtemplatebetreff.php in beiden Verzeichnissen waren tot (falscher
relativer require-Pfad, HTTP 500) - die mailtemplate-Vorgaenger
enthielten zudem rohe SQL-Injection mit Patientendaten. Keine der
sechs Dateien wird irgendwo aufgerufen.
admin/sql war als leere Datei statt als Verzeichnis angelegt.
Kleinere Korrekturen
--------------------
intern/authentifizierung.php und admin/passwortzuruecksetzen.php
verglichen das Ergebnis von fetch() mit null statt false und pruefen
den Ablauf des Codes jetzt in SQL statt mit strtotime()/time() -
dieselbe Zeitzonenfalle wie bei den 2FA-Codes, hier in die harmlose
Richtung. Ein toter password_hash()-Aufruf auf einer nie gesetzten
Variablen ist entfallen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beim Umstellen der git-ftp-Konfiguration ist aufgefallen, dass
.vscode/ auf dem Server lag und per HTTPS ohne jede Sperre abrufbar
war: sftp.json und ftp-sync.json lieferten mit HTTP 200 das
FTP-Passwort im Klartext aus, settings.json Host, Datenbankname und
Benutzer der MySQL-Verbindung (dort ohne Passwort).
Die drei Dateien und das Verzeichnis sind vom Server geloescht, die
URLs antworten jetzt mit 404. .git/ war bereits serverseitig
gesperrt, ebenso .git-ftp.log und die .sql-Datei im Root.
Zusaetzlich sperrt die Root-.htaccess jetzt Editor-, Versions- und
Konfigurationsdateien: Punkt-Verzeichnisse (.git, .vscode, .svn, .hg,
.idea, .env) per RedirectMatch 404 sowie die Endungen jsonc, sql,
log, ini, bak, old, orig, save, swp, dist. Vorher geprueft, dass
nichts davon per HTTP geladen wird; .json ist bewusst nicht
gesperrt, damit Bibliotheken unter admin/ weiter funktionieren.
Nach dem Ausrollen verifiziert: Startseite, /termine (Rewrite),
intern, zeiterfassung, admin, CSS, JS und Bilder liefern weiter 200.
.git-ftp.log ist aus der Versionierung genommen. Die Datei ist der
Deployment-Zustand, den git-ftp auf dem Server fuehrt; die lokale
Kopie stammt nur aus dem Sync und kann den eigenen Commit
naturgemaess nie enthalten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sicherungskopien
----------------
intern/neueanfrage-old1.php und -old2.php waren toter Code, aber als
.php im Web-Root oeffentlich aufrufbar - inklusive eigener DB- und
Mailzugriffe. Auf sie verweist nichts, sie sind jetzt entfernt.
Der Stand bleibt ueber die Git-Historie erreichbar.
Datenbank-Zugangsdaten
----------------------
Host, Benutzer, Passwort und Datenbankname standen im Klartext in
inc/config.inc.php UND in zeiterfassung/inc/config.inc.php, beide
versioniert. Sie liegen jetzt in inc/credentials.php, das per
.gitignore ausgenommen ist; beide Configs laden es und brechen mit
HTTP 500 und einem Log-Eintrag ab, wenn es fehlt, statt sich still
ohne Zugangsdaten zu verbinden. inc/credentials.example.php ist als
versionierte Vorlage dabei.
FTP-Zugangsdaten
----------------
.vscode/ftp-sync.json und .vscode/sftp.json enthalten das
FTP-Passwort und waren versioniert - die .gitignore-Eintraege dafuer
gab es zwar, sie greifen bei bereits getrackten Dateien aber nicht.
Beide sind jetzt per "git rm --cached" aus der Versionierung genommen
und bleiben lokal liegen. Die passenden .example-Dateien existieren
bereits.
Verzeichnisschutz
-----------------
inc/ und zeiterfassung/inc/ enthalten ausschliesslich Includes, waren
als Teil des Web-Roots aber direkt per URL abrufbar. Beide bekommen
eine .htaccess, die den HTTP-Zugriff verweigert. PHP-includes sind
davon nicht betroffen, und per HTTP greift nichts auf diese
Verzeichnisse zu.
Wichtig: das Passwort steht weiterhin in der Git-Historie und wurde
bereits gepusht. Diese Aenderung verhindert nur die Weiterverbreitung.
Wirksam entschaerft ist es erst, wenn DB- und FTP-Passwort gewechselt
werden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei zusammenhaengende Punkte aus der Analyse des Login-Problems.
1. PHP-Fehler wurden an Besucher ausgegeben
display_errors war in impfwarteliste.php (oeffentlich), intern/
impfwarteliste.php, intern/neueanfrage.php, den beiden neueanfrage-old
Sicherungen und zeiterfassung/api/vacations.php fest eingeschaltet.
Die oeffentliche Impfwarteliste zeigte Patienten bei einem Fatal Error
sogar Meldung, Dateipfad und Zeilennummer. Ueberall auf log_errors
umgestellt: E_ALL wird weiterhin vollstaendig erfasst, landet aber im
Server-Log statt auf der Seite. Der Shutdown-Handler der Impfwarteliste
protokolliert die Details und zeigt nur noch einen neutralen Hinweis.
Auch register.php gab die rohe Exception-Meldung aus.
2. SendMailMessageSilent() verschluckte jeden Fehler
Der catch-Block war leer. Ein SMTP-Ausfall war dadurch von aussen nicht
von einem falschen Code zu unterscheiden - der Benutzer landete auf
verify_2fa.php und wartete auf eine Mail, die nie kam. Die Funktion
protokolliert jetzt und liefert einen bool zurueck. login.php wertet
das aus, nimmt bei Fehlschlag den 2FA-Datensatz und die Session-Vormerkung
zurueck und sagt es auf der Login-Seite, statt weiterzuleiten.
Nebenbei entfernt: ein uebrig gebliebenes echo, das den Mailserver-Namen
mitten in die Seite schrieb, sowie ein zweites mysqli_fetch_assoc() auf
demselben Result, das nur NULL liefern konnte.
3. register.php verschickte keine Bestaetigungsmail
mailreg blieb 0, jeder neue Benutzer landete nach dem Login auf der
Aufforderung, die Authentifizierung selbst anzustossen. Die Mail geht
jetzt direkt nach der Registrierung raus. Erzeugung und Text liegen in
der neuen Funktion sendeAuthentifizierungsMail(), die authmeldung.php
ebenfalls benutzt - dort wurde der Rueckgabewert des Versands bisher
einer Variablen zugewiesen und nie ausgewertet, die Seite meldete
Erfolg auch bei fehlgeschlagenem Versand.
Der Versand laeuft nach dem commit(), deshalb prueft der catch-Block in
register.php jetzt inTransaction(), bevor er rollBack() aufruft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Pruefung war auskommentiert, Reset-Links galten damit unbegrenzt,
obwohl die E-Mail "innerhalb der naechsten 24 Stunden" verspricht.
Sie wird jetzt in SQL ausgewertet statt mit strtotime()/time():
passwortcode_time wird in passwortvergessen.php mit NOW() gesetzt, also
mit der Uhr des DB-Servers (Europe/Berlin), waehrend der Webserver in
UTC laeuft. Eine Pruefung in PHP waere um diese zwei Stunden falsch -
dieselbe Ursache, die schon die 2FA-Codes unbrauchbar gemacht hat.
So benutzen Setzen und Pruefen dieselbe Uhr.
Ausserdem: fetch() liefert false und nicht null, wenn kein Benutzer
gefunden wird. Die Bedingung $user === null hat deshalb nie gegriffen,
und der Code lief mit false weiter in den Array-Zugriff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
login.php erzeugte expires_at mit PHPs date(), verify_2fa.php verglich
gegen MySQLs NOW(). Webserver (secureserver.net) und Datenbankserver
(mysql2fda.netcup.net) liegen bei verschiedenen Anbietern und sind
unabhaengig voneinander konfiguriert. Laeuft PHP in UTC und MySQL in
Europe/Berlin, liegt expires_at (PHP-Zeit + 5 Minuten) zwei Stunden
vor NOW() - jeder Code gilt sofort als abgelaufen und wird als
"Falscher oder abgelaufener Code" abgewiesen.
Die Ablaufzeit wird jetzt beim INSERT per DATE_ADD(NOW(), INTERVAL 5
MINUTE) berechnet. Erzeugung und Pruefung benutzen damit dieselbe Uhr,
unabhaengig davon, wie die beiden Server eingestellt sind.
Ausserdem:
- Der eingegebene Code wird auf Ziffern reduziert. Aus HTML-Mails
kopierte Codes schleppen oft Leerzeichen oder geschuetzte
Leerzeichen mit, die den Hash-Vergleich scheitern liessen.
- Abgelaufener Code und falscher Code werden getrennt gemeldet, damit
ein solcher Fall kuenftig ohne Raten eingegrenzt werden kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
index.php und neueanfrage.php gaben das Header-Template aus, bevor
check_intern_user() aufgerufen wurde. Dadurch schlugen setcookie(),
session_regenerate_id() und header('Location: login.php') mit
"headers already sent" fehl: die Session wurde geloescht ohne neues
Cookie zu setzen, der rotierte Remember-Token landete nur in der DB,
und die Weiterleitung blieb wirkungslos. Ergebnis war eine halb
gerenderte Seite statt des Logins. Beide Seiten puffern jetzt mit
ob_start(), wie login.php und verify_2fa.php es bereits tun.
Weitere Fehler im selben Ablauf:
- passwortvergessen.php uebergab $con (mysqli) an SendMailMessage(),
das PDO erwartet -> fataler TypeError, "Passwort vergessen" brach
immer mit HTTP 500 ab und verschickte nie eine Mail.
- logout.php war aus dem Admin-Bereich kopiert und loeschte weder
intern_securitytokens noch die Cookies remember_device /
remember_device_token. Der Logout war damit wirkungslos, weil
check_intern_user() sofort wieder ueber das Cookie anmeldete.
- is_checked_in_index() pruefte das Admin-Cookie 'identifier' statt
'remember_device'.
- index.php gab das undefinierte $email_value im Login-Formular aus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>