Files
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00

68 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Anmeldung: Name und Passwort, gehasht mit password_hash() (bcrypt/argon2 je nach PHP).
*
* Nach fünf Fehlversuchen in 15 Minuten nimmt diese IP-Adresse eine Viertelstunde lang keine
* Anmeldung mehr an.
*/
final class Konten
{
private const VERSUCHE = 5;
private const SPERRE_SEKUNDEN = 900;
public function __construct(private \PDO $db)
{
}
public function gibtEs(): bool
{
return (int) $this->db->query('SELECT COUNT(*) FROM konto')->fetchColumn() > 0;
}
public function anlegen(string $name, string $passwort): void
{
$this->db->prepare('INSERT INTO konto (name, passwort, erstellt) VALUES (?, ?, ?)')
->execute([$name, password_hash($passwort, PASSWORD_DEFAULT), Datenbank::jetzt()]);
}
public function gesperrt(string $ip): bool
{
$abfrage = $this->db->prepare('SELECT COUNT(*) FROM anmeldeversuch WHERE ip = ? AND zeit > ?');
$abfrage->execute([$ip, gmdate('Y-m-d\TH:i:s\Z', time() - self::SPERRE_SEKUNDEN)]);
return (int) $abfrage->fetchColumn() >= self::VERSUCHE;
}
/** @return array{id: int, name: string}|null */
public function pruefen(string $name, string $passwort, string $ip): ?array
{
$abfrage = $this->db->prepare('SELECT id, name, passwort FROM konto WHERE name = ?');
$abfrage->execute([$name]);
$konto = $abfrage->fetch() ?: null;
// Auch ohne Konto einmal hashen: Die Antwortzeit verrät sonst, welche Namen es gibt.
$hash = null !== $konto ? (string) $konto['passwort'] : password_hash('attrappe', PASSWORD_DEFAULT);
if (!password_verify($passwort, $hash) || null === $konto) {
$this->fehlversuch($ip);
return null;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$this->db->prepare('UPDATE konto SET passwort = ? WHERE id = ?')->execute([password_hash($passwort, PASSWORD_DEFAULT), $konto['id']]);
}
$this->db->prepare('DELETE FROM anmeldeversuch WHERE ip = ?')->execute([$ip]);
return ['id' => (int) $konto['id'], 'name' => (string) $konto['name']];
}
public function fehlversuch(string $ip): void
{
$this->db->prepare('INSERT INTO anmeldeversuch (ip, zeit) VALUES (?, ?)')->execute([$ip, Datenbank::jetzt()]);
$this->db->prepare('DELETE FROM anmeldeversuch WHERE zeit < ?')->execute([gmdate('Y-m-d\TH:i:s\Z', time() - 86400)]);
}
}