Repair two comment blocks the admin-hosts edit ran together
An earlier edit removed the ADMIN_HOSTS example line and left its "Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file documented neither properly. SECRETS_KEY was never in here at all — without it the console silently stores no credentials, which is exactly the kind of thing an example file exists to prevent.feat/granted-plans
parent
75b4a47768
commit
669d0471a8
18
.env.example
18
.env.example
|
|
@ -38,7 +38,7 @@ REDIS_HOST=redis
|
|||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
|
|
@ -157,17 +157,19 @@ KUMA_TOTP=
|
|||
MONITORING_REQUIRED=false
|
||||
MONITORING_ATTEMPTS=2
|
||||
|
||||
# ── Admin-Konsole: erlaubte Hostnamen ────────────────────────────────────
|
||||
# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar
|
||||
# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich);
|
||||
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
|
||||
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
|
||||
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
|
||||
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
|
||||
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
|
||||
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
|
||||
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
|
||||
# Erzeugen: head -c 32 /dev/urandom | base64
|
||||
VPN_CONFIG_KEY=
|
||||
|
||||
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
|
||||
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
|
||||
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
|
||||
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
|
||||
# Erzeugen: head -c 32 /dev/urandom | base64
|
||||
SECRETS_KEY=
|
||||
|
||||
# Netze, die als "wir" gelten, solange die Website versteckt ist
|
||||
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
|
||||
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A browser that has signed in before, and is therefore not news.
|
||||
*
|
||||
* Identified by a long-lived signed cookie carrying a random token, and by
|
||||
* nothing else. The two obvious alternatives both produce warnings nobody
|
||||
* reads: an IP changes on every train and every café, and a user-agent string
|
||||
* changes on every browser update — twelve false alarms a year, per customer,
|
||||
* from Chrome's release schedule alone.
|
||||
*
|
||||
* `name` is for the person reading the list. It is recomputed on every sign-in
|
||||
* so an upgraded browser relabels itself instead of raising an alarm, which
|
||||
* only works because the name is not what identifies anything.
|
||||
*
|
||||
* Scoped by guard as well as by id: operators and customers are two tables and
|
||||
* two guards (R21), and operator 1 is not customer 1.
|
||||
*/
|
||||
class UserDevice extends Model
|
||||
{
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'guard', 'authenticatable_id', 'token_hash', 'name', 'last_ip', 'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_seen_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function sessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(LoginSession::class, 'device_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sessions somebody can actually see and end, and the devices they belong to.
|
||||
*
|
||||
* Sessions lived in Redis, where they cannot be enumerated: one key per session
|
||||
* and no index by user, so "show me where I am signed in" and "end that one"
|
||||
* were not answerable at all. The database driver keeps a row per session and
|
||||
* makes ending one a delete.
|
||||
*
|
||||
* Two tables of our own on top of it, and not for decoration:
|
||||
*
|
||||
* `user_devices` is what makes a new-device warning trustworthy. Recognising a
|
||||
* device by IP means warning on every train journey; by user-agent means
|
||||
* warning on every browser update. Neither is a signal anybody keeps reading.
|
||||
* A device is a long-lived signed cookie carrying a random token — present and
|
||||
* known means known, absent means new, and nothing else is consulted.
|
||||
*
|
||||
* `login_sessions` is the link between the two, and it exists because Laravel's
|
||||
* own sessions.user_id cannot carry it: that column is filled from the DEFAULT
|
||||
* guard, while operators and customers are two guards and two tables (R21).
|
||||
* Operator 1 and customer 1 would both write user_id = 1, and each would be
|
||||
* offered the other's sessions to end.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Laravel's own, unchanged — the driver owns this table.
|
||||
//
|
||||
// Guarded: this database already had it, byte for byte and empty, with
|
||||
// no migration anywhere that creates it — left behind by an early
|
||||
// `session:table` whose file did not survive. Failing here would leave
|
||||
// the two tables below uncreated over a table that is already correct.
|
||||
if (! Schema::hasTable('sessions')) {
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
// Written by the framework from the default guard. Deliberately
|
||||
// NOT what anything here reads: see the note above.
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
Schema::create('user_devices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
|
||||
// The guard IS the type. One guard, one identity table (R21), so a
|
||||
// pair of columns says everything a polymorphic type column would
|
||||
// and stays readable in a query somebody runs at 3am.
|
||||
$table->string('guard', 32);
|
||||
$table->unsignedBigInteger('authenticatable_id');
|
||||
|
||||
// The cookie's token, hashed. Stored the way a password is, for the
|
||||
// same reason: a leaked database must not hand out working device
|
||||
// cookies that skip the new-device warning.
|
||||
$table->string('token_hash', 64)->unique();
|
||||
|
||||
// "Chrome auf macOS" — for the person reading the list, never for
|
||||
// deciding whether the device is known. Recomputed on each sign-in
|
||||
// so a browser upgrade updates the label instead of raising an alarm.
|
||||
$table->string('name');
|
||||
|
||||
$table->string('last_ip', 45)->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['guard', 'authenticatable_id']);
|
||||
});
|
||||
|
||||
Schema::create('login_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Kept INSIDE the session payload, which is what makes this robust:
|
||||
// Laravel rotates the session id on every sign-in and on every
|
||||
// regenerate(), so a row keyed only by session id loses track of
|
||||
// itself. The uuid travels with the session and the id is refreshed
|
||||
// from it on each request.
|
||||
$table->uuid()->unique();
|
||||
|
||||
$table->foreignId('device_id')->constrained('user_devices')->cascadeOnDelete();
|
||||
$table->string('session_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_sessions');
|
||||
Schema::dropIfExists('user_devices');
|
||||
|
||||
// `sessions` is deliberately left standing. This migration only creates
|
||||
// it where it was missing, so dropping it unconditionally would destroy
|
||||
// a table it never made — and on a rollback that is somebody already
|
||||
// having a bad day.
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue