feat(security): Sessions, Login, CSRF, Rate-Limiting und Setup-Token
Session-Login mit genau einem Admin-Konto, Passwort als bcrypt-Hash. Sessions mit HttpOnly, SameSite=Lax, strict mode, ID-Wechsel beim Login, Inaktivitaets- und absoluter Zeitgrenze; ueber HTTPS wird das Cookie automatisch auf secure gesetzt. Ein Passwortwechsel entwertet bestehende Sitzungen. Das Rate-Limiting zaehlt Fehlversuche pro IP-Hash - die IP selbst wird nie gespeichert. SetupToken schliesst das Zeitfenster zwischen Upload und erstem Aufruf: ohne das Einmal-Token aus dem Datenverzeichnis laesst sich kein Konto anlegen.
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-basierte Authentifizierung mit genau einem Admin-Konto.
|
||||||
|
*
|
||||||
|
* Die Zugangsdaten kommen entweder aus der .env (ADMIN_USER /
|
||||||
|
* ADMIN_PASSWORD_HASH) oder - falls dort nichts gesetzt ist - aus der
|
||||||
|
* settings-Tabelle, die der Setup-Assistent befuellt.
|
||||||
|
*/
|
||||||
|
final class Auth
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = '_admin_authenticated';
|
||||||
|
private const SESSION_USER = '_admin_user';
|
||||||
|
private const SESSION_FINGERPRINT = '_admin_fingerprint';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly SettingRepository $settings,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function username(): string
|
||||||
|
{
|
||||||
|
$fromEnv = $this->config->getString('ADMIN_USER', 'admin');
|
||||||
|
|
||||||
|
// Steht das Passwort in der .env, gilt auch der Benutzername von dort.
|
||||||
|
if ($this->config->adminPasswordHash() !== null) {
|
||||||
|
return $fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->settings->get(SettingRepository::ADMIN_USER, $fromEnv) ?? $fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function passwordHash(): ?string
|
||||||
|
{
|
||||||
|
return $this->config->adminPasswordHash()
|
||||||
|
?? $this->settings->get(SettingRepository::ADMIN_PASSWORD_HASH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist bereits ein Admin-Konto eingerichtet?
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$hash = $this->passwordHash();
|
||||||
|
|
||||||
|
return is_string($hash) && $hash !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt Zugangsdaten in der Datenbank an (Setup-Assistent).
|
||||||
|
*/
|
||||||
|
public function configure(string $username, string $plainPassword): void
|
||||||
|
{
|
||||||
|
$this->settings->set(SettingRepository::ADMIN_USER, $username);
|
||||||
|
$this->settings->set(
|
||||||
|
SettingRepository::ADMIN_PASSWORD_HASH,
|
||||||
|
password_hash($plainPassword, PASSWORD_DEFAULT),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Benutzername und Passwort in konstanter Zeit.
|
||||||
|
*/
|
||||||
|
public function verifyCredentials(string $username, string $password): bool
|
||||||
|
{
|
||||||
|
$hash = $this->passwordHash();
|
||||||
|
|
||||||
|
if (!is_string($hash) || $hash === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immer beide Pruefungen ausfuehren, damit ein falscher
|
||||||
|
// Benutzername nicht schneller beantwortet wird als ein
|
||||||
|
// falsches Passwort.
|
||||||
|
$userOk = hash_equals($this->username(), $username);
|
||||||
|
$passwordOk = password_verify($password, $hash);
|
||||||
|
|
||||||
|
return $userOk && $passwordOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $username): void
|
||||||
|
{
|
||||||
|
$this->session->regenerate();
|
||||||
|
$this->session->set(self::SESSION_KEY, true);
|
||||||
|
$this->session->set(self::SESSION_USER, $username);
|
||||||
|
$this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->session->destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if ($this->session->get(self::SESSION_KEY) !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwortwechsel invalidiert bestehende Sessions.
|
||||||
|
if ($this->session->get(self::SESSION_FINGERPRINT) !== $this->fingerprint()) {
|
||||||
|
$this->session->destroy();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUser(): ?string
|
||||||
|
{
|
||||||
|
$user = $this->session->get(self::SESSION_USER);
|
||||||
|
|
||||||
|
return is_string($user) ? $user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fingerprint(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', (string) $this->passwordHash());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Schutz per Synchronizer-Token (ein Token pro Session).
|
||||||
|
*/
|
||||||
|
final class Csrf
|
||||||
|
{
|
||||||
|
public const FIELD = '_csrf';
|
||||||
|
|
||||||
|
public function __construct(private readonly Session $session)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function token(): string
|
||||||
|
{
|
||||||
|
$token = $this->session->get('_csrf_token');
|
||||||
|
|
||||||
|
if (!is_string($token) || strlen($token) !== 64) {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->session->set('_csrf_token', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValid(?string $candidate): bool
|
||||||
|
{
|
||||||
|
if (!is_string($candidate) || $candidate === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->session->get('_csrf_token');
|
||||||
|
|
||||||
|
return is_string($token) && hash_equals($token, $candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verstecktes Formularfeld.
|
||||||
|
*/
|
||||||
|
public function field(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<input type="hidden" name="%s" value="%s">',
|
||||||
|
self::FIELD,
|
||||||
|
htmlspecialchars($this->token(), ENT_QUOTES, 'UTF-8'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-Limiting fuer den Login. Speichert nur einen gesalzenen Hash
|
||||||
|
* der IP-Adresse, niemals die IP selbst.
|
||||||
|
*/
|
||||||
|
final class RateLimiter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Database $db,
|
||||||
|
private readonly int $maxAttempts = 5,
|
||||||
|
private readonly int $lockoutSeconds = 900,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private function identifier(string $ip): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', 'login|' . $ip), 0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzahl der Fehlversuche im aktuellen Zeitfenster.
|
||||||
|
*/
|
||||||
|
public function failedAttempts(string $ip): int
|
||||||
|
{
|
||||||
|
$since = gmdate('Y-m-d H:i:s', time() - $this->lockoutSeconds);
|
||||||
|
|
||||||
|
$count = $this->db->fetchValue(
|
||||||
|
'SELECT COUNT(*) FROM login_attempts WHERE identifier = ? AND successful = 0 AND attempted_at > ?',
|
||||||
|
[$this->identifier($ip), $since],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocked(string $ip): bool
|
||||||
|
{
|
||||||
|
return $this->failedAttempts($ip) >= $this->maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remainingAttempts(string $ip): int
|
||||||
|
{
|
||||||
|
return max(0, $this->maxAttempts - $this->failedAttempts($ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbleibende Sperrzeit in Sekunden.
|
||||||
|
*/
|
||||||
|
public function retryAfter(string $ip): int
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchOne(
|
||||||
|
'SELECT attempted_at FROM login_attempts
|
||||||
|
WHERE identifier = ? AND successful = 0
|
||||||
|
ORDER BY attempted_at DESC LIMIT 1',
|
||||||
|
[$this->identifier($ip)],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = strtotime((string) $row['attempted_at'] . ' UTC') ?: time();
|
||||||
|
|
||||||
|
return max(0, $this->lockoutSeconds - (time() - $last));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordFailure(string $ip): void
|
||||||
|
{
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO login_attempts (identifier, attempted_at, successful) VALUES (?, ?, 0)',
|
||||||
|
[$this->identifier($ip), gmdate('Y-m-d H:i:s')],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pruneOldEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordSuccess(string $ip): void
|
||||||
|
{
|
||||||
|
// Erfolgreicher Login setzt den Zaehler zurueck.
|
||||||
|
$this->db->execute(
|
||||||
|
'DELETE FROM login_attempts WHERE identifier = ?',
|
||||||
|
[$this->identifier($ip)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pruneOldEntries(): void
|
||||||
|
{
|
||||||
|
$cutoff = gmdate('Y-m-d H:i:s', time() - max($this->lockoutSeconds, 86400));
|
||||||
|
$this->db->execute('DELETE FROM login_attempts WHERE attempted_at < ?', [$cutoff]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichere Session-Konfiguration.
|
||||||
|
*/
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
private bool $started = false;
|
||||||
|
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $isSecureRequest Laeuft die Anfrage ueber HTTPS?
|
||||||
|
* Dann wird das Cookie automatisch auf "secure" gesetzt,
|
||||||
|
* auch ohne ausdrueckliche Konfiguration.
|
||||||
|
*/
|
||||||
|
public function start(bool $isSecureRequest = false): void
|
||||||
|
{
|
||||||
|
if ($this->started || PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
$this->started = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lifetime = $this->config->getInt('SESSION_LIFETIME', 7200);
|
||||||
|
$absoluteLifetime = $this->config->getInt('SESSION_ABSOLUTE_LIFETIME', 43200);
|
||||||
|
|
||||||
|
session_name($this->config->getString('SESSION_NAME', 'urlshort_session'));
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $this->config->getBool('SESSION_SECURE', false) || $isSecureRequest,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
ini_set('session.use_only_cookies', '1');
|
||||||
|
ini_set('session.cookie_httponly', '1');
|
||||||
|
ini_set('session.gc_maxlifetime', (string) $lifetime);
|
||||||
|
ini_set('session.sid_length', '48');
|
||||||
|
ini_set('session.sid_bits_per_character', '5');
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
$this->started = true;
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Inaktivitaetsgrenze
|
||||||
|
$last = $this->get('_last_activity');
|
||||||
|
if (is_int($last) && $now - $last > $lifetime) {
|
||||||
|
$this->destroy();
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute Obergrenze, unabhaengig von der Aktivitaet: eine
|
||||||
|
// gestohlene Sitzung bleibt so nicht beliebig lange nutzbar.
|
||||||
|
$startedAt = $this->get('_started_at');
|
||||||
|
if ($absoluteLifetime > 0 && is_int($startedAt) && $now - $startedAt > $absoluteLifetime) {
|
||||||
|
$this->destroy();
|
||||||
|
session_start();
|
||||||
|
$startedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_int($startedAt)) {
|
||||||
|
$this->set('_started_at', $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->set('_last_activity', $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $_SESSION[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $key): void
|
||||||
|
{
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerate(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
// Frische Sitzung: die absolute Laufzeit beginnt neu.
|
||||||
|
$this->set('_started_at', time());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name() ?: 'urlshort_session', '', [
|
||||||
|
'expires' => time() - 42000,
|
||||||
|
'path' => $params['path'],
|
||||||
|
'domain' => $params['domain'],
|
||||||
|
'secure' => $params['secure'],
|
||||||
|
'httponly' => $params['httponly'],
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmal-Meldung setzen (Flash).
|
||||||
|
*/
|
||||||
|
public function flash(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$messages = $this->get('_flash', []);
|
||||||
|
$messages = is_array($messages) ? $messages : [];
|
||||||
|
$messages[] = ['type' => $type, 'message' => $message];
|
||||||
|
$this->set('_flash', $messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{type:string,message:string}>
|
||||||
|
*/
|
||||||
|
public function takeFlashes(): array
|
||||||
|
{
|
||||||
|
$messages = $this->get('_flash', []);
|
||||||
|
$this->remove('_flash');
|
||||||
|
|
||||||
|
/** @var list<array{type:string,message:string}> */
|
||||||
|
return is_array($messages) ? array_values($messages) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmal-Token fuer die Ersteinrichtung.
|
||||||
|
*
|
||||||
|
* Ohne diesen Schutz gehoert eine frische Installation demjenigen, der
|
||||||
|
* sie als Erster aufruft - zwischen Upload und eigenem Erstaufruf steht
|
||||||
|
* der Setup-Assistent sonst jedem offen. Das Token liegt als Datei im
|
||||||
|
* Datenverzeichnis (ausserhalb des Webroots) und muss beim Einrichten
|
||||||
|
* abgetippt werden. Wer keinen Datei- oder Log-Zugriff auf den Server
|
||||||
|
* hat, kommt damit nicht an die Einrichtung.
|
||||||
|
*
|
||||||
|
* Nach abgeschlossener Einrichtung wird die Datei geloescht.
|
||||||
|
*/
|
||||||
|
final class SetupToken
|
||||||
|
{
|
||||||
|
private const FILENAME = 'setup-token.txt';
|
||||||
|
|
||||||
|
public function __construct(private readonly string $directory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return $this->directory . '/' . self::FILENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das aktuelle Token und erzeugt es beim ersten Aufruf.
|
||||||
|
*/
|
||||||
|
public function ensure(): string
|
||||||
|
{
|
||||||
|
$existing = $this->read();
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = strtoupper(bin2hex(random_bytes(8)));
|
||||||
|
$contents = <<<TEXT
|
||||||
|
Einrichtungs-Token fuer die Kurzlink-Verwaltung
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
{$token}
|
||||||
|
|
||||||
|
Dieses Token wird einmalig beim Anlegen des Administrator-Kontos
|
||||||
|
abgefragt. Danach wird diese Datei automatisch geloescht.
|
||||||
|
|
||||||
|
Erzeugt am: {$this->now()}
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||||
|
throw new RuntimeException(sprintf('Verzeichnis "%s" konnte nicht angelegt werden.', $this->directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@file_put_contents($this->path(), $contents . "\n", LOCK_EX) === false) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Das Einrichtungs-Token konnte nicht nach "%s" geschrieben werden. '
|
||||||
|
. 'Bitte Schreibrechte für das Datenverzeichnis prüfen.',
|
||||||
|
$this->path(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($this->path(), 0600);
|
||||||
|
|
||||||
|
// Damit es im Docker-Betrieb direkt in den Logs auftaucht.
|
||||||
|
error_log('[urlshort] Einrichtungs-Token: ' . $token . ' (Datei: ' . $this->path() . ')');
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(): bool
|
||||||
|
{
|
||||||
|
return $this->read() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleich in konstanter Zeit, Gross-/Kleinschreibung und
|
||||||
|
* Leerzeichen werden verziehen.
|
||||||
|
*/
|
||||||
|
public function matches(string $candidate): bool
|
||||||
|
{
|
||||||
|
$token = $this->read();
|
||||||
|
if ($token === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = strtoupper(preg_replace('/[\s-]+/', '', $candidate) ?? '');
|
||||||
|
|
||||||
|
return $candidate !== '' && hash_equals($token, $candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function discard(): void
|
||||||
|
{
|
||||||
|
$path = $this->path();
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
// Vor dem Loeschen ueberschreiben, damit das Token nicht in
|
||||||
|
// freigegebenen Bloecken zurueckbleibt.
|
||||||
|
@file_put_contents($path, str_repeat("\0", 64));
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function read(): ?string
|
||||||
|
{
|
||||||
|
$path = $this->path();
|
||||||
|
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = (string) @file_get_contents($path);
|
||||||
|
|
||||||
|
if (preg_match('/\b([0-9A-F]{16})\b/', strtoupper($contents), $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function now(): string
|
||||||
|
{
|
||||||
|
return date('d.m.Y H:i:s');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user